Compare commits

...

525 Commits

Author SHA1 Message Date
fiatjaf
63ccc8b4c8 v2.3.1 2024-02-19 18:54:40 -03:00
fiatjaf
7cf7df88db nip46: skip duplicates on fetchBunkerProviders (prev fetchCustodialBunkers). 2024-02-19 18:54:18 -03:00
fiatjaf
bded539122 nip46: fix messages being ignored after auth_url. 2024-02-19 18:53:48 -03:00
fiatjaf
3647bbd68a get rid of the last vestiges of webcrypto dependencies. 2024-02-17 18:29:01 -03:00
fiatjaf
fb085ffdf7 v2.3.0 2024-02-17 18:19:52 -03:00
fiatjaf
280d483ef4 adjust expected value in nip11 test. 2024-02-17 18:19:09 -03:00
fiatjaf
54b55b98f1 nip44: get rid of ensureBytes() since it was removed from upstream library. 2024-02-17 18:18:24 -03:00
fiatjaf
84f9881812 use @noble/ciphers instead of webcrypto on nip04. 2024-02-17 18:15:42 -03:00
fiatjaf
db6baf2e6b bump to v2.2.1 2024-02-16 07:43:38 -03:00
fiatjaf
bb1e6f4356 nip46: only handle the first auth_url for every command. 2024-02-16 07:43:20 -03:00
fiatjaf
5626d3048b nip46: remove NostrConnectAdmin wrong kind. 2024-02-16 07:40:21 -03:00
fiatjaf
058d0276e2 nip49: nfkc normalization. 2024-02-16 00:13:58 -03:00
Sepehr Safari
37b046c047 bump to v2.2.0 2024-02-14 19:48:07 -03:00
Sepehr Safari
846654b449 add exports/nip75 to package.json 2024-02-14 19:48:07 -03:00
Sepehr Safari
b676dc0987 add tests for nip75 2024-02-14 19:48:07 -03:00
Sepehr Safari
b1ce901555 implement nip75 handlers 2024-02-14 19:48:07 -03:00
fiatjaf
62e5730965 call useWebSocketImplementation() on relay and pool tests. 2024-02-14 13:26:38 -03:00
fiatjaf
01f13292bb useWebSocketImplementation() on relay.ts 2024-02-14 13:19:48 -03:00
fiatjaf
7b0458db72 make the examples on the readme use the new import method. 2024-02-14 13:17:59 -03:00
fiatjaf
3aab7121f7 use a public BunkerPointer property on BunkerSigner class. 2024-02-14 12:29:47 -03:00
fiatjaf
ce059d4608 bump to v2.1.8 2024-02-14 12:25:22 -03:00
fiatjaf
b72b0dc1f0 nip46: fix typo in fetchCustodialBunkers() function name. 2024-02-14 12:24:47 -03:00
fiatjaf
29e5b71473 nip46: implement the remaining methods. 2024-02-14 12:24:33 -03:00
fiatjaf
b4e54d679f nip46: fix checking event that comes from bunker call sign_event and bump version. 2024-02-12 16:06:20 -03:00
fiatjaf
9d78c90a79 nip46: fix bunker url parsing. 2024-02-12 15:32:50 -03:00
fiatjaf
566a2deea3 nip46: omit custodial bunker providers that don't have relays configured. 2024-02-12 09:04:31 -03:00
fiatjaf
177e673d83 nip46: params for createAccount that get passed into BunkerSigner. 2024-02-12 09:04:06 -03:00
fiatjaf
cf766cd835 nip05: fix regex group 2 matching. 2024-02-12 07:41:44 -03:00
fiatjaf
7d332605ee nip46: tweaks. 2024-02-12 00:28:48 -03:00
fiatjaf
72f9b482ef nip46: NostrConnectAdmin kind and handle "auth_url". 2024-02-12 00:28:36 -03:00
fiatjaf
d14830a8ff nip46 big implementation adapted from ignition. 2024-02-11 19:14:04 -03:00
fiatjaf
943cc4fb48 nip46 beginnings. 2024-02-11 08:32:08 -03:00
fiatjaf
04252aaaec nip05: improve regex. 2024-02-11 07:48:13 -03:00
Shusui MOYATANI
8c78649d5c ignore HTTP redirect in nip05 2024-02-09 12:00:10 -03:00
Adam Soltys
b9435af708 remove tsd package 2024-02-06 08:13:33 -03:00
Quentin
ea5d00beed Fix explicit file extension for core.ts 2024-02-04 13:07:17 -03:00
Sepehr Safari
7ec6d127b0 fix unexpected errors 2024-01-28 06:45:37 -03:00
fiatjaf
7a9d432686 add nip49 key encryption and decryption. 2024-01-25 12:14:51 -03:00
fiatjaf
744a930ccf add missing exports to package.json. 2024-01-25 12:13:59 -03:00
fiatjaf
c6a521e73c some nip29 helpers. 2024-01-24 15:56:44 -03:00
fiatjaf
6aebe0d38c v2.1.5 2024-01-24 15:42:35 -03:00
fiatjaf
16cdf40112 nip96: type fix. 2024-01-24 12:04:54 -03:00
fiatjaf
e36ea11f41 add nip-34 code for contributing. 2024-01-24 09:32:36 -03:00
fiatjaf
31a35a8008 justfile: always emit types on build. 2024-01-24 09:32:36 -03:00
Sepehr Safari
0f5b3f397c Nip96 implementation (#360)
* add nip96 kind 10096 file server preference

* implement nip96

* refactor nip96 and liftup all type definitions

* install nock as devDep

* fix nip96 throwing errors

* add tests for nip96

* revert installing nock and install msw for mocking apis

* fix trailing slashes in nip96 file deletion

* implement msw in nip96 and add more test cases

* fix fetching server config

* enhance error handling in uploadFile

* add more test cases with mock apis

* add more test cases to reach 90 percent coverage
2024-01-24 09:24:47 -03:00
Sepehr Safari
d156f3c0ac add test cases for nip94 2024-01-21 07:32:13 -03:00
Sepehr Safari
d656c84ab5 implement nip94 2024-01-21 07:32:13 -03:00
fiatjaf
2f0ef90bd5 delete some unnecessary code from mock-relay implementation. 2024-01-20 12:48:46 -03:00
fiatjaf
967d7fe63a normalizeURL prepends ws:// when necessary. 2024-01-20 12:48:28 -03:00
fiatjaf_
12147d4fee Merge pull request #358 from sepehr-safari/mock-relay-class
Enhance Mock Relay
2024-01-20 08:36:40 -03:00
Sepehr Safari
c453bc5ec3 revert nip11.test.ts with a todo flag 2024-01-20 11:50:05 +03:30
Sepehr Safari
2017b3cabd Merge branch 'nbd-wtf:master' into mock-relay-class 2024-01-20 11:41:27 +03:30
Sepehr Safari
fbcfccda01 update nip42.test.ts with new mock relay class 2024-01-20 11:31:18 +03:30
Sepehr Safari
0357e035f4 fix nip11 broken test 2024-01-20 11:29:59 +03:30
Sepehr Safari
dd0014aee3 refactor pool.test.ts and update with new mock relay class 2024-01-20 11:29:46 +03:30
Sepehr Safari
2e9798b8ab increase random range for mock relay urls 2024-01-20 11:21:29 +03:30
Sepehr Safari
10b800db3a randomize relay urls in mock relays 2024-01-20 11:14:57 +03:30
Sepehr Safari
dbad25b2fa use new MockRelay class in relay.test.ts 2024-01-20 10:41:05 +03:30
Sepehr Safari
829633b0d6 inhance mock relay and refactor to a class 2024-01-20 10:40:15 +03:30
Sepehr Safari
b1bbcd6c46 use mock relay in nip42 tests 2024-01-20 09:57:25 +03:30
fiatjaf
6a9940c850 nip29: make relay property mandatory on Group. 2024-01-19 21:23:44 -03:00
fiatjaf
9b08550885 some beginnings of nip29 helpers. 2024-01-19 16:13:00 -03:00
fiatjaf
3b81e5e762 use mock relays on pool tests. 2024-01-19 16:12:02 -03:00
fiatjaf
8b2b050c0d unify mock-socket interface into a single implementation. 2024-01-19 16:01:06 -03:00
Sepehr Safari
d4090dae2b refactor relay test cases with mock websocket 2024-01-19 15:32:36 -03:00
Sepehr Safari
49596d24c3 install mock-socket as dev dependency 2024-01-19 15:32:36 -03:00
Sepehr Safari
ac83eeff1c format with prettier 2024-01-18 11:51:13 -03:00
Sepehr Safari
85b741b39a suppress eqeqeq eslint rule 2024-01-18 11:50:35 -03:00
fiatjaf_
c69c528ab0 Merge pull request #352 from sepehr-safari/nip99-implementation
Nip99 implementation
2024-01-18 11:00:41 -03:00
fiatjaf_
1aad9ad0bd Merge pull request #350 from sepehr-safari/nip98-enhancement
Nip98 enhancement
2024-01-18 11:00:28 -03:00
Sepehr Safari
f6ed374f2f remove un-used imports 2024-01-18 17:16:24 +03:30
Sepehr Safari
6d7ad22677 add test cases for nip99 2024-01-18 17:13:39 +03:30
Sepehr Safari
340a4a6799 implement nip99 2024-01-18 17:13:31 +03:30
Sepehr Safari
5ec136a365 refactor and add more test cases 2024-01-17 18:13:10 +03:30
Sepehr Safari
75eb08b170 fix some bugs, refactor to smaller parts, add docs 2024-01-17 17:50:48 +03:30
Alex Gleason
677b679c2c NIP-57: build lnurl in more secure way 2024-01-15 21:26:34 -03:00
fiatjaf
7b79d6a899 nostr-wasm as optional and v2.1.3 2024-01-09 16:58:47 -03:00
Alex Gleason
c1efbbd919 Add NIP-40 module for event expiration 2024-01-09 16:16:43 -03:00
Akiomi Kamakura
7d58705e9a Fix typo 2024-01-08 13:50:48 -03:00
Akiomi Kamakura
f1d315632c Sort kinds 2024-01-08 13:50:36 -03:00
Alex Gleason
348d118ce4 Add getFilterLimit function 2024-01-04 09:56:02 -03:00
fiatjaf
498c1603b0 nip57: implement "P" tag for sender. 2024-01-01 11:39:22 -03:00
Shusui MOYATANI
4cfc67e294 fix yieldThread memory leak 2023-12-30 13:50:44 -03:00
fiatjaf
da51418f04 update readme example.
fixes https://github.com/nbd-wtf/nostr-tools/issues/337
2023-12-27 11:14:19 -03:00
fiatjaf
75df47421f v2.1.1 2023-12-26 07:57:36 -03:00
fiatjaf
1cfe705baf auth() returns a promise that resolves on OK.
fixes https://github.com/nbd-wtf/nostr-tools/issues/336
2023-12-26 07:56:55 -03:00
fiatjaf
566437fe2e nip19: length 0 on TLV is not forbidden. 2023-12-26 07:56:55 -03:00
fiatjaf
5d6c2b9e5d nip19: reverse TLV ordering just to keep other implementations honest. 2023-12-26 07:56:55 -03:00
jiftechnify
a43f2a708c fix problem when required from CommonJS 2023-12-23 08:31:39 -03:00
fiatjaf
f727058a3a rename benchmark.ts -> benchmarks.ts 2023-12-22 11:48:07 -03:00
fiatjaf
1de54838d3 changed the relay in the test, must also change the event queried for. 2023-12-22 11:46:02 -03:00
fiatjaf
703c29a311 fix things so relays tests work. 2023-12-22 11:38:35 -03:00
fiatjaf
ddf1064da9 adjust benchmarks to be done in a more realistic scenario. 2023-12-22 11:11:04 -03:00
fiatjaf
f719d99a11 rename this._push to this._onmessage and use it internally. 2023-12-22 10:54:03 -03:00
fiatjaf
6152238d65 update nostr-wasm to fix memory leak bug. 2023-12-22 10:53:08 -03:00
fiatjaf
9ac1b63994 a test on pool subscribing to many relays, getting many events then closing on eose. 2023-12-22 08:21:58 -03:00
fiatjaf
1890c91ae3 comments. 2023-12-22 08:02:39 -03:00
fiatjaf
7067b47cd4 remove last remains of pool-pure.ts 2023-12-22 07:51:17 -03:00
fiatjaf
397931f847 mention benchmark results in readme. 2023-12-22 06:59:32 -03:00
fiatjaf
5d795c291f fix relay.ts imports after 7f11c0c618. 2023-12-22 06:58:01 -03:00
fiatjaf
7adbd30799 streamline and improve benchmarks. 2023-12-22 06:57:23 -03:00
fiatjaf
83b6dd7ec3 remove pool-wasm.ts that I had forgotten. 2023-12-21 21:04:27 -03:00
fiatjaf
d61cc6c9bf just benchmark 2023-12-21 20:59:45 -03:00
fiatjaf
d7dad8e204 reduce spaces on justfile. 2023-12-21 20:50:42 -03:00
fiatjaf
daaa2ef0a1 bring back relayConnect() as deprecated. 2023-12-21 19:59:12 -03:00
fiatjaf
7f11c0c618 unsplit, backwards-compatibility, wasm relay and pool must be configured manually from the abstract classes. 2023-12-21 19:57:28 -03:00
fiatjaf
a4ae964ee6 split relay and pool into pure and wasm modules. 2023-12-21 17:27:42 -03:00
fiatjaf
1f7378ca49 import from core.ts instead of pure.ts whenever possible. 2023-12-21 17:27:32 -03:00
fiatjaf
d155bcdcda tag v2.0.3 2023-12-21 17:27:25 -03:00
Shusui MOYATANI
919d29363e export kinds 2023-12-21 16:42:30 -03:00
Shusui MOYATANI
ef12a451be fix ensureRelay 2023-12-21 16:42:00 -03:00
fiatjaf
a9acdada19 fix nip-42 test await. 2023-12-21 08:56:03 -03:00
Jon Staab
bf3818e434 Add nip44 v2 2023-12-21 08:55:23 -03:00
jiftechnify
b7389be5c7 correctly wait until connection to a relay is established 2023-12-20 14:43:33 -03:00
Asai Toshiya
7552a36ff2 Update README.md 2023-12-20 13:41:52 -03:00
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
fiatjaf
74a0d5454a guard against some nonexisting arrays of event listeners. 2022-12-23 15:18:23 -03:00
fiatjaf
c0d1e41424 always recompute the hash when signing.
fixes https://github.com/fiatjaf/nostr-tools/issues/59
2022-12-23 15:06:21 -03:00
fiatjaf
f7e510e1c8 nip05 regex name check. 2022-12-23 15:04:24 -03:00
fiatjaf
c08bdac7a7 catch usage of global fetch for nodejs.
fixes https://github.com/fiatjaf/nostr-tools/issues/53
2022-12-23 11:36:37 -03:00
rkfg
c5b64404f6 Add limit to filter 2022-12-23 11:29:38 -03:00
adamritter
c7b26fdba2 Don't expose external API to hex representation of mnemoic 2022-12-23 11:01:10 -03:00
fiatjaf
ac698ef67d make relay.connect() an awaitable thing. 2022-12-22 08:53:40 -03:00
fiatjaf
8262a81cb2 make crypto available as a global on nip04 test. 2022-12-21 17:12:50 -03:00
fiatjaf
26e6da6ba3 we need websocket polyfill on relay tests. 2022-12-21 17:09:00 -03:00
fiatjaf
8aa31bb437 remove websocket-polyfill, instruct nodejs users to install it manually. 2022-12-21 16:23:47 -03:00
fiatjaf
4bd4469357 remove useless readable-stream dependency. 2022-12-21 16:19:59 -03:00
fiatjaf
89ae21f796 remove buffer usage everywhere. 2022-12-21 16:04:09 -03:00
fiatjaf
41a1614d89 remove browserify-cipher, use crypto.subtle for nip04. 2022-12-21 16:04:00 -03:00
fiatjaf
0500415a4e remove all the auto-reconnection code from relay. 2022-12-21 15:31:57 -03:00
fiatjaf
cee4357cab Merge pull request #50 from mmalmi/patch-1 2022-12-21 08:50:29 -03:00
Sandwich
d5cf5930d1 Fix example code in readme, resolves #47 2022-12-21 08:44:52 -03:00
Martti Malmi
a78e2036aa status code 3 (closed) for un-opened connection 2022-12-21 11:15:36 +02:00
Martti Malmi
adc1854ac6 relay.status() returns 0 when ws not created 2022-12-21 11:08:10 +02:00
fiatjaf
83148e8bdf fix small things in README. 2022-12-20 22:34:19 -03:00
fiatjaf
364c37cac5 fix autopublishing to npm. 2022-12-20 20:15:43 -03:00
fiatjaf
385cdb4ac6 README examples for nip05 and nip19. 2022-12-20 18:42:24 -03:00
fiatjaf
3f1025f551 nip05.queryProfile() and test. 2022-12-20 18:36:49 -03:00
fiatjaf
482c5affd4 add nip19. 2022-12-20 18:26:30 -03:00
fiatjaf
679ac0c133 fix standalone script URL. 2022-12-20 17:01:35 -03:00
fiatjaf
b96159ad36 better publishing built files. 2022-12-20 16:56:05 -03:00
fiatjaf
6dede4a688 use semisol relay that has our desired event on test. 2022-12-20 16:26:55 -03:00
fiatjaf
50c8bb72f9 v1.0.0-alpha 2022-12-20 16:16:59 -03:00
fiatjaf
72781e0eab nip05 typescript fixes. 2022-12-20 16:16:59 -03:00
fiatjaf
bf120c1348 relay examples on README. 2022-12-20 16:16:59 -03:00
fiatjaf
3630d377e5 test every commit on github actions. 2022-12-20 16:16:59 -03:00
fiatjaf
53b0091bf4 some fixes on relay.ts and tests. 2022-12-20 15:25:34 -03:00
fiatjaf
1a7cc5f21f updated readme with nicer examples. 2022-12-19 20:13:08 -03:00
fiatjaf
1162935f58 add a bunch of tests. 2022-12-19 20:02:01 -03:00
fiatjaf
a49d971f6a reorganize index.ts to use "export *". 2022-12-19 19:51:38 -03:00
fiatjaf
897919be3b get rid of create-hash, use noble hashes. 2022-12-19 19:50:41 -03:00
fiatjaf
39aca167fb build for commonjs, esm and a standalone bundle. 2022-12-19 15:46:31 -03:00
fiatjaf
de8bdd8370 fix typescript types everywhere, delete pool.js and refactor relay.js to use event listeners everywhere. 2022-12-18 17:02:19 -03:00
Íñigo Aréjula Aísa
46a0a342db event fields (#37) 2022-12-17 13:09:25 -03:00
Leo Wandersleb
4fe2a9c91a use default fetch if in service worker (#23) 2022-12-17 13:08:59 -03:00
fiatjaf
e62b833464 Merge pull request #41 from monlovesmango/cb-api
refactor of cb api
2022-12-17 13:06:30 -03:00
monica
100c77d2aa finalize cb api 2022-12-07 22:26:51 -06:00
Íñigo Aréjula Aísa
12be5a5338 Fix tag type
I realized that tags were an array of array, if it is correct merge, if im wrong just discard
2022-12-05 16:40:47 -03:00
monica
b955ba2a09 initial refactor of cb api 2022-12-04 21:57:15 -06:00
Íñigo Aréjula Aísa
ec805be4ab expose nip 4 functions to TS (#39) 2022-11-30 19:41:10 -03:00
Íñigo Aréjula Aísa
92fb339afb Update relay.js 2022-11-27 13:57:21 +01:00
Íñigo Aréjula Aísa
f8f125270a fix return value getRelayList 2022-11-26 11:16:20 -03:00
Íñigo Aréjula Aísa
1b798b2eee Expose relay funcs (#31) 2022-11-25 23:21:33 -03:00
Íñigo Aréjula Aísa
ae717a1a4a Documentation pool.js (#30) 2022-11-25 23:20:22 -03:00
Íñigo Aréjula Aísa
b2015c8fe5 Filter type with optional atributtes 2022-11-25 13:03:45 -03:00
fiatjaf
c5d2e3b037 github action to publish to npm on tag. 2022-11-21 20:26:58 -03:00
fiatjaf
0ef5d1e19c Merge pull request #27 from monlovesmango/Expose-EOSE-Relay-URL 2022-11-21 20:24:01 -03:00
monlovesmango
7d9d10fdb1 add relay url arg to eoseCb 2022-11-21 16:11:30 -06:00
monlovesmango
a1e1ce131a include eoseCb in sub 2022-11-21 16:09:23 -06:00
Fred
cdb07bb175 replace micro-bip with @scure/bip, fix #25 2022-10-24 06:16:11 -03:00
Leo Wandersleb
1f1bcff803 EOSE nip-15 2022-09-29 07:23:48 -03:00
fiatjaf
896af30619 release v0.24.1 2022-09-06 15:19:29 -03:00
bjong
a8542c4b56 fix: CJK characters are garbled after decryption 2022-09-06 15:17:50 -03:00
fiatjaf
9f9e822c6d allow skipping signature verification. 2022-08-05 16:36:27 -03:00
Lennon Day-Reynolds
821a8f7895 TypeScript definitions (#18) 2022-07-15 15:49:49 -03:00
fiatjaf
2f7e3f8473 bump version. 2022-06-22 20:08:48 -03:00
monlovesmango
536dbcbffe Update pool.js 2022-06-22 20:07:25 -03:00
monlovesmango
ed52d2a8d4 updating cb property for subControllers entries
when updating subscription or adding new relays, subsequent events that are received have the relay as undefined. by updating cb property for the subControllers entries to be an arrow function (when calling sub.sub or sub.addRelay), subsequent events now return the relay url appropriately
2022-06-22 20:07:25 -03:00
fiatjaf
faf8e62120 maybe fix a bug with calling sub.sub() 2022-06-04 18:34:54 -03:00
fiatjaf
dc489bf387 build esm module that can be imported from browsers.
closes https://github.com/fiatjaf/nostr-tools/issues/14
2022-05-08 20:49:36 -03:00
Ricardo Arturo Cabral Mejia
60ce13e17d chore: bump version to 0.23.0 2022-04-10 19:51:35 -03:00
Ricardo Arturo Cabral Mejia
727bcb05a8 feat: add beforeSend hook to sub() 2022-04-10 19:51:35 -03:00
monlovesmango
c236e41f80 import 'Buffer'
'Buffer' wasn't imported initially and was causing issues when I tried to use generatePrivateKey in a client I am building. not sure why Branle has no error, maybe I am doing something wrong?
2022-04-06 18:34:50 -03:00
fiatjaf
f04bc0cee1 fix filter on statusCallback: id -> ids 2022-02-15 21:03:44 -03:00
fiatjaf
e63479ee7f nip05 more strict. enforce the presence of "_" for domain names. 2022-02-12 20:37:23 -03:00
fiatjaf
c47f091d9b update noble secp256k1 and ensure we always return hex. 2022-02-11 16:27:23 -03:00
Melvin Carvalho
4c785279bc remove => from onEvent function in README.md. 2022-02-03 09:31:03 -03:00
fiatjaf
6786641b1d are you kidding me? 2022-01-25 17:06:26 -03:00
fiatjaf
0396db5ed6 nip04 string key is actually x and y, so we must get only 32 bytes of x. 2022-01-25 16:25:10 -03:00
fiatjaf
0c8e7a74f5 fix previous commit because noble is returning different values depending on [unknown], sometimes uint8array, sometimes hex. 2022-01-25 15:41:49 -03:00
fiatjaf
c66a2acda1 encrypt uint8array to hex. 2022-01-24 21:00:51 -03:00
fiatjaf
6f07c756e5 change nip04 functions interfaces. 2022-01-24 20:21:26 -03:00
fiatjaf
f6bcda8d8d support _ names in nip05. 2022-01-17 17:12:48 -03:00
fiatjaf
4b666e421b update nip05 to well-known version. 2022-01-17 16:37:19 -03:00
fiatjaf
454366f6a2 allow signing events with a custom signing function on pool.publish() 2022-01-12 22:32:45 -03:00
fiatjaf
3d6f9a41e0 prevent blocking waiting times on publish (unless "wait" is set in the pool policy). 2022-01-12 17:39:24 -03:00
fiatjaf
e3631ba806 fix and update nip06. 2022-01-06 21:46:34 -03:00
fiatjaf
89f11e214d fix filter matching for tags. 2022-01-02 19:46:19 -03:00
fiatjaf
bb09e25512 fix tag in matchFilter for kinds and ids. 2022-01-01 21:18:37 -03:00
fiatjaf
1b5c314436 nip-01 update: everything as arrays on filters. 2022-01-01 20:49:05 -03:00
fiatjaf
2230f32d11 use randomBytes from @noble/hashes. 2022-01-01 14:59:12 -03:00
98 changed files with 11011 additions and 678 deletions

9
.editorconfig Normal file
View File

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

View File

@@ -1,5 +1,10 @@
{
"root": true,
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"],
"parserOptions": {
"ecmaVersion": 9,
"ecmaFeatures": {
@@ -14,14 +19,11 @@
"node": true
},
"plugins": [
"babel"
],
"globals": {
"document": false,
"navigator": false,
"window": false,
"crypto": false,
"location": false,
"URL": false,
"URLSearchParams": false,
@@ -43,9 +45,7 @@
"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)$" ],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
@@ -100,7 +100,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,
@@ -117,7 +116,7 @@
"no-unexpected-multiline": 2,
"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,

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

@@ -0,0 +1,24 @@
name: test every commit
on:
push:
branches:
- master
pull_request:
jobs:
test:
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 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

4
.gitignore vendored
View File

@@ -2,3 +2,7 @@ node_modules
dist
yarn.lock
package-lock.json
.envrc
lib
test.html
bench.js

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>

323
README.md
View File

@@ -1,72 +1,269 @@
# 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.
## Usage
Only depends on _@scure_ and _@noble_ packages.
```js
import {relayPool} from 'nostr-tools'
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
const pool = relayPool()
## Installation
pool.setPrivateKey('<hex>') // optional
pool.addRelay('ws://some.relay.com', {read: true, write: true})
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
// example callback function for a subscription
function onEvent(event, relay) => {
console.log(`got an event from ${relay.url} which is already validated.`, event)
}
// subscribing to a single user
// author is the user's public key
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
// or bulk follow
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
// reuse a subscription channel
const mySubscription = pool.sub({cb: ..., filter: ....})
mySubscription.sub({filter: ....})
mySubscription.sub({cb: ...})
mySubscription.unsub()
// get specific event
const specificChannel = pool.sub({
cb: (event, relay) => {
console.log('got specific event from relay', event, relay)
specificChannel.unsub()
},
filter: {id: '<hex>'}
})
// or get a specific event plus all the events that reference it in the 'e' tag
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
// get all events
pool.sub({cb: (event, relay) => {...}, filter: {}})
// get recent events
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
// publishing events(inside an async function):
const ev = await pool.publish(eventObject, (status, url) => {
if (status === 0) {
console.log(`publish request sent to ${url}`)
}
if (status === 1) {
console.log(`event published by ${url}`, ev)
}
})
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
// subscribing to a new relay
pool.addRelay('<url>')
// will automatically subscribe to the all the events called with .sub above
```bash
npm install nostr-tools # or yarn add nostr-tools
```
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
If using TypeScript, this package requires TypeScript >= 5.0.
For other utils please read the source (for now).
## Usage
### Generating a private key and a public key
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
let sk = generateSecretKey() // `sk` is a Uint8Array
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
let event = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
}, sk)
let isGood = verifyEvent(event)
```
### Interacting with a relay
```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
const relay = await Relay.connect('wss://relay.example.com')
console.log(`connected to ${relay.url}`)
// let's query for an event that exists
const sub = relay.subscribe([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
], {
onevent(event) {
console.log('we got the event we wanted:', event)
},
oneose() {
sub.close()
}
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey()
let pk = getPublicKey(sk)
relay.sub([
{
kinds: [1],
authors: [pk],
},
], {
onevent(event) {
console.log('got event:', event)
}
})
let eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world',
}
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent)
relay.close()
```
To use this on Node.js you first must install `ws` and call something like this:
```js
import { useWebSocketImplementation } from 'nostr-tools/relay'
useWebSocketImplementation(require('ws'))
```
### Interacting with multiple relays
```js
import { SimplePool } from 'nostr-tools/pool'
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/references'
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 { queryProfile } from 'nostr-tools/nip05'
let profile = await queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js
import { useFetchImplementation } from 'nostr-tools/nip05'
useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
let sk = generateSecretKey()
let nsec = nip19.nsecEncode(sk)
let { type, data } = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generateSecretKey())
let npub = nip19.npubEncode(pk)
let { type, data } = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
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)
```
### 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
```
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
```js
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
import { initNostrWasm } from 'nostr-wasm'
initNostrWasm().then(setNostrWasm)
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
const pool = new AbstractSimplePool({ verifyEvent })
```
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
```
benchmark time (avg) (min … max) p75 p99 p995
------------------------------------------------- -----------------------------
• relay read message and verify event (many events)
------------------------------------------------- -----------------------------
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
summary for relay read message and verify event
wasm
86.77x slower than trusted
6.86x faster than pure js
```
### 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.generateSecretKey('...') // and so on
</script>
```
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
## License
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.
## Contributing to this repository
Use NIP-34 to send your patches to `naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq`.

190
abstract-pool.ts Normal file
View File

@@ -0,0 +1,190 @@
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts'
import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: () => void }
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
maxWait?: number
onclose?: (reasons: string[]) => void
id?: string
}
export class AbstractSimplePool {
private relays = new Map<string, AbstractRelay>()
public seenOn = new Map<string, Set<AbstractRelay>>()
public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent']
public trustedRelayURLs = new Set<string>()
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
this.verifyEvent = opts.verifyEvent
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
url = normalizeURL(url)
let relay = this.relays.get(url)
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
})
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
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: AbstractRelay, 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: AbstractRelay
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)
})
}
}

369
abstract-relay.ts Normal file
View File

@@ -0,0 +1,369 @@
/* global WebSocket */
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
}
export class AbstractRelay {
public readonly url: string
private _connected: 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
public openSubs = new Map<string, Subscription>()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined
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
private verifyEvent: Nostr['verifyEvent']
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent
}
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
const relay = new AbstractRelay(url, opts)
await relay.connect()
return relay
}
private closeAllSubscriptions(reason: string) {
for (let [_, sub] of this.openSubs) {
sub.close(reason)
}
this.openSubs.clear()
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()
}
this.ws.onerror = ev => {
reject((ev as any).message)
if (this._connected) {
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
this._connected = false
}
}
this.ws.onclose = async () => {
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
this._connected = false
}
this.ws.onmessage = this._onmessage.bind(this)
})
return this.connectionPromise
}
private async runQueue() {
this.queueRunning = true
while (true) {
if (false === this.handleNext()) {
break
}
await yieldThread()
}
this.queueRunning = false
}
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.verifyEvent(event) && matchFilters(so.filters, event)) {
so.onevent(event)
}
return
}
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)
}
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)
return
}
case 'NOTICE':
this.onnotice(data[1] as string)
return
case 'AUTH': {
this.challenge = data[1] as string
return
}
}
} 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: (evt: EventTemplate) => Promise<VerifiedEvent>) {
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(evt.id, { resolve, reject })
})
this.send('["AUTH",' + JSON.stringify(evt) + ']')
return ret
}
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()
}
// this is the function assigned to this.ws.onmessage
// it's exposed for testing and debugging purposes
public _onmessage(ev: MessageEvent<any>) {
this.incomingMessageQueue.enqueue(ev.data as string)
if (!this.queueRunning) {
this.runQueue()
}
}
}
export class Subscription {
public readonly relay: AbstractRelay
public readonly id: string
public closed: boolean = false
public eosed: boolean = false
public filters: Filter[]
public alreadyHaveEvent: ((id: string) => boolean) | undefined
public receivedEvent: ((relay: AbstractRelay, 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: AbstractRelay, 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.relay.openSubs.delete(this.id)
this.onclose?.(reason)
}
}
export type SubscriptionParams = {
onevent?: (evt: Event) => void
oneose?: () => void
onclose?: (reason: string) => void
alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: AbstractRelay, 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
}

61
benchmarks.ts Normal file
View File

@@ -0,0 +1,61 @@
import { run, bench, group, baseline } from 'mitata'
import { initNostrWasm } from 'nostr-wasm'
import { NostrEvent } from './core'
import { finalizeEvent, generateSecretKey } from './pure'
import { setNostrWasm, verifyEvent } from './wasm'
import { AbstractRelay } from './abstract-relay.ts'
import { Relay as PureRelay } from './relay.ts'
import { alwaysTrue } from './helpers.ts'
// benchmarking relay reads with verifyEvent
const EVENTS = 200
let messages: string[] = []
let baseContent = ''
for (let i = 0; i < EVENTS; i++) {
baseContent += 'a'
}
const secretKey = generateSecretKey()
for (let i = 0; i < EVENTS; i++) {
const tags = []
for (let t = 0; t < i; t++) {
tags.push(['t', 'nada'])
}
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
const signed = finalizeEvent(event, secretKey)
messages.push(JSON.stringify(['EVENT', '_', signed]))
}
setNostrWasm(await initNostrWasm())
const pureRelay = new PureRelay('wss://pure.com/')
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
const runWith = (relay: AbstractRelay) => async () => {
return new Promise<void>(resolve => {
let received = 0
let sub = relay.prepareSubscription([{}], {
id: '_',
onevent(_: NostrEvent) {
received++
if (received === messages.length - 1) {
resolve()
sub.closed = true
sub.close()
}
},
})
for (let e = 0; e < messages.length; e++) {
relay._onmessage({ data: messages[e] } as any)
}
})
}
group(`relay read ${EVENTS} messages and verify its events`, () => {
baseline('wasm', runWith(wasmRelay))
bench('pure js', runWith(pureRelay))
bench('trusted', runWith(trustedRelay))
})
// actually running the thing
await run()

60
build.js Executable file
View File

@@ -0,0 +1,60 @@
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 !== 'benchmarks.ts' &&
!file.endsWith('.test.ts') &&
fs.statSync(join(process.cwd(), file)).isFile(),
)
let common = {
entryPoints,
bundle: true,
sourcemap: 'external',
}
esbuild
.build({
...common,
outdir: 'lib/esm',
format: 'esm',
packages: 'external',
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outdir: 'lib/cjs',
format: 'cjs',
packages: 'external',
})
.then(() => {
const packageJson = JSON.stringify({ type: 'commonjs' })
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
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": {}}',
},
})
.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)
})
})

51
core.ts Normal file
View File

@@ -0,0 +1,51 @@
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 NostrEvent = Event
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,56 +0,0 @@
import {Buffer} from 'buffer'
import createHash from 'create-hash'
import * as secp256k1 from '@noble/secp256k1'
export function getBlankEvent() {
return {
kind: 255,
pubkey: null,
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt) {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event) {
let eventHash = createHash('sha256')
.update(Buffer.from(serializeEvent(event)))
.digest()
return Buffer.from(eventHash).toString('hex')
}
export function validateEvent(event) {
if (event.id !== getEventHash(event)) return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event) {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event, key) {
return 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,26 +0,0 @@
export function matchFilter(filter, event) {
if (filter.id && event.id !== filter.id) return false
if (typeof filter.kind === 'number' && event.kind !== filter.kind) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
if (
filter['#e'] &&
!event.tags.find(([t, v]) => t === 'e' && v === filter['#e'])
)
return false
if (
filter['#p'] &&
!event.tags.find(([t, v]) => t === 'p' && v === filter['#p'])
)
return false
if (filter.since && event.created_at <= filter.since) return false
return true
}
export function matchFilters(filters, event) {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

267
filter.test.ts Normal file
View File

@@ -0,0 +1,267 @@
import { describe, test, expect } from 'bun:test'
import { getFilterLimit, 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 })
})
})
describe('getFilterLimit', () => {
test('should handle ids', () => {
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
})
test('should count the authors times replaceable kinds', () => {
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
})
test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
})
test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity)
})
})
})

88
filter.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts'
export type Filter = {
ids?: string[]
kinds?: number[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[] | undefined
}
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) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at > filter.until) return false
return true
}
export function matchFilters(filters: Filter[], 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
}
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0
if (filter.authors && !filter.authors.length) return 0
return Math.min(
Math.max(0, filter.limit ?? Infinity),
filter.ids?.length ?? Infinity,
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
)
}

21
helpers.ts Normal file
View File

@@ -0,0 +1,21 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() {
return new Promise<void>(resolve => {
const ch = new MessageChannel()
const handler = () => {
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
ch.port1.removeEventListener('message', handler)
resolve()
}
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', handler)
ch.port2.postMessage(0)
ch.port1.start()
})
}
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
t[verifiedSymbol] = true
return true
}

View File

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

28
index.ts Normal file
View File

@@ -0,0 +1,28 @@
export * from './pure.ts'
export * from './relay.ts'
export * from './filter.ts'
export * from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as 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 nip44 from './nip44.ts'
export * as nip47 from './nip47.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

30
justfile Normal file
View File

@@ -0,0 +1,30 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
build:
rm -rf lib
bun run build.js
tsc
test:
bun test --timeout 20000
test-only file:
bun test {{file}}
publish: build
npm publish
format:
eslint --ext .ts --fix *.ts
prettier --write *.ts
lint:
eslint --ext .ts *.ts
prettier --check *.ts
benchmark:
bun build --target=node --outfile=bench.js benchmarks.ts
timeout 60s deno run --allow-read bench.js || true
timeout 60s node bench.js || true
timeout 60s bun run benchmarks.ts || true
rm bench.js

View File

@@ -1,9 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
export function generatePrivateKey() {
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
}
export function getPublicKey(privateKey) {
return 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')
})

106
kinds.ts Normal file
View File

@@ -0,0 +1,106 @@
/** 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 EncryptedDirectMessages = 4
export const EventDeletion = 5
export const Repost = 6
export const Reaction = 7
export const BadgeAward = 8
export const GenericRepost = 16
export const ChannelCreation = 40
export const ChannelMetadata = 41
export const ChannelMessage = 42
export const ChannelHideMessage = 43
export const ChannelMuteUser = 44
export const OpenTimestamps = 1040
export const FileMetadata = 1063
export const LiveChatMessage = 1311
export const ProblemTracker = 1971
export const Report = 1984
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 ZapRequest = 9734
export const Zap = 9735
export const Highlights = 9802
export const Mutelist = 10000
export const Pinlist = 10001
export const RelayList = 10002
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 FileServerPreference = 10096
export const NWCWalletInfo = 13194
export const LightningPubRPC = 21000
export const ClientAuth = 22242
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 BadgeDefinition = 30009
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,39 +0,0 @@
import aes from 'browserify-cipher'
import {Buffer} from 'buffer'
import randomBytes from 'randombytes'
import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
iv
)
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
}
export function decrypt(privkey, pubkey, ciphertext, iv) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
var decipher = aes.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(ciphertext, 'base64')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getOnlyXFromFullSharedSecret(fullSharedSecretCoordinates) {
return fullSharedSecretCoordinates.substr(2, 64)
}

41
nip04.test.ts Normal file
View File

@@ -0,0 +1,41 @@
import { test, expect } from 'bun:test'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
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)
})

40
nip04.ts Normal file
View File

@@ -0,0 +1,40 @@
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { cbc } from '@noble/ciphers/aes'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
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 ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
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 iv = base64.decode(ivb64)
let ciphertext = base64.decode(ctb64)
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
return utf8Decoder.decode(plaintext)
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

View File

@@ -1,52 +0,0 @@
import {Buffer} from 'buffer'
import dnsPacket from 'dns-packet'
const dohProviders = [
'cloudflare-dns.com',
'fi.doh.dns.snopyta.org',
'basic.bravedns.com',
'hydra.plan9-ns1.com',
'doh.pl.ahadns.net',
'dns.flatuslifir.is',
'doh.dns.sb',
'doh.li'
]
let counter = 0
export async function keyFromDomain(domain) {
let host = dohProviders[counter % dohProviders.length]
let buf = dnsPacket.encode({
type: 'query',
id: Math.floor(Math.random() * 65534),
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
type: 'TXT',
name: `_nostrkey.${domain}`
}
]
})
let fetching = fetch(`https://${host}/dns-query`, {
method: 'POST',
headers: {
'Content-Type': 'application/dns-message',
'Content-Length': Buffer.byteLength(buf)
},
body: buf
})
counter++
try {
let response = Buffer.from(await (await fetching).arrayBuffer())
let {answers} = dnsPacket.decode(response)
if (answers.length === 0) return null
return Buffer.from(answers[0].data[0]).toString()
} catch (err) {
console.log(`error querying DNS for ${domain} on ${host}`, err)
return null
}
}

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

53
nip05.ts Normal file
View File

@@ -0,0 +1,53 @@
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_-]+(\.[\w_-]+)+)$/
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'error' })
const json = await res.json()
return json.names
} catch (_) {
return {}
}
}
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json()
let pubkey = res.names[name]
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
export async function isValid(pubkey: string, nip05: string): Promise<boolean> {
let res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false
}

View File

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

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

19
nip06.ts Normal file
View File

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

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 './core.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
}

17
nip11.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, fetchRelayInformation } from './nip11'
// TODO: replace with a mock
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).toContain('nostr.land family')
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
}

163
nip19.test.ts Normal file
View File

@@ -0,0 +1,163 @@
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 nevent = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
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 nevent = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
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('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
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)
})

242
nip19.ts Normal file
View File

@@ -0,0 +1,242 @@
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export 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
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
kind?: number
}
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))
switch (prefix) {
case 'nprofile': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
case 'nevent': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
},
}
}
case 'naddr': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]),
}
}
case 'nsec':
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[] }
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
return encodeBytes('nsec', key)
}
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hexToBytes(hex))
}
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hexToBytes(hex))
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
export 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: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
})
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): `nevent1${string}` {
let kindArray
if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind)
}
let data = encodeTLV({
0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [],
3: kindArray ? [new Uint8Array(kindArray)] : [],
})
return encodeBech32('nevent', data)
}
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)],
})
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)],
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv)
.reverse()
.forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return concatBytes(...entries)
}

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

166
nip29.ts Normal file
View File

@@ -0,0 +1,166 @@
import { AbstractSimplePool } from './abstract-pool'
import { Subscription } from './abstract-relay'
import { decode } from './nip19'
import type { Event } from './core'
import { fetchRelayInformation } from './nip11'
import { normalizeURL } from './utils'
import { AddressPointer } from './nip19'
export function subscribeRelayGroups(
pool: AbstractSimplePool,
url: string,
params: {
ongroups: (_: Group[]) => void
onerror: (_: Error) => void
onconnect?: () => void
},
): () => void {
let normalized = normalizeURL(url)
let sub: Subscription
let groups: Group[] = []
fetchRelayInformation(normalized)
.then(async info => {
let rl = await pool.ensureRelay(normalized)
params.onconnect?.()
sub = rl.prepareSubscription(
[
{
kinds: [39000],
limit: 50,
authors: [info.pubkey],
},
],
{
onevent(event: Event) {
groups.push(parseGroup(event, normalized))
},
oneose() {
params.ongroups(groups)
sub.onevent = (event: Event) => {
groups.push(parseGroup(event, normalized))
params.ongroups(groups)
}
},
},
)
sub.fire()
})
.catch(params.onerror)
return () => sub.close()
}
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
let normalized = normalizeURL(gr.host)
let info = await fetchRelayInformation(normalized)
let event = await pool.get([normalized], {
kinds: [39000],
authors: [info.pubkey],
'#d': [gr.id],
})
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
return parseGroup(event, normalized)
}
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
let gr = parseGroupCode(code)
if (!gr) throw new Error(`code "${code}" does not identify a group`)
return loadGroup(pool, gr)
}
export type GroupReference = {
id: string
host: string
}
export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) {
try {
let { data } = decode(code)
let { relays, identifier } = data as AddressPointer
if (!relays || relays.length === 0) return null
let host = relays![0]
if (host.startsWith('wss://')) {
host = host.slice(6)
}
return { host, id: identifier }
} catch (err) {
return null
}
} else if (code.split("'").length === 2) {
let spl = code.split("'")
return { host: spl[0], id: spl[1] }
}
return null
}
export function encodeGroupReference(gr: GroupReference): string {
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
return `${gr.host}'${gr.id}`
}
export type Group = {
id: string
relay: string
pubkey: string
name?: string
picture?: string
about?: string
public?: boolean
open?: boolean
}
export function parseGroup(event: Event, relay: string): Group {
const group: Partial<Group> = { relay, pubkey: event.pubkey }
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
switch (tag[0]) {
case 'd':
group.id = tag[1] || ''
break
case 'name':
group.name = tag[1] || ''
break
case 'about':
group.about = tag[1] || ''
break
case 'picture':
group.picture = tag[1] || ''
break
case 'open':
group.open = true
break
case 'public':
group.public = true
break
}
}
return group as Group
}
export type Member = {
pubkey: string
label?: string
permissions: string[]
}
export function parseMembers(event: Event): Member[] {
const members = []
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
if (tag.length < 2) continue
if (tag[0] !== 'p') continue
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
const member: Member = { pubkey: tag[1], permissions: [] }
if (tag.length > 2) member.label = tag[2]
if (tag.length > 3) member.permissions = tag.slice(3)
members.push(member)
}
return members
}

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

44
nip40.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, test, expect, jest } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
describe('getExpiration', () => {
test('returns the expiration as a Date object', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = getExpiration(event)
expect(result).toEqual(new Date(123000))
})
})
describe('isEventExpired', () => {
test('returns true when the event has expired', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = isEventExpired(event)
expect(result).toEqual(true)
})
test('returns false when the event has not expired', () => {
const future = Math.floor(Date.now() / 1000) + 10
const event = buildEvent({ tags: [['expiration', future.toString()]] })
const result = isEventExpired(event)
expect(result).toEqual(false)
})
})
describe('waitForExpire', () => {
test('returns a promise that resolves when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = await waitForExpire(event)
expect(result).toEqual(event)
})
})
describe('onExpire', () => {
test('calls the callback when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const callback = jest.fn()
onExpire(event, callback)
await new Promise(resolve => setTimeout(resolve, 200))
expect(callback).toHaveBeenCalled()
})
})

49
nip40.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Event } from './core.ts'
/** Get the expiration of the event as a `Date` object, if any. */
function getExpiration(event: Event): Date | undefined {
const tag = event.tags.find(([name]) => name === 'expiration')
if (tag) {
return new Date(parseInt(tag[1]) * 1000)
}
}
/** Check if the event has expired. */
function isEventExpired(event: Event): boolean {
const expiration = getExpiration(event)
if (expiration) {
return Date.now() > expiration.getTime()
} else {
return false
}
}
/** Returns a promise that resolves when the event expires. */
async function waitForExpire(event: Event): Promise<Event> {
const expiration = getExpiration(event)
if (expiration) {
const diff = expiration.getTime() - Date.now()
if (diff > 0) {
await sleep(diff)
return event
} else {
return event
}
} else {
throw new Error('Event has no expiration')
}
}
/** Calls the callback when the event expires. */
function onExpire(event: Event, callback: (event: Event) => void): void {
waitForExpire(event)
.then(callback)
.catch(() => {})
}
/** Resolves when the given number of milliseconds have elapsed. */
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export { getExpiration, isEventExpired, waitForExpire, onExpire }

16
nip42.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import { expect, test } from 'bun:test'
import { makeAuthEvent } from './nip42.ts'
import { Relay } from './relay.ts'
import { MockRelay } from './test-helpers.ts'
test('auth flow', async () => {
const mockRelay = new MockRelay()
const relay = await Relay.connect(mockRelay.url)
const auth = makeAuthEvent(relay.url, 'chachacha')
expect(auth.tags).toHaveLength(2)
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
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 './core.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: '',
}
}

44
nip44.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { test, expect } from 'bun:test'
import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2
test('get_conversation_key', () => {
for (const v of v2vec.valid.get_conversation_key) {
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
}
})
test('encrypt_decrypt', () => {
for (const v of v2vec.valid.encrypt_decrypt) {
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
const key = v2.utils.getConversationKey(v.sec1, pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
expect(ciphertext).toEqual(v.payload)
const decrypted = v2.decrypt(ciphertext, key)
expect(decrypted).toEqual(v.plaintext)
}
})
test('calc_padded_len', () => {
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
const actual = v2.utils.calcPaddedLen(len)
expect(actual).toEqual(shouldBePaddedTo)
}
})
test('decrypt', async () => {
for (const v of v2vec.invalid.decrypt) {
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
}
})
test('get_conversation_key', async () => {
for (const v of v2vec.invalid.get_conversation_key) {
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
}
})

129
nip44.ts Normal file
View File

@@ -0,0 +1,129 @@
import { chacha20 } from '@noble/ciphers/chacha'
import { equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
const decoder = new TextDecoder()
const u = {
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
utf8Encode: utf8ToBytes,
utf8Decode(bytes: Uint8Array) {
return decoder.decode(bytes)
},
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
},
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
return {
chacha_key: keys.subarray(0, 32),
chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
}
},
calcPaddedLen(len: number): number {
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
},
writeU16BE(num: number) {
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
const arr = new Uint8Array(2)
new DataView(arr.buffer).setUint16(0, num, false)
return arr
},
pad(plaintext: string): Uint8Array {
const unpadded = u.utf8Encode(plaintext)
const unpaddedLen = unpadded.length
const prefix = u.writeU16BE(unpaddedLen)
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
return concatBytes(prefix, unpadded, suffix)
},
unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen < u.minPlaintextSize ||
unpaddedLen > u.maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
)
throw new Error('invalid padding')
return u.utf8Decode(unpadded)
},
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
const combined = concatBytes(aad, message)
return hmac(sha256, key, combined)
},
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
decodePayload(payload: string) {
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
const plen = payload.length
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
if (payload[0] === '#') throw new Error('unknown encryption version')
let data: Uint8Array
try {
data = base64.decode(payload)
} catch (error) {
throw new Error('invalid base64: ' + (error as any).message)
}
const dlen = data.length
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const vers = data[0]
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
}
},
}
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
const padded = u.pad(plaintext)
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
}
function decrypt(payload: string, conversationKey: Uint8Array): string {
const { nonce, ciphertext, mac } = u.decodePayload(payload)
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
return u.unpad(padded)
}
export const v2 = {
utils: u,
encrypt,
decrypt,
}
export default { v2 }

605
nip44.vectors.json Normal file
View File

@@ -0,0 +1,605 @@
{
"v2": {
"valid": {
"get_conversation_key": [
{
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
},
{
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
},
{
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
},
{
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
},
{
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
},
{
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
},
{
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
},
{
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
},
{
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
},
{
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
},
{
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
},
{
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
},
{
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
},
{
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
},
{
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
},
{
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
},
{
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
},
{
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
},
{
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
},
{
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
},
{
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
},
{
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
},
{
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
},
{
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
},
{
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
},
{
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
},
{
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
},
{
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
},
{
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
},
{
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
},
{
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
},
{
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
"note": "sec1 = n-2, pub2: random, 0x02"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
"note": "sec1 = 2, pub2: rand"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
"note": "sec1 == pub2"
}
],
"get_message_keys": {
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
"keys": [
{
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
"chacha_nonce": "c4ad129bb01180c0933a160c",
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
},
{
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
"chacha_nonce": "22925e920cee4a50a478be90",
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
},
{
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
"chacha_nonce": "d3594987af769a52904656ac",
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
},
{
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
},
{
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
"chacha_nonce": "400224ba67fc2f1b76736916",
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
},
{
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
},
{
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
"chacha_nonce": "72f69a5a5f795465cee59da8",
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
},
{
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
"chacha_nonce": "6e69be92d61c04a276021565",
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
},
{
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
"chacha_nonce": "3dda53569cfcb7fac1805c35",
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
},
{
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
"chacha_nonce": "65064239186e50304cc0f156",
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
},
{
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
},
{
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
"chacha_nonce": "cbb2530ea653766e5a37a83a",
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
},
{
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
},
{
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
"chacha_nonce": "67803605a7e5010d0f63f8c8",
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
},
{
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
"chacha_nonce": "4e62a0073087ed808be62469",
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
},
{
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
},
{
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
"chacha_nonce": "329bb3024461e84b2e1c489b",
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
},
{
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
"chacha_nonce": "653d759042b85194d4d8c0a7",
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
},
{
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
"chacha_nonce": "b822e2c959df32b3cb772a7c",
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
},
{
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
},
{
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
},
{
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
},
{
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
},
{
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
},
{
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
"chacha_nonce": "263830a065af33d9c6c5aa1f",
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
},
{
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
},
{
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
"chacha_nonce": "ccdaad5b3b7645be430992eb",
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
},
{
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
"chacha_nonce": "ed02dece5fc3a186f123420b",
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
},
{
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
},
{
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
"chacha_nonce": "26b450612ca5e905b937e147",
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
},
{
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
"chacha_nonce": "4a296a1fb0048e5020d3b129",
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
},
{
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
"chacha_nonce": "a8188daff807a1182200b39d",
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
}
]
},
"calc_padded_len": [
[16, 32],
[32, 32],
[33, 64],
[37, 64],
[45, 64],
[49, 64],
[64, 64],
[65, 96],
[100, 128],
[111, 128],
[200, 224],
[250, 256],
[320, 320],
[383, 384],
[384, 384],
[400, 448],
[500, 512],
[512, 512],
[515, 640],
[700, 768],
[800, 896],
[900, 1024],
[1020, 1024],
[65536, 65536]
],
"encrypt_decrypt": [
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
"plaintext": "🍕🫃",
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
},
{
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"plaintext": "表ポあA鷗Œé逍Üߪąñ丂㐀𠀀",
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
},
{
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
"plaintext": "ability🤝的 ȺȾ",
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
},
{
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
"plaintext": "pepper👀їжак",
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
},
{
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"plaintext": "( ͡° ͜ʖ ͡°)",
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
"plaintext": "الكل في المجمو عة (5)",
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"plaintext": "🙈 🙉 🙊 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ 9⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
}
],
"encrypt_decrypt_long_msg": [
{
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
"letter": "ф",
"repeat": 65535,
"payload_checksum_sha256": "",
"note": "фффф... (65535 times)"
}
]
},
"invalid": {
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
"get_conversation_key": [
{
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 higher than curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 is 0"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"note": "pub2 is invalid, no sqrt, all-ff"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 == curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "pub2 is invalid, no sqrt"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
"note": "pub2 is point of order 3 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
"note": "pub2 is point of order 13 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
"note": "pub2 is point of order 3319 on twist"
}
],
"decrypt": [
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o b l e",
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
"note": "unknown encryption version"
},
{
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
"plaintext": "⚠️",
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
"note": "unknown encryption version 0"
},
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o s t r",
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
"note": "invalid base64"
},
{
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
"plaintext": "¯\\_(ツ)_/¯",
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"note": "invalid MAC"
},
{
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
"plaintext": "🥎",
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
"note": "invalid MAC"
},
{
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
"plaintext": "elliptic-curve cryptography",
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
"note": "invalid padding"
},
{
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
"plaintext": "noble",
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
"note": "invalid padding"
},
{
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
"plaintext": "censorship-resistant and global social network",
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
"note": "invalid padding"
}
]
}
}
}

342
nip46.ts Normal file
View File

@@ -0,0 +1,342 @@
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt, encrypt } from './nip04.ts'
import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = {
relays: string[]
pubkey: string
secret: null | string
}
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
and returns a BunkerPointer -- or null in case of error */
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
let match = input.match(BUNKER_REGEX)
if (match) {
try {
const pubkey = match[1]
const qs = new URLSearchParams(match[2])
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret'),
}
} catch (_err) {
/* just move to the next case */
}
}
return queryBunkerProfile(input)
}
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json()
let pubkey = res.names[name]
let relays = res.nip46[pubkey] || []
return { pubkey, relays, secret: null }
} catch (_err) {
return null
}
}
export type BunkerSignerParams = {
pool?: AbstractSimplePool
onauth?: (url: string) => void
}
export class BunkerSigner {
private pool: AbstractSimplePool
private subCloser: SubCloser
private isOpen: boolean
private serial: number
private idPrefix: string
private listeners: {
[id: string]: {
resolve: (_: string) => void
reject: (_: string) => void
}
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
public bp: BunkerPointer
/**
* Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses.
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair.
*/
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.bp = bp
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0
this.listeners = {}
this.waitingForAuth = {}
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
this.subCloser = this.pool.subscribeMany(
this.bp.relays,
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }],
{
async onevent(event: NostrEvent) {
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id]
if (params.onauth) {
params.onauth(error)
} else {
console.warn(
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
)
}
return
}
let handler = listeners[id]
if (handler) {
if (error) handler.reject(error)
else if (result) handler.resolve(result)
delete listeners[id]
}
},
},
)
this.isOpen = true
}
// closes the subscription -- this object can't be used anymore after this
async close() {
this.isOpen = false
this.subCloser.close()
}
async sendRequest(method: string, params: string[]): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
this.serial++
const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params }))
// the request event
const verifiedEvent: VerifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000),
},
this.secretKey,
)
// setup callback listener
this.listeners[id] = { resolve, reject }
this.waitingForAuth[id] = true
// publish the event
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
} catch (err) {
reject(err)
}
})
}
/**
* Calls the "connect" method on the bunker.
* The promise will be rejected if the response is not "pong".
*/
async ping(): Promise<void> {
let resp = await this.sendRequest('ping', [])
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
}
/**
* Calls the "connect" method on the bunker.
*/
async connect(): Promise<void> {
await this.sendRequest('connect', [getPublicKey(this.secretKey), this.bp.secret || ''])
}
/**
* This was supposed to call the "get_public_key" method on the bunker,
* but instead we just returns the public key we already know.
*/
async getPublicKey(): Promise<string> {
return this.bp.pubkey
}
/**
* Calls the "get_relays" method on the bunker.
*/
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> {
return JSON.parse(await this.sendRequest('get_relays', []))
}
/**
* Signs an event using the remote private key.
* @param event - The event to sign.
* @returns A Promise that resolves to the signed event.
*/
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp)
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) {
return signed
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
}
}
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
}
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
}
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
return hexToBytes(resp)
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
}
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext])
}
}
/**
* Creates an account with the specified username, domain, and optional email.
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
* @param username - The username for the account.
* @param domain - The domain for the account.
* @param email - The optional email for the account.
* @throws Error if the email is present but invalid.
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
*/
export async function createAccount(
bunker: BunkerProfile,
params: BunkerSignerParams,
username: string,
domain: string,
email?: string,
): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let sk = generateSecretKey()
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
// once we get the newly created pubkey back, we hijack this signer instance
// and turn it into the main instance for this newly created pubkey
rpc.bp.pubkey = pubkey
await rpc.connect()
return rpc
}
// @deprecated use fetchBunkerProviders instead
export const fetchCustodialBunkers = fetchBunkerProviders
/**
* Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects.
*/
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()],
})
events.sort((a, b) => b.created_at - a.created_at)
// validate bunkers by checking their NIP-05 and pubkey
// map to a more useful object
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content)
// skip duplicates
try {
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
} catch (err) {
/***/
}
const bp = await queryBunkerProfile(content.nip05)
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false,
}
}
} catch (err) {
return undefined
}
}),
)
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
}
export type BunkerProfile = {
bunkerPointer: BunkerPointer
domain: string
nip05: string
name: string
picture: string
about: string
website: string
local: boolean
}

60
nip47.test.ts Normal file
View File

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

95
nip49.test.ts Normal file
View File

@@ -0,0 +1,95 @@
import { test, expect } from 'bun:test'
import { decrypt, encrypt } from './nip49'
import { hexToBytes } from '@noble/hashes/utils'
test('encrypt and decrypt', () => {
for (let i = 0; i < vectors.length; i++) {
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
let sec = hexToBytes(secret)
let there = encrypt(sec, password, logn, ksb)
let back = decrypt(there, password)
let again = decrypt(ncryptsec, password)
expect(back).toEqual(again)
expect(again).toEqual(sec)
}
})
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
1,
0x00,
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
2,
0x01,
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
3,
0x02,
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
],
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
7,
0x00,
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
8,
0x01,
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x02,
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
4,
0x00,
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
],
[
'',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
5,
0x01,
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
1,
0x00,
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
],
[
'ÅΩẛ̣',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
],
[
'ÅΩṩ',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
],
]

45
nip49.ts Normal file
View File

@@ -0,0 +1,45 @@
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, encodeBytes } from './nip19'
import { bech32 } from '@scure/base'
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
let salt = randomBytes(16)
let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let nonce = randomBytes(24)
let aad = Uint8Array.from([ksb])
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let ciphertext = xc2p1.encrypt(sec)
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
return encodeBytes('ncryptsec', b)
}
export function decrypt(ncryptsec: string, password: string): Uint8Array {
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
if (prefix !== 'ncryptsec') {
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
}
let b = new Uint8Array(bech32.fromWords(words))
let version = b[0]
if (version !== 0x02) {
throw new Error(`invalid version ${version}, expected 0x02`)
}
let logn = b[1]
let n = 2 ** logn
let salt = b.slice(2, 2 + 16)
let nonce = b.slice(2 + 16, 2 + 16 + 24)
let ksb = b[2 + 16 + 24]
let aad = Uint8Array.from([ksb])
let ciphertext = b.slice(2 + 16 + 24 + 1)
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let sec = xc2p1.decrypt(ciphertext)
return sec
}

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', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
test('returns a valid Zap receipt with a preimage', () => {
const zapRequest = JSON.stringify(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['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', target],
['P', publicKey],
['preimage', preimage],
]),
)
})
test('returns a valid Zap receipt without a preimage', () => {
const zapRequest = JSON.stringify(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['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', target],
['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 = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} 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, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
}
if (preimage) {
zap.tags.push(['preimage', preimage])
}
return zap
}

203
nip75.test.ts Normal file
View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from 'bun:test'
import { ZapGoal } from './kinds.ts'
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('Goal Type', () => {
it('should create a proper Goal object', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
expect(goal.content).toBe('Fundraising for a new project')
expect(goal.amount).toBe('100000000')
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
expect(goal.closedAt).toBe(1671150419)
expect(goal.image).toBe('https://example.com/goal-image.jpg')
expect(goal.summary).toBe('Help us reach our fundraising goal!')
expect(goal.r).toBe('https://example.com/additional-info')
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
expect(goal.zapTags).toEqual([
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
})
describe('generateGoalEventTemplate', () => {
it('should generate an EventTemplate for a fundraising goal', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
])
})
it('should generate an EventTemplate that is valid', () => {
const sk = generateSecretKey()
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
})
describe('validateZapGoalEvent', () => {
it('should validate a proper Goal event', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
it('should not validate an event with an incorrect kind', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: 0, // Incorrect kind
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "amount" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
// Missing "amount" tag
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "relays" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
// Missing "relays" tag
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
})

115
nip75.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Event, EventTemplate } from './core'
import { ZapGoal } from './kinds'
/**
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
* This type is used to structure the information needed to create a goal event (`kind:9041`).
*/
export type Goal = {
/**
* A human-readable description of the fundraising goal.
* This content should provide clear information about the purpose of the fundraising.
*/
content: string
/**
* The target amount for the fundraising goal in milisats.
* This defines the financial target that the fundraiser aims to reach.
*/
amount: string
/**
* A list of relays where the zaps towards this goal will be sent to and tallied from.
* Each relay is represented by its WebSocket URL.
*/
relays: string[]
/**
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
* Zaps published after this timestamp should not count towards the goal progress.
* If not provided, the goal remains open indefinitely or until manually closed.
*/
closedAt?: number
/**
* An optional URL to an image related to the goal.
* This can be used to visually represent the goal on client interfaces.
*/
image?: string
/**
* An optional brief description or summary of the goal.
* This can provide a quick overview of the goal, separate from the detailed `content`.
*/
summary?: string
/**
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
* This is a single URL, as per NIP-75 specifications for linking additional resources.
*/
r?: string
/**
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
*/
a?: string
/**
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
*/
zapTags?: string[][]
}
/**
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
* This function is tailored to fit the structure of EventTemplate as defined in the library.
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
* @returns An EventTemplate object structured for creating a Nostr event.
*/
export function generateGoalEventTemplate({
amount,
content,
relays,
a,
closedAt,
image,
r,
summary,
zapTags,
}: Goal): EventTemplate {
const tags: string[][] = [
['amount', amount],
['relays', ...relays],
]
// Append optional tags based on the presence of optional properties in zapGoal
closedAt && tags.push(['closed_at', closedAt.toString()])
image && tags.push(['image', image])
summary && tags.push(['summary', summary])
r && tags.push(['r', r])
a && tags.push(['a', a])
zapTags && tags.push(...zapTags)
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content,
tags,
}
return eventTemplate
}
export function validateZapGoalEvent(event: Event): boolean {
if (event.kind !== ZapGoal) return false
const requiredTags = ['amount', 'relays'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}

374
nip94.test.ts Normal file
View File

@@ -0,0 +1,374 @@
import { describe, expect, it } from 'bun:test'
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('generateEventTemplate', () => {
it('should generate the correct event template', () => {
const fileMetadataObject: FileMetadataObject = {
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
}
const expectedEventTemplate: EventTemplate = {
content: 'Lorem ipsum dolor sit amet',
created_at: expect.any(Number),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
}
const eventTemplate = generateEventTemplate(fileMetadataObject)
expect(eventTemplate).toEqual(expectedEventTemplate)
})
})
describe('validateEvent', () => {
it('should return true for a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
it('should return false if kind is not FileMetadataKind', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: 0, // not FileMetadataKind
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if content is empty', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // empty
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if required tags are missing', () => {
const sk = generateSecretKey()
const eventWithoutUrl: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
// missing url
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
const eventWithoutM: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
// missing m
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
const eventWithoutX: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
// missing x
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
const eventWithoutOx: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
// missing ox
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(eventWithoutUrl)).toBe(false)
expect(validateEvent(eventWithoutM)).toBe(false)
expect(validateEvent(eventWithoutX)).toBe(false)
expect(validateEvent(eventWithoutOx)).toBe(false)
})
it('should return false if size is not a number', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', 'abc'], // not a number
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if dim is not a valid dimension string', () => {
const sk = generateSecretKey()
const eventWithInvalidDim: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', 'abc'], // invalid dim
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(validateEvent(eventWithInvalidDim)).toBe(false)
})
})
describe('parseEvent', () => {
it('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
const parsedEvent = parseEvent(event)
expect(parsedEvent).toEqual({
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
})
})
it('should throw an error if the event is invalid', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // invalid
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow('Invalid event')
})
})

201
nip94.ts Normal file
View File

@@ -0,0 +1,201 @@
import { Event, EventTemplate } from './core'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
/**
* Type definition for File Metadata as specified in NIP-94.
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
*/
export type FileMetadataObject = {
/**
* A description or caption for the file content.
*/
content: string
/**
* The URL to download the file.
*/
url: string
/**
* The MIME type of the file, in lowercase.
*/
m: string
/**
* The SHA-256 hex-encoded string of the file.
*/
x: string
/**
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
*/
ox: string
/**
* Optional: The size of the file in bytes.
*/
size?: string
/**
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
*/
dim?: string
/**
* Optional: The URI to the magnet file.
*/
magnet?: string
/**
* Optional: The torrent infohash.
*/
i?: string
/**
* Optional: The blurhash string to show while the file is being loaded by the client.
*/
blurhash?: string
/**
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
*/
thumb?: string
/**
* Optional: The URL of a preview image with the same dimensions as the original file.
*/
image?: string
/**
* Optional: A text excerpt or summary of the file's content.
*/
summary?: string
/**
* Optional: A description for accessibility, providing context or a brief description of the file.
*/
alt?: string
}
/**
* Generates an event template based on a file metadata object.
*
* @param fileMetadata - The file metadata object.
* @returns The event template.
*/
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
const eventTemplate: EventTemplate = {
content: fileMetadata.content,
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', fileMetadata.url],
['m', fileMetadata.m],
['x', fileMetadata.x],
['ox', fileMetadata.ox],
],
}
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
return eventTemplate
}
/**
* Validates an event to ensure it is a valid file metadata event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (event.kind !== FileMetadataKind) return false
if (!event.content) return false
const requiredTags = ['url', 'm', 'x', 'ox'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate optional size tag
const sizeTag = event.tags.find(([t]) => t == 'size')
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
// validate optional dim tag
const dimTag = event.tags.find(([t]) => t == 'dim')
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
return true
}
/**
* Parses an event and returns a file metadata object.
* @param event - The event to parse.
* @returns The file metadata object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): FileMetadataObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const fileMetadata: FileMetadataObject = {
content: event.content,
url: '',
m: '',
x: '',
ox: '',
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'url':
fileMetadata.url = value
break
case 'm':
fileMetadata.m = value
break
case 'x':
fileMetadata.x = value
break
case 'ox':
fileMetadata.ox = value
break
case 'size':
fileMetadata.size = value
break
case 'dim':
fileMetadata.dim = value
break
case 'magnet':
fileMetadata.magnet = value
break
case 'i':
fileMetadata.i = value
break
case 'blurhash':
fileMetadata.blurhash = value
break
case 'thumb':
fileMetadata.thumb = value
break
case 'image':
fileMetadata.image = value
break
case 'summary':
fileMetadata.summary = value
break
case 'alt':
fileMetadata.alt = value
break
}
}
return fileMetadata
}

654
nip96.test.ts Normal file
View File

@@ -0,0 +1,654 @@
import { describe, expect, it } from 'bun:test'
import { HttpResponse, http } from 'msw'
import { setupServer } from 'msw/node'
import { FileServerPreference } from './kinds.ts'
import {
calculateFileHash,
checkFileProcessingStatus,
deleteFile,
generateDownloadUrl,
generateFSPEventTemplate,
readServerConfig,
uploadFile,
validateDelayedProcessingResponse,
validateFileUploadResponse,
validateServerConfiguration,
type DelayedProcessingResponse,
type FileUploadResponse,
type ServerConfiguration,
} from './nip96.ts'
describe('validateServerConfiguration', () => {
it("should return true if 'api_url' is valid URL", () => {
const config: ServerConfiguration = {
api_url: 'http://example.com',
}
expect(validateServerConfiguration(config)).toBe(true)
})
it("should return false if 'api_url' is empty", () => {
const config: ServerConfiguration = {
api_url: '',
}
expect(validateServerConfiguration(config)).toBe(false)
})
it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => {
const config: ServerConfiguration = {
api_url: 'http://example.com',
delegated_to_url: 'http://example.com',
}
expect(validateServerConfiguration(config)).toBe(false)
})
})
describe('readServerConfig', () => {
it('should return a valid ServerConfiguration object', async () => {
// setup mock server
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
const validConfig: ServerConfiguration = {
api_url: 'http://example.com',
}
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
return HttpResponse.json(validConfig)
})
const server = setupServer(handler)
server.listen()
const result = await readServerConfig('http://example.com/')
expect(result).toEqual(validConfig)
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error if response is not valid', async () => {
// setup mock server
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
const invalidConfig = {
// missing api_url
}
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
return HttpResponse.json(invalidConfig)
})
const server = setupServer(handler)
server.listen()
expect(readServerConfig('http://example.com/')).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error if response is not proper json', async () => {
// setup mock server
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
return HttpResponse.json(null)
})
const server = setupServer(handler)
server.listen()
expect(readServerConfig('http://example.com/')).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error if response status is not 200', async () => {
// setup mock server
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
return new HttpResponse(null, { status: 400 })
})
const server = setupServer(handler)
server.listen()
expect(readServerConfig('http://example.com/')).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error if input url is not valid', async () => {
expect(readServerConfig('invalid-url')).rejects.toThrow()
})
})
describe('validateFileUploadResponse', () => {
it('should return true if response is valid', () => {
const mockResponse: FileUploadResponse = {
status: 'error',
message: 'File uploaded failed',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(true)
})
it('should return false if status is undefined', () => {
const mockResponse: Omit<FileUploadResponse, 'status'> = {
// status: 'error',
message: 'File upload failed',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if message is undefined', () => {
const mockResponse: Omit<FileUploadResponse, 'message'> = {
status: 'error',
// message: 'message',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if status is not valid', () => {
const mockResponse = {
status: 'something else',
message: 'message',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if "message" is not a string', () => {
const mockResponse = {
status: 'error',
message: 123,
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if status is "processing" and "processing_url" is undefined', () => {
const mockResponse = {
status: 'processing',
message: 'message',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if status is "processing" and "processing_url" is not a string', () => {
const mockResponse = {
status: 'processing',
message: 'message',
processing_url: 123,
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if status is "success" and "nip94_event" is undefined', () => {
const mockResponse = {
status: 'success',
message: 'message',
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if "nip94_event" tags are invalid', () => {
const mockResponse = {
status: 'success',
message: 'message',
nip94_event: {
tags: [
// missing url
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
],
},
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return false if "nip94_event" tags are empty', () => {
const mockResponse = {
status: 'success',
message: 'message',
nip94_event: {
tags: [],
},
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(false)
})
it('should return true if "nip94_event" tags are valid', () => {
const mockResponse = {
status: 'success',
message: 'message',
nip94_event: {
tags: [
['url', 'http://example.com'],
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
],
},
}
const result = validateFileUploadResponse(mockResponse)
expect(result).toBe(true)
})
})
describe('uploadFile', () => {
it('should return a valid FileUploadResponse object', async () => {
// setup mock server
const validFileUploadResponse: FileUploadResponse = {
status: 'success',
message: 'message',
nip94_event: {
content: '',
tags: [
['url', 'http://example.com'],
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
],
},
}
const handler = http.post('http://example.com/upload', () => {
return HttpResponse.json(validFileUploadResponse, { status: 200 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)
expect(result).toEqual(validFileUploadResponse)
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw a proper error if response status is 413', async () => {
// setup mock server
const handler = http.post('http://example.com/upload', () => {
return new HttpResponse(null, { status: 413 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('File too large!')
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw a proper error if response status is 400', async () => {
// setup mock server
const handler = http.post('http://example.com/upload', () => {
return new HttpResponse(null, { status: 400 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
'Bad request! Some fields are missing or invalid!',
)
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw a proper error if response status is 403', async () => {
// setup mock server
const handler = http.post('http://example.com/upload', () => {
return new HttpResponse(null, { status: 403 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
'Forbidden! Payload tag does not match the requested file!',
)
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw a proper error if response status is 402', async () => {
// setup mock server
const handler = http.post('http://example.com/upload', () => {
return new HttpResponse(null, { status: 402 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('Payment required!')
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => {
// setup mock server
const handler = http.post('http://example.com/upload', () => {
return new HttpResponse(null, { status: 500 })
})
const server = setupServer(handler)
server.listen()
const file = new File(['hello world'], 'hello.txt')
const serverUploadUrl = 'http://example.com/upload'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
'Unknown error in uploading file!',
)
// cleanup mock server
server.resetHandlers()
server.close()
})
})
describe('generateDownloadUrl', () => {
it('should generate a download URL without file extension', () => {
const fileHash = 'abc123'
const serverDownloadUrl = 'http://example.com/download'
const expectedUrl = 'http://example.com/download/abc123'
const result = generateDownloadUrl(fileHash, serverDownloadUrl)
expect(result).toBe(expectedUrl)
})
it('should generate a download URL with file extension', () => {
const fileHash = 'abc123'
const serverDownloadUrl = 'http://example.com/download'
const fileExtension = '.jpg'
const expectedUrl = 'http://example.com/download/abc123.jpg'
const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension)
expect(result).toBe(expectedUrl)
})
})
describe('deleteFile', () => {
it('should return a basic json response for successful delete', async () => {
// setup mock server
const handler = http.delete('http://example.com/delete/abc123', () => {
return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 })
})
const server = setupServer(handler)
server.listen()
const fileHash = 'abc123'
const serverDeleteUrl = 'http://example.com/delete'
const nip98AuthorizationHeader = 'Nostr abcabc'
const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)
expect(result).toEqual({ status: 'success', message: 'File deleted.' })
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error for unsuccessful delete', async () => {
// setup mock server
const handler = http.delete('http://example.com/delete/abc123', () => {
return new HttpResponse(null, { status: 400 })
})
const server = setupServer(handler)
server.listen()
const fileHash = 'abc123'
const serverDeleteUrl = 'http://example.com/delete'
const nip98AuthorizationHeader = 'Nostr abcabc'
expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
})
describe('validateDelayedProcessingResponse', () => {
it('should return false for non-object input', () => {
expect(validateDelayedProcessingResponse('not an object')).toBe(false)
})
it('should return false for null input', () => {
expect(validateDelayedProcessingResponse(null)).toBe(false)
})
it('should return false for object missing required properties', () => {
const missingStatus: Omit<DelayedProcessingResponse, 'status'> = {
// missing status
message: 'test',
percentage: 50,
}
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
status: 'processing',
// missing message
percentage: 50,
}
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
status: 'processing',
message: 'test',
// missing percentage
}
expect(validateDelayedProcessingResponse(missingStatus)).toBe(false)
expect(validateDelayedProcessingResponse(missingMessage)).toBe(false)
expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false)
})
it('should return false for invalid status', () => {
expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false)
})
it('should return false for non-string message', () => {
expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false)
})
it('should return false for non-number percentage', () => {
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false)
})
it('should return false for percentage out of range', () => {
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false)
})
it('should return true for valid input', () => {
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true)
})
})
describe('checkFileProcessingStatus', () => {
it('should throw an error if response is not ok', async () => {
// setup mock server
const handler = http.get('http://example.com/status/abc123', () => {
return new HttpResponse(null, { status: 400 })
})
const server = setupServer(handler)
server.listen()
const processingUrl = 'http://example.com/status/abc123'
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should throw an error if response is not a valid json', async () => {
// setup mock server
const handler = http.get('http://example.com/status/abc123', () => {
return HttpResponse.text('not a json', { status: 200 })
})
const server = setupServer(handler)
server.listen()
const processingUrl = 'http://example.com/status/abc123'
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should return a valid DelayedProcessingResponse object if response status is 200', async () => {
// setup mock server
const validDelayedProcessingResponse: DelayedProcessingResponse = {
status: 'processing',
message: 'test',
percentage: 50,
}
const handler = http.get('http://example.com/status/abc123', () => {
return HttpResponse.json(validDelayedProcessingResponse, { status: 200 })
})
const server = setupServer(handler)
server.listen()
const processingUrl = 'http://example.com/status/abc123'
const result = await checkFileProcessingStatus(processingUrl)
expect(result).toEqual(validDelayedProcessingResponse)
// cleanup mock server
server.resetHandlers()
server.close()
})
it('should return a valid FileUploadResponse object if response status is 201', async () => {
// setup mock server
const validFileUploadResponse: FileUploadResponse = {
status: 'success',
message: 'message',
nip94_event: {
content: '',
tags: [
['url', 'http://example.com'],
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
],
},
}
const handler = http.get('http://example.com/status/abc123', () => {
return HttpResponse.json(validFileUploadResponse, { status: 201 })
})
const server = setupServer(handler)
server.listen()
const processingUrl = 'http://example.com/status/abc123'
const result = await checkFileProcessingStatus(processingUrl)
expect(result).toEqual(validFileUploadResponse)
// cleanup mock server
server.resetHandlers()
server.close()
})
})
describe('generateFSPEventTemplate', () => {
it('should generate FSP event template', () => {
const serverUrls = ['http://example.com', 'https://example.org']
const eventTemplate = generateFSPEventTemplate(serverUrls)
expect(eventTemplate.kind).toBe(FileServerPreference)
expect(eventTemplate.content).toBe('')
expect(eventTemplate.tags).toEqual([
['server', 'http://example.com'],
['server', 'https://example.org'],
])
expect(typeof eventTemplate.created_at).toBe('number')
})
it('should filter invalid server URLs', () => {
const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org']
const eventTemplate = generateFSPEventTemplate(serverUrls)
expect(eventTemplate.tags).toEqual([
['server', 'http://example.com'],
['server', 'https://example.org'],
])
})
it('should handle empty server URLs', () => {
const serverUrls: string[] = []
const eventTemplate = generateFSPEventTemplate(serverUrls)
expect(eventTemplate.tags).toEqual([])
})
})
describe('calculateFileHash', () => {
it('should calculate file hash', async () => {
const file = new File(['hello world'], 'hello.txt')
const hash = await calculateFileHash(file)
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9')
})
it('should calculate file hash with empty file', async () => {
const file = new File([], 'empty.txt')
const hash = await calculateFileHash(file)
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
})

582
nip96.ts Normal file
View File

@@ -0,0 +1,582 @@
import { sha256 } from '@noble/hashes/sha256'
import { EventTemplate } from './core'
import { FileServerPreference } from './kinds'
import { bytesToHex } from '@noble/hashes/utils'
/**
* Represents the configuration for a server compliant with NIP-96.
*/
export type ServerConfiguration = {
/**
* The base URL from which file upload and deletion operations are served.
* Also used for downloads if "download_url" is not specified.
*/
api_url: string
/**
* Optional. The base URL from which files are downloaded.
* Used if different from the "api_url".
*/
download_url?: string
/**
* Optional. URL of another HTTP file storage server's configuration.
* Used by nostr relays to delegate to another server.
* In this case, "api_url" must be an empty string.
*/
delegated_to_url?: string
/**
* Optional. An array of NIP numbers that this server supports.
*/
supported_nips?: number[]
/**
* Optional. URL to the server's Terms of Service.
*/
tos_url?: string
/**
* Optional. An array of MIME types supported by the server.
*/
content_types?: string[]
/**
* Optional. Defines various storage plans offered by the server.
*/
plans?: {
[planKey: string]: {
/**
* The name of the storage plan.
*/
name: string
/**
* Optional. Indicates whether NIP-98 is required for uploads in this plan.
*/
is_nip98_required?: boolean
/**
* Optional. URL to a landing page providing more information about the plan.
*/
url?: string
/**
* Optional. The maximum file size allowed under this plan, in bytes.
*/
max_byte_size?: number
/**
* Optional. Defines the range of file expiration in days.
* The first value indicates the minimum expiration time, and the second value indicates the maximum.
* A value of 0 indicates no expiration.
*/
file_expiration?: [number, number]
/**
* Optional. Specifies the types of media transformations supported under this plan.
* Currently, only image transformations are considered.
*/
media_transformations?: {
/**
* Optional. An array of supported image transformation types.
*/
image?: string[]
}
}
}
}
/**
* Represents the optional form data fields for file upload in accordance with NIP-96.
*/
export type OptionalFormDataFields = {
/**
* Specifies the desired expiration time of the file on the server.
* It should be a string representing a UNIX timestamp in seconds.
* An empty string indicates that the file should be stored indefinitely.
*/
expiration?: string
/**
* Indicates the size of the file in bytes.
* This field can be used by the server to pre-validate the file size before processing the upload.
*/
size?: string
/**
* Provides a strict description of the file for accessibility purposes,
* particularly useful for visibility-impaired users.
*/
alt?: string
/**
* A loose, more descriptive caption for the file.
* This can be used for additional context or commentary about the file.
*/
caption?: string
/**
* Specifies the intended use of the file.
* Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner.
* Absence of this field suggests standard file upload without special treatment.
*/
media_type?: 'avatar' | 'banner'
/**
* The MIME type of the file being uploaded.
* This can be used for early rejection by the server if the file type isn't supported.
*/
content_type?: string
/**
* Other custom form data fields.
*/
[key: string]: string | undefined
}
/**
* Type representing the response from a NIP-96 compliant server after a file upload request.
*/
export type FileUploadResponse = {
/**
* The status of the upload request.
* - 'success': Indicates the file was successfully uploaded.
* - 'error': Indicates there was an error in the upload process.
* - 'processing': Indicates the file is still being processed (used in cases of delayed processing).
*/
status: 'success' | 'error' | 'processing'
/**
* A message provided by the server, which could be a success message, error description, or processing status.
*/
message: string
/**
* Optional. A URL provided by the server where the upload processing status can be checked.
* This is relevant in cases where the file upload involves delayed processing.
*/
processing_url?: string
/**
* Optional. An event object conforming to NIP-94, which includes details about the uploaded file.
* This object is typically provided in the response for a successful upload and contains
* essential information such as the download URL and file metadata.
*/
nip94_event?: {
/**
* A collection of key-value pairs (tags) providing metadata about the uploaded file.
* Standard tags include:
* - 'url': The URL where the file can be accessed.
* - 'ox': The SHA-256 hash of the original file before any server-side transformations.
* Additional optional tags might include file dimensions, MIME type, etc.
*/
tags: Array<[string, string]>
/**
* A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure.
*/
content: string
}
}
/**
* Type representing the response from a NIP-96 compliant server after a delayed processing request.
*/
export type DelayedProcessingResponse = {
/**
* The status of the delayed processing request.
* - 'processing': Indicates the file is still being processed.
* - 'error': Indicates there was an error in the processing.
*/
status: 'processing' | 'error'
/**
* A message provided by the server, which could be a success message or error description.
*/
message: string
/**
* The percentage of the file that has been processed. This is a number between 0 and 100.
*/
percentage: number
}
/**
* Validates the server configuration.
*
* @param config - The server configuration object.
* @returns True if the configuration is valid, false otherwise.
*/
export function validateServerConfiguration(config: ServerConfiguration): boolean {
if (Boolean(config.api_url) == false) {
return false
}
if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) {
return false
}
return true
}
/**
* Fetches, parses, and validates the server configuration from the given URL.
*
* @param serverUrl The URL of the server.
* @returns The server configuration, or an error if the configuration could not be fetched or parsed.
*/
export async function readServerConfig(serverUrl: string): Promise<ServerConfiguration> {
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
let fetchUrl = ''
try {
const { origin } = new URL(serverUrl)
fetchUrl = origin + HTTPROUTE
} catch (error) {
throw new Error('Invalid URL')
}
try {
const response = await fetch(fetchUrl)
if (!response.ok) {
throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`)
}
const data: any = await response.json()
if (!data) {
throw new Error('No data')
}
if (!validateServerConfiguration(data)) {
throw new Error('Invalid configuration data')
}
return data
} catch (_) {
throw new Error(`Error fetching.`)
}
}
/**
* Validates if the given object is a valid FileUploadResponse.
*
* @param response - The object to validate.
* @returns true if the object is a valid FileUploadResponse, otherwise false.
*/
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
if (typeof response !== 'object' || response === null) return false
if (!response.status || !response.message) {
return false
}
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
return false
}
if (typeof response.message !== 'string') {
return false
}
if (response.status === 'processing' && !response.processing_url) {
return false
}
if (response.processing_url) {
if (typeof response.processing_url !== 'string') {
return false
}
}
if (response.status === 'success' && !response.nip94_event) {
return false
}
if (response.nip94_event) {
if (
!response.nip94_event.tags ||
!Array.isArray(response.nip94_event.tags) ||
response.nip94_event.tags.length === 0
) {
return false
}
for (const tag of response.nip94_event.tags) {
if (!Array.isArray(tag) || tag.length !== 2) return false
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
}
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
return false
}
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
return false
}
}
return true
}
/**
* Uploads a file to a NIP-96 compliant server.
*
* @param file - The file to be uploaded.
* @param serverApiUrl - The API URL of the server, retrieved from the server's configuration.
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
* @param optionalFormDataFields - Optional form data fields.
* @returns A promise that resolves to the server's response.
*/
export async function uploadFile(
file: File,
serverApiUrl: string,
nip98AuthorizationHeader: string,
optionalFormDataFields?: OptionalFormDataFields,
): Promise<FileUploadResponse> {
// Create FormData object
const formData = new FormData()
// Append the authorization header to HTML Form Data
formData.append('Authorization', nip98AuthorizationHeader)
// Append optional fields to FormData
optionalFormDataFields &&
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
if (value) {
formData.append(key, value)
}
})
// Append the file to FormData as the last field
formData.append('file', file)
// Make the POST request to the server
const response = await fetch(serverApiUrl, {
method: 'POST',
headers: {
Authorization: nip98AuthorizationHeader,
'Content-Type': 'multipart/form-data',
},
body: formData,
})
if (response.ok === false) {
// 413 Payload Too Large
if (response.status === 413) {
throw new Error('File too large!')
}
// 400 Bad Request
if (response.status === 400) {
throw new Error('Bad request! Some fields are missing or invalid!')
}
// 403 Forbidden
if (response.status === 403) {
throw new Error('Forbidden! Payload tag does not match the requested file!')
}
// 402 Payment Required
if (response.status === 402) {
throw new Error('Payment required!')
}
// unknown error
throw new Error('Unknown error in uploading file!')
}
try {
const parsedResponse = await response.json()
if (!validateFileUploadResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
} catch (error) {
throw new Error('Error parsing JSON response!')
}
}
/**
* Generates the URL for downloading a file from a NIP-96 compliant server.
*
* @param fileHash - The SHA-256 hash of the original file.
* @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration.
* @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png').
* @returns A string representing the complete URL to download the file.
*
*/
export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string {
// Construct the base download URL using the file hash
let downloadUrl = `${serverDownloadUrl}/${fileHash}`
// Append the file extension if provided
if (fileExtension) {
downloadUrl += fileExtension
}
return downloadUrl
}
/**
* Sends a request to delete a file from a NIP-96 compliant server.
*
* @param fileHash - The SHA-256 hash of the original file.
* @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration.
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
* @returns A promise that resolves to the server's response to the deletion request.
*
*/
export async function deleteFile(
fileHash: string,
serverApiUrl: string,
nip98AuthorizationHeader: string,
): Promise<any> {
// make sure the serverApiUrl ends with a slash
if (!serverApiUrl.endsWith('/')) {
serverApiUrl += '/'
}
// Construct the URL for the delete request
const deleteUrl = `${serverApiUrl}${fileHash}`
// Send the DELETE request
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
Authorization: nip98AuthorizationHeader,
},
})
// Handle the response
if (!response.ok) {
throw new Error('Error deleting file!')
}
// Return the response from the server
try {
return await response.json()
} catch (error) {
throw new Error('Error parsing JSON response!')
}
}
/**
* Validates the server's response to a delayed processing request.
*
* @param response - The server's response to a delayed processing request.
* @returns A boolean indicating whether the response is valid.
*/
export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse {
if (typeof response !== 'object' || response === null) return false
if (!response.status || !response.message || !response.percentage) {
return false
}
if (response.status !== 'processing' && response.status !== 'error') {
return false
}
if (typeof response.message !== 'string') {
return false
}
if (typeof response.percentage !== 'number') {
return false
}
if (Number(response.percentage) < 0 || Number(response.percentage) > 100) {
return false
}
return true
}
/**
* Checks the processing status of a file when delayed processing is used.
*
* @param processingUrl - The URL provided by the server where the processing status can be checked.
* @returns A promise that resolves to an object containing the processing status and other relevant information.
*/
export async function checkFileProcessingStatus(
processingUrl: string,
): Promise<FileUploadResponse | DelayedProcessingResponse> {
// Make the GET request to the processing URL
const response = await fetch(processingUrl)
// Handle the response
if (!response.ok) {
throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`)
}
// Parse the response
try {
const parsedResponse = await response.json()
// 201 Created: Indicates the processing is over.
if (response.status === 201) {
// Validate the response
if (!validateFileUploadResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
// 200 OK: Indicates the processing is still ongoing.
if (response.status === 200) {
// Validate the response
if (!validateDelayedProcessingResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
throw new Error('Invalid response from the server!')
} catch (error) {
throw new Error('Error parsing JSON response!')
}
}
/**
* Generates an event template to indicate a user's File Server Preferences.
* This event is of kind 10096 and is used to specify one or more preferred servers for file uploads.
*
* @param serverUrls - An array of URLs representing the user's preferred file storage servers.
* @returns An object representing a Nostr event template for setting file server preferences.
*/
export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate {
serverUrls = serverUrls.filter(serverUrl => {
try {
new URL(serverUrl)
return true
} catch (error) {
return false
}
})
return {
kind: FileServerPreference,
content: '',
tags: serverUrls.map(serverUrl => ['server', serverUrl]),
created_at: Math.floor(Date.now() / 1000),
}
}
/**
* Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations,
* such as file upload, download, and deletion, to uniquely identify files.
*
* @param file - The file for which the SHA-256 hash needs to be calculated.
* @returns A promise that resolves to the SHA-256 hash of the file.
*/
export async function calculateFileHash(file: Blob): Promise<string> {
return bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
}

350
nip98.test.ts Normal file
View File

@@ -0,0 +1,350 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { describe, expect, test } from 'bun:test'
import { HTTPAuth } from './kinds.ts'
import {
getToken,
hashPayload,
unpackEventFromToken,
validateEvent,
validateEventKind,
validateEventMethodTag,
validateEventPayloadTag,
validateEventTimestamp,
validateEventUrlTag,
validateToken,
} from './nip98.ts'
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { utf8Encoder } from './utils.ts'
describe('getToken', () => {
test('returns without authorization scheme for GET', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get'],
])
})
test('returns token without authorization scheme for POST', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token WITH authorization scheme for POST', async () => {
const authorizationScheme = 'Nostr '
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(token.startsWith(authorizationScheme)).toBe(true)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token with a valid payload tag when payload is present', async () => {
const sk = generateSecretKey()
const payload = { test: 'payload' }
const payloadHash = hashPayload(payload)
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
])
})
})
describe('validateToken', () => {
test('returns true for valid token without authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
})
test('returns true for valid token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
})
test('throws an error for invalid token', async () => {
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for missing token', async () => {
const isTokenValid = validateToken('', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.kind = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.created_at = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid url', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid method', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://test.com', 'post')
expect(isTokenValid).rejects.toThrow(Error)
})
})
describe('validateEvent', () => {
test('returns true for valid decoded token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).toBe(true)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
expect(isEventValid).rejects.toThrow(Error)
})
test('returns true for valid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
expect(isEventValid).toBe(true)
})
test('returns false for invalid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(isEventValid).rejects.toThrow(Error)
})
})
describe('validateEventTimestamp', () => {
test('returns true for valid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(true)
})
test('returns false for invalid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(false)
})
})
describe('validateEventKind', () => {
test('returns true for valid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(true)
})
test('returns false for invalid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(false)
})
})
describe('validateEventUrlTag', () => {
test('returns true for valid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
expect(isEventUrlTagValid).toBe(true)
})
test('returns false for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
expect(isEventUrlTagValid).toBe(false)
})
})
describe('validateEventMethodTag', () => {
test('returns true for valid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
expect(isEventMethodTagValid).toBe(true)
})
test('returns false for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
expect(isEventMethodTagValid).toBe(false)
})
})
describe('validateEventPayloadTag', () => {
test('returns true for valid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
expect(isEventPayloadTagValid).toBe(true)
})
test('returns false for invalid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
expect(isEventPayloadTagValid).toBe(false)
})
test('returns false for missing payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
expect(isEventPayloadTagValid).toBe(false)
})
})
describe('hashPayload', () => {
test('returns hash for valid payload', async () => {
const payload = { test: 'payload' }
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
test('returns hash for empty payload', async () => {
const payload = {}
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
})

206
nip98.ts Normal file
View File

@@ -0,0 +1,206 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { HTTPAuth } from './kinds.ts'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
const _authorizationScheme = 'Nostr '
/**
* 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', hashPayload(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
}
/**
* Unpacks an event from a token.
*
* @param token - The token to unpack.
* @returns A promise that resolves to the unpacked event.
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
*/
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
}
/**
* Validates the timestamp of an event.
* @param event - The event object to validate.
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
*/
export function validateEventTimestamp(event: Event): boolean {
if (!event.created_at) {
return false
}
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
}
/**
* Validates the kind of an event.
* @param event The event to validate.
* @returns A boolean indicating whether the event kind is valid.
*/
export function validateEventKind(event: Event): boolean {
return event.kind === HTTPAuth
}
/**
* Validates if the given URL matches the URL tag of the event.
* @param event - The event object.
* @param url - The URL to validate.
* @returns A boolean indicating whether the URL is valid or not.
*/
export function validateEventUrlTag(event: Event, url: string): boolean {
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag) {
return false
}
return urlTag.length > 0 && urlTag[1] === url
}
/**
* Validates if the given event has a method tag that matches the specified method.
* @param event - The event to validate.
* @param method - The method to match against the method tag.
* @returns A boolean indicating whether the event has a matching method tag.
*/
export function validateEventMethodTag(event: Event, method: string): boolean {
const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag) {
return false
}
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
}
/**
* Calculates the hash of a payload.
* @param payload - The payload to be hashed.
* @returns The hash value as a string.
*/
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Validates the event payload tag against the provided payload.
* @param event The event object.
* @param payload The payload to validate.
* @returns A boolean indicating whether the payload tag is valid.
*/
export function validateEventPayloadTag(event: Event, payload: any): boolean {
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (!payloadTag) {
return false
}
const payloadHash = hashPayload(payload)
return payloadTag.length > 0 && payloadTag[1] === payloadHash
}
/**
* Validates a Nostr event for the NIP-98 flow.
*
* @param event - The Nostr event to validate.
* @param url - The URL associated with the event.
* @param method - The HTTP method associated with the event.
* @param body - The request body associated with the event (optional).
* @returns A promise that resolves to a boolean indicating whether the event is valid.
* @throws An error if the event is invalid.
*/
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (!validateEventKind(event)) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!validateEventTimestamp(event)) {
throw new Error('Invalid nostr event, created_at timestamp invalid')
}
if (!validateEventUrlTag(event, url)) {
throw new Error('Invalid nostr event, url tag invalid')
}
if (!validateEventMethodTag(event, method)) {
throw new Error('Invalid nostr event, method tag invalid')
}
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
if (!validateEventPayloadTag(event, body)) {
throw new Error('Invalid nostr event, payload tag does not match request body hash')
}
}
return true
}

506
nip99.test.ts Normal file
View File

@@ -0,0 +1,506 @@
import { describe, expect, test } from 'bun:test'
import { Event } from './core'
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
import { finalizeEvent, generateSecretKey } from './pure'
describe('validateEvent', () => {
test('should return true for a valid classified listing event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
test('should return false when the "d" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "title" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
// Missing 'title' tag
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "summary" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
// Missing 'summary' tag
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
// Missing 'published_at' tag
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "location" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
// Missing 'location' tag
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
// Missing 'price' tag
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is not a valid timestamp', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', 'not-a-valid-timestamp'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid price', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', 'not-a-valid-price', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid currency', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'not-a-valid-currency'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
})
test('should return false when the "a" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['a', 'extra1'],
['a', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
const event2: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['e', 'extra1'],
['e', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
expect(validateEvent(event2)).toBe(false)
})
})
describe('parseEvent', () => {
test('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
const expectedListing = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
e: ['value1', 'value2'],
a: ['value1', 'value2'],
},
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
expect(parseEvent(event)).toEqual(expectedListing)
})
test('should throw an error for an invalid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow(Error)
})
})
describe('generateEventTemplate', () => {
test('should generate the correct event template for a classified listing', () => {
const listing: ClassifiedListingObject = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
extra1: 'value1',
extra2: 'value2',
},
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
const expectedEventTemplate = {
kind: DraftClassifiedListing,
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['published_at', '1296962229'],
['summary', 'Sample Summary'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['extra1', 'value1'],
['extra2', 'value2'],
],
created_at: expect.any(Number),
}
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
})
})

228
nip99.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Event, EventTemplate } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
/**
* Represents the details of a price.
* @example { amount: '100', currency: 'USD', frequency: 'month' }
* @example { amount: '100', currency: 'EUR' }
*/
export type PriceDetails = {
/**
* The amount of the price.
*/
amount: string
/**
* The currency of the price in 3-letter ISO 4217 format.
* @example 'USD'
*/
currency: string
/**
* The optional frequency of payment.
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
*/
frequency?: string
}
/**
* Represents a classified listing object.
*/
export type ClassifiedListingObject = {
/**
* Whether the listing is a draft or not.
*/
isDraft: boolean
/**
* A title of the listing.
*/
title: string
/**
* A short summary or tagline.
*/
summary: string
/**
* A description in Markdown format.
*/
content: string
/**
* Timestamp in unix seconds of when the listing was published.
*/
publishedAt: string
/**
* Location of the listing.
* @example 'NYC'
*/
location: string
/**
* Price details.
*/
price: PriceDetails
/**
* Images of the listing with optional dimensions.
*/
images: Array<{
url: string
dimensions?: string
}>
/**
* Tags/Hashtags (i.e. categories, keywords, etc.)
*/
hashtags: string[]
/**
* Other standard tags.
* @example "g", a geohash for more precise location
*/
additionalTags: Record<string, string | string[]>
}
/**
* Validates an event to ensure it is a valid classified listing event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
const requiredTagCount = requiredTags.length
const tagCounts: Record<string, number> = {}
if (event.tags.length < requiredTagCount) return false
for (const tag of event.tags) {
if (tag.length < 2) return false
const [tagName, ...tagValues] = tag
if (tagName == 'published_at') {
const timestamp = parseInt(tagValues[0])
if (isNaN(timestamp)) return false
} else if (tagName == 'price') {
if (tagValues.length < 2) return false
const price = parseInt(tagValues[0])
if (isNaN(price) || tagValues[1].length != 3) return false
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
return false
}
if (requiredTags.includes(tagName)) {
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
}
}
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
}
/**
* Parses an event and returns a classified listing object.
* @param event - The event to parse.
* @returns The classified listing object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): ClassifiedListingObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const listing: ClassifiedListingObject = {
isDraft: event.kind === DraftClassifiedListing,
title: '',
summary: '',
content: event.content,
publishedAt: '',
location: '',
price: {
amount: '',
currency: '',
},
images: [],
hashtags: [],
additionalTags: {},
}
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
const [tagName, ...tagValues] = tag
if (tagName == 'title') {
listing.title = tagValues[0]
} else if (tagName == 'summary') {
listing.summary = tagValues[0]
} else if (tagName == 'published_at') {
listing.publishedAt = tagValues[0]
} else if (tagName == 'location') {
listing.location = tagValues[0]
} else if (tagName == 'price') {
listing.price.amount = tagValues[0]
listing.price.currency = tagValues[1]
if (tagValues.length == 3) {
listing.price.frequency = tagValues[2]
}
} else if (tagName == 'image') {
listing.images.push({
url: tagValues[0],
dimensions: tagValues?.[1] ?? undefined,
})
} else if (tagName == 't') {
listing.hashtags.push(tagValues[0])
} else if (tagName == 'e' || tagName == 'a') {
listing.additionalTags[tagName] = [...tagValues]
}
}
return listing
}
/**
* Generates an event template based on a classified listing object.
*
* @param listing - The classified listing object.
* @returns The event template.
*/
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
const priceTag = ['price', listing.price.amount, listing.price.currency]
if (listing.price.frequency) priceTag.push(listing.price.frequency)
const tags: string[][] = [
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
['title', listing.title],
['published_at', listing.publishedAt],
['summary', listing.summary],
['location', listing.location],
priceTag,
]
for (let i = 0; i < listing.images.length; i++) {
const image = listing.images[i]
const imageTag = ['image', image.url]
if (image.dimensions) imageTag.push(image.dimensions)
tags.push(imageTag)
}
for (let i = 0; i < listing.hashtags.length; i++) {
const t = listing.hashtags[i]
tags.push(['t', t])
}
for (const [key, value] of Object.entries(listing.additionalTags)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const val = value[i]
tags.push([key, val])
}
} else {
tags.push([key, value])
}
}
return {
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
content: listing.content,
tags,
created_at: Math.floor(Date.now() / 1000),
}
}

View File

@@ -1,36 +1,259 @@
{
"type": "module",
"name": "nostr-tools",
"version": "0.15.1",
"version": "2.3.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/fiatjaf/nostr-tools.git"
"url": "https://github.com/nbd-wtf/nostr-tools.git"
},
"files": [
"lib"
],
"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"
},
"./core": {
"import": "./lib/esm/core.js",
"require": "./lib/cjs/core.js",
"types": "./lib/types/core.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"
},
"./kinds": {
"import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./abstract-relay": {
"import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts"
},
"./relay": {
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./abstract-pool": {
"import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.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"
},
"./nip29": {
"import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.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"
},
"./nip44": {
"import": "./lib/esm/nip44.js",
"require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts"
},
"./nip46": {
"import": "./lib/esm/nip46.js",
"require": "./lib/cjs/nip46.js",
"types": "./lib/types/nip46.d.ts"
},
"./nip49": {
"import": "./lib/esm/nip49.js",
"require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts"
},
"./nip57": {
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip75": {
"import": "./lib/esm/nip75.js",
"require": "./lib/cjs/nip75.js",
"types": "./lib/types/nip75.d.ts"
},
"./nip94": {
"import": "./lib/esm/nip94.js",
"require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts"
},
"./nip96": {
"import": "./lib/esm/nip96.js",
"require": "./lib/cjs/nip96.js",
"types": "./lib/types/nip96.d.ts"
},
"./nip98": {
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./nip99": {
"import": "./lib/esm/nip99.js",
"require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.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/secp256k1": "^1.3.0",
"browserify-cipher": ">=1",
"buffer": ">=5",
"create-hash": "^1.2.0",
"dns-packet": "^5.2.4",
"micro-bip32": "^0.1.0",
"micro-bip39": "^0.1.3",
"randombytes": ">=2",
"websocket-polyfill": "^0.0.3"
"@noble/ciphers": "^0.5.1",
"@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"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"keywords": [
"decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social",
"unstoppable",
"censorship",
"censorship-resistance",
"client"
"client",
"nostr"
],
"devDependencies": {
"eslint": "^8.5.0",
"eslint-plugin-babel": "^5.3.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.56.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"mitata": "^0.1.6",
"mock-socket": "^9.3.1",
"msw": "^2.1.4",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"typescript": "^5.0.4"
},
"scripts": {
"prepublish": "just build"
}
}

166
pool.js
View File

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

127
pool.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { SimplePool } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
useWebSocketImplementation(MockWebSocketClient)
let pool: SimplePool
let mockRelays: MockRelay[]
let relayURLs: string[]
beforeEach(() => {
pool = new SimplePool()
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
relayURLs = mockRelays.map(mr => mr.url)
})
afterEach(() => {
pool.close(relayURLs)
})
test('removing duplicates when subscribing', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: [],
},
priv,
)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
})
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double subs', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relayURLs, [{ 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(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
expect(received).toHaveLength(2)
})
test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
})
})
expect(events.size).toBeGreaterThan(50)
})
test('querySync()', async () => {
let authors = mockRelays.flatMap(mr => mr.authors)
let events = await pool.querySync(relayURLs, {
authors: authors,
kinds: [1],
limit: 2,
})
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toBeGreaterThan(2)
expect(events).toHaveLength(uniqueEventCount)
})
test('get()', async () => {
let ids = mockRelays.flatMap(mr => mr.ids)
let event = await pool.get(relayURLs, {
ids: [ids[0]],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', ids[0])
})

10
pool.ts Normal file
View File

@@ -0,0 +1,10 @@
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent })
}
}
export * from './abstract-pool.ts'

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.ts'
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 './core.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
}

180
relay.js
View File

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

94
relay.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { expect, test } from 'bun:test'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay, useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
useWebSocketImplementation(MockWebSocketClient)
test('connectivity', async () => {
const mockRelay = new MockRelay()
const relay = new Relay(mockRelay.url)
await relay.connect()
expect(relay.connected).toBeTrue()
relay.close()
})
test('connectivity, with Relay.connect()', async () => {
const mockRelay = new MockRelay()
const relay = await Relay.connect(mockRelay.url)
expect(relay.connected).toBeTrue()
relay.close()
})
test('querying', async done => {
const mockRelay = new MockRelay()
const kind = 0
const relay = new Relay(mockRelay.url)
await relay.connect()
relay.subscribe(
[
{
authors: mockRelay.authors,
kinds: [kind],
},
],
{
onevent(event) {
expect(mockRelay.authors).toContain(event.pubkey)
expect(event).toHaveProperty('kind', kind)
relay.close()
done()
},
},
)
})
test('listening and publishing and closing', async done => {
const mockRelay = new MockRelay()
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const kind = 23571
const relay = new Relay(mockRelay.url)
await relay.connect()
let sub = relay.subscribe(
[
{
kinds: [kind],
authors: [pk],
},
],
{
onevent(event) {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', kind)
expect(event).toHaveProperty('content', 'content')
sub.close() // close the subscription and will trigger onclose()
},
onclose() {
relay.close()
done()
},
},
)
relay.publish(
finalizeEvent(
{
kind,
content: 'content',
created_at: 0,
tags: [],
},
sk,
),
)
})

23
relay.ts Normal file
View File

@@ -0,0 +1,23 @@
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
/**
* @deprecated use Relay.connect() instead.
*/
export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
}
export class Relay extends AbstractRelay {
constructor(url: string) {
super(url, { verifyEvent })
}
static async connect(url: string) {
const relay = new Relay(url)
await relay.connect()
return relay
}
}
export * from './abstract-relay.ts'

120
test-helpers.ts Normal file
View File

@@ -0,0 +1,120 @@
import { Server, WebSocket } from 'mock-socket'
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
import { matchFilters, type Filter } from './filter.ts'
export const MockWebSocketClient = WebSocket
export function buildEvent(params: Partial<Event>): Event {
return {
id: '',
kind: 1,
pubkey: '',
created_at: 0,
content: '',
tags: [],
sig: '',
...params,
}
}
let serial = 0
export class MockRelay {
private _server: Server
public url: string
public secretKeys: Uint8Array[]
public preloadedEvents: Event[]
constructor(url?: string | undefined) {
serial++
this.url = url ?? `wss://random.mock.relay/${serial}`
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
this.preloadedEvents = this.secretKeys.map(sk =>
finalizeEvent(
{
kind: 1,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [],
},
sk,
),
)
this._server = new Server(this.url)
this._server.on('connection', (conn: any) => {
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
conn.on('message', (message: string) => {
const data = JSON.parse(message)
switch (data[0]) {
case 'REQ': {
let subId = data[1]
let filters = data.slice(2)
subs[subId] = { conn, filters }
this.preloadedEvents.forEach(event => {
conn.send(JSON.stringify(['EVENT', subId, event]))
})
filters.forEach((filter: Filter) => {
const kinds = filter.kinds?.length ? filter.kinds : [1]
kinds.forEach(kind => {
this.secretKeys.forEach(sk => {
const event = finalizeEvent(
{
kind,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [],
},
sk,
)
conn.send(JSON.stringify(['EVENT', subId, event]))
})
})
})
conn.send(JSON.stringify(['EOSE', subId]))
break
}
case 'CLOSE': {
let subId = data[1]
delete subs[subId]
break
}
case 'EVENT': {
let event = data[1]
conn.send(JSON.stringify(['OK', event.id, 'true']))
for (let subId in subs) {
const { filters, conn: listener } = subs[subId]
if (matchFilters(filters, event)) {
listener.send(JSON.stringify(['EVENT', subId, event]))
}
}
break
}
}
})
})
}
get authors() {
return this.secretKeys.map(getPublicKey)
}
get ids() {
return this.preloadedEvents.map(evt => evt.id)
}
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"allowImportingTsExtensions": true,
"outDir": "lib/types",
"resolveJsonModule": true,
"rootDir": ".",
"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 './core.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])
})

117
utils.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Event } from './core.ts'
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
export function normalizeURL(url: string): string {
if (url.indexOf('://') === -1) url = 'wss://' + url
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'