mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
471 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d706ef961f | ||
|
|
2f529b3f8a | ||
|
|
f0357805c3 | ||
|
|
ffa7fb926e | ||
|
|
12acb900ab | ||
|
|
d773012658 | ||
|
|
b8f91c37fa | ||
|
|
2da3528362 | ||
|
|
315e9a472c | ||
|
|
a2b1bf0338 | ||
|
|
861a77e2b3 | ||
|
|
9132b722f3 | ||
|
|
ae2f97655b | ||
|
|
5b78a829c7 | ||
|
|
de26ee98c5 | ||
|
|
1437bbdb0f | ||
|
|
57354b9fb4 | ||
|
|
924075b803 | ||
|
|
666a02027e | ||
|
|
eff9ea9579 | ||
|
|
ca174e6cd8 | ||
|
|
4ba9c8886b | ||
|
|
7dbd86eb5c | ||
|
|
3e839db6f2 | ||
|
|
cb370fbf4f | ||
|
|
c015b6e794 | ||
|
|
52079f6e75 | ||
|
|
ef28b2eb73 | ||
|
|
2a422774fb | ||
|
|
b80f8a0bcc | ||
|
|
dd603e47d8 | ||
|
|
ba26b92973 | ||
|
|
aec8ff5946 | ||
|
|
e498c9144d | ||
|
|
42d47abba1 | ||
|
|
303c35120c | ||
|
|
4a738c93d0 | ||
|
|
2a11c9ec91 | ||
|
|
cbe3a9d683 | ||
|
|
2944a932b8 | ||
|
|
6b39de04d7 | ||
|
|
9a612e59a2 | ||
|
|
266dbdf766 | ||
|
|
19ae9837a7 | ||
|
|
4188f2c596 | ||
|
|
97bded8f5b | ||
|
|
174d36a440 | ||
|
|
0177b130c3 | ||
|
|
05eb62da5b | ||
|
|
3c4019a154 | ||
|
|
e7e8db1dbd | ||
|
|
44a679e642 | ||
|
|
c1172caf1d | ||
|
|
86f37d6003 | ||
|
|
3daade322c | ||
|
|
fcf10541c8 | ||
|
|
548abb5d4a | ||
|
|
1e5bfe856b | ||
|
|
3266b4d4c2 | ||
|
|
a0b950ab12 | ||
|
|
be741159d7 | ||
|
|
9c50b2c655 | ||
|
|
bbb09420fe | ||
|
|
2e85f7a5fe | ||
|
|
b22e2465cc | ||
|
|
43ce7f9377 | ||
|
|
5a55c670fb | ||
|
|
bf0c4d4988 | ||
|
|
50fe7c2a8b | ||
|
|
29270c8c9d | ||
|
|
cb29d62033 | ||
|
|
0d237405d9 | ||
|
|
659ad36b62 | ||
|
|
d062ab8afd | ||
|
|
94f841f347 | ||
|
|
c1d03cf00b | ||
|
|
29ecdfc5ec | ||
|
|
d3fc4734b4 | ||
|
|
66d0b8a4e1 | ||
|
|
e2ec7a4b55 | ||
|
|
a72e47135a | ||
|
|
de7bbfc6a2 | ||
|
|
f2d421fa4f | ||
|
|
cae06fc4fe | ||
|
|
5c538efa38 | ||
|
|
013daae91b | ||
|
|
75660e7ff1 | ||
|
|
4c2d2b5ce6 | ||
|
|
aba266b8e6 | ||
|
|
d7dcc75ebe | ||
|
|
b18510b460 | ||
|
|
b04e0d16c0 | ||
|
|
633696bf46 | ||
|
|
bf975c9a87 | ||
|
|
7aa4f09769 | ||
|
|
f646fcd889 | ||
|
|
1d89038375 | ||
|
|
0b5b35714c | ||
|
|
e398617fdc | ||
|
|
1b236faa7b | ||
|
|
7064e0b828 | ||
|
|
4f6976f6f8 | ||
|
|
a61cde77ea | ||
|
|
23d95acb26 | ||
|
|
13ac04b8f8 | ||
|
|
45b25c5bf5 | ||
|
|
ee76d69b4b | ||
|
|
21433049b8 | ||
|
|
e8ff68f0b3 | ||
|
|
1b77d6e080 | ||
|
|
76d3a91600 | ||
|
|
6f334f31a7 | ||
|
|
9c009ac543 | ||
|
|
a87099fa5c | ||
|
|
475a22a95f | ||
|
|
54e352d8e2 | ||
|
|
235a1c50cb | ||
|
|
dfc2107569 | ||
|
|
986b9d0cce | ||
|
|
753ff323ea | ||
|
|
f8c3e20f3d | ||
|
|
87a91c2daf | ||
|
|
4f1dc9ef1c | ||
|
|
faa1a9d556 | ||
|
|
97d838f254 | ||
|
|
260400b24d | ||
|
|
6e5ab34a54 | ||
|
|
9562c408b3 | ||
|
|
4f4de458e9 | ||
|
|
88454de628 | ||
|
|
9f5984d78d | ||
|
|
80df21d47f | ||
|
|
296e99d2a4 | ||
|
|
1cd9847ad5 | ||
|
|
fa31fdca78 | ||
|
|
5876acd67a | ||
|
|
44efd49bc0 | ||
|
|
f4f9bece6e | ||
|
|
e217f751da | ||
|
|
d0ae8b36a2 | ||
|
|
fd945757be | ||
|
|
c12ddd3c53 | ||
|
|
1e9f828e3e | ||
|
|
0a5eaac088 | ||
|
|
e858698cb9 | ||
|
|
b349ee577d | ||
|
|
849a2ac3f3 | ||
|
|
c18b94677c | ||
|
|
f306cec716 | ||
|
|
5c7e9c8f36 | ||
|
|
1d7620a057 | ||
|
|
88247e56c1 | ||
|
|
e5cda3509c | ||
|
|
02da1dc036 | ||
|
|
7aed747bb2 | ||
|
|
747a7944d7 | ||
|
|
9f8b7274b3 | ||
|
|
ee565db7f5 | ||
|
|
e9ee8258e7 | ||
|
|
ad07d260ab | ||
|
|
632184afb8 | ||
|
|
d7d5d30f41 | ||
|
|
387ce2c335 | ||
|
|
b62b8f88af | ||
|
|
6b43533f2e | ||
|
|
e30e08d8e2 | ||
|
|
59426d9f35 | ||
|
|
5429142858 | ||
|
|
564c9bca17 | ||
|
|
0190ae94a7 | ||
|
|
e1bde08ff3 | ||
|
|
71456feb20 | ||
|
|
ca928c697b | ||
|
|
7b98cae7fa | ||
|
|
db53f37161 | ||
|
|
1691f0b51d | ||
|
|
3b582a0206 | ||
|
|
8ed2c13c28 | ||
|
|
27a536f41d | ||
|
|
fbc82d0b73 | ||
|
|
9c0ade1329 | ||
|
|
63ccc8b4c8 | ||
|
|
7cf7df88db | ||
|
|
bded539122 | ||
|
|
3647bbd68a | ||
|
|
fb085ffdf7 | ||
|
|
280d483ef4 | ||
|
|
54b55b98f1 | ||
|
|
84f9881812 | ||
|
|
db6baf2e6b | ||
|
|
bb1e6f4356 | ||
|
|
5626d3048b | ||
|
|
058d0276e2 | ||
|
|
37b046c047 | ||
|
|
846654b449 | ||
|
|
b676dc0987 | ||
|
|
b1ce901555 | ||
|
|
62e5730965 | ||
|
|
01f13292bb | ||
|
|
7b0458db72 | ||
|
|
3aab7121f7 | ||
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 | ||
|
|
498c1603b0 | ||
|
|
4cfc67e294 | ||
|
|
da51418f04 | ||
|
|
75df47421f | ||
|
|
1cfe705baf | ||
|
|
566437fe2e | ||
|
|
5d6c2b9e5d | ||
|
|
a43f2a708c | ||
|
|
f727058a3a | ||
|
|
1de54838d3 | ||
|
|
703c29a311 | ||
|
|
ddf1064da9 | ||
|
|
f719d99a11 | ||
|
|
6152238d65 | ||
|
|
9ac1b63994 | ||
|
|
1890c91ae3 | ||
|
|
7067b47cd4 | ||
|
|
397931f847 | ||
|
|
5d795c291f | ||
|
|
7adbd30799 | ||
|
|
83b6dd7ec3 | ||
|
|
d61cc6c9bf | ||
|
|
d7dad8e204 | ||
|
|
daaa2ef0a1 | ||
|
|
7f11c0c618 | ||
|
|
a4ae964ee6 | ||
|
|
1f7378ca49 | ||
|
|
d155bcdcda | ||
|
|
919d29363e | ||
|
|
ef12a451be | ||
|
|
a9acdada19 | ||
|
|
bf3818e434 | ||
|
|
b7389be5c7 | ||
|
|
7552a36ff2 | ||
|
|
1b31a27d89 | ||
|
|
0cc3c02d84 | ||
|
|
8625d45152 | ||
|
|
8f03116687 | ||
|
|
e6d1808fda | ||
|
|
9648de3470 | ||
|
|
fe87529646 | ||
|
|
1908e1ee0d | ||
|
|
2571db9afc | ||
|
|
f77b9eab10 | ||
|
|
71b412657f | ||
|
|
8840c4d8e2 | ||
|
|
804403f574 | ||
|
|
965ebdb6d1 | ||
|
|
c54fd95b3e | ||
|
|
7a6c0754ad | ||
|
|
9e4911160a | ||
|
|
73c6630cf7 | ||
|
|
88703e9ea2 | ||
|
|
07d208308f | ||
|
|
f56f2ae709 | ||
|
|
a0cb2eecae | ||
|
|
2a7fd83be8 | ||
|
|
1ebe098805 | ||
|
|
3bfb50e267 | ||
|
|
420a6910e9 | ||
|
|
7a640092d0 | ||
|
|
3d541e537e | ||
|
|
1357642575 | ||
|
|
d16f3f77c3 | ||
|
|
0108e3b605 | ||
|
|
2ac69278ce | ||
|
|
bf31f2eba3 | ||
|
|
39cfc5c09e | ||
|
|
3d767beeb9 | ||
|
|
36e0de2a68 | ||
|
|
9cd4f16e45 | ||
|
|
6a07e7c1cc | ||
|
|
1939c46eaa | ||
|
|
93538d2373 | ||
|
|
19b3faea17 | ||
|
|
867aa11d12 | ||
|
|
4fcf925387 | ||
|
|
40c5337ef0 | ||
|
|
350d8ec3b6 | ||
|
|
c5f3c8052e | ||
|
|
dc04d1eb85 | ||
|
|
a2a15567b7 | ||
|
|
318e3f8c88 | ||
|
|
894ffff1f0 | ||
|
|
ce11a5fc89 | ||
|
|
5e85bbc2ed | ||
|
|
eb0a9093f2 | ||
|
|
c73268c4e2 | ||
|
|
6874f58c0a | ||
|
|
e899cc32b7 | ||
|
|
de72172583 | ||
|
|
073dcaafd6 | ||
|
|
8e932f0c5a | ||
|
|
f9a048679f | ||
|
|
6db8b94275 | ||
|
|
13bc2ad5a8 | ||
|
|
55f032d0a4 | ||
|
|
c890e29290 | ||
|
|
c18f050468 | ||
|
|
401b9c7864 | ||
|
|
c175f6c804 | ||
|
|
41265a19f5 | ||
|
|
d88761907a | ||
|
|
8325d4351e | ||
|
|
62bf592d72 | ||
|
|
54f3bedf38 | ||
|
|
34e0ad8c41 | ||
|
|
e9eac28bab | ||
|
|
85035d61f2 | ||
|
|
cf46560619 | ||
|
|
e7aa23cb1d | ||
|
|
5977d68ec2 | ||
|
|
48767d382d | ||
|
|
718032022c | ||
|
|
2a70bb18ff | ||
|
|
9effe807d1 | ||
|
|
899c2bd0dc | ||
|
|
918d514a25 | ||
|
|
48cb9046c4 | ||
|
|
864dd28b26 | ||
|
|
fa085367c9 | ||
|
|
350951b88e | ||
|
|
c6133f7160 | ||
|
|
470512bbeb | ||
|
|
c3acb82464 | ||
|
|
fc23d05764 | ||
|
|
8296ce897c | ||
|
|
3ca78c0e13 | ||
|
|
837a05e54d | ||
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
1c6f39e4ae | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f | ||
|
|
55ff796b9f | ||
|
|
3ef2ad5bc4 | ||
|
|
45c07a5f45 | ||
|
|
6a037d1658 | ||
|
|
dcf101c6c2 | ||
|
|
eb97dbd9ef | ||
|
|
92988051c6 |
19
.devcontainer/Dockerfile
Executable file
19
.devcontainer/Dockerfile
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20
|
||||||
|
|
||||||
|
RUN npm install typescript eslint prettier -g
|
||||||
|
|
||||||
|
# Install bun
|
||||||
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Install just
|
||||||
|
WORKDIR /usr/bin
|
||||||
|
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||||
|
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||||
|
RUN chmod +x ./just
|
||||||
|
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||||
|
|
||||||
|
WORKDIR /nostr-tools
|
||||||
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
|
# The run the start script
|
||||||
|
CMD [ "/bin/bash" ]
|
||||||
19
.devcontainer/devcontainer.json
Executable file
19
.devcontainer/devcontainer.json
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Nostr Tools",
|
||||||
|
"dockerComposeFile": [
|
||||||
|
"docker-compose.yml"
|
||||||
|
],
|
||||||
|
"service": "nostr-tools-dev",
|
||||||
|
"workspaceFolder": "/nostr-tools",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-vscode.vscode-typescript-next",
|
||||||
|
"eamodio.gitlens",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"manishsencha.readme-preview",
|
||||||
|
"wix.vscode-import-cost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.devcontainer/docker-compose.yml
Executable file
13
.devcontainer/docker-compose.yml
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nostr-tools-dev:
|
||||||
|
image: nostr-tools-dev
|
||||||
|
container_name: nostr-tools-dev
|
||||||
|
build:
|
||||||
|
context: ../.
|
||||||
|
dockerfile: ./.devcontainer/Dockerfile
|
||||||
|
working_dir: /nostr-tools
|
||||||
|
volumes:
|
||||||
|
- ..:/nostr-tools:cached
|
||||||
|
tty: true
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
|
"extends": ["prettier"],
|
||||||
|
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint"],
|
||||||
@@ -18,8 +19,6 @@
|
|||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"plugins": ["babel"],
|
|
||||||
|
|
||||||
"globals": {
|
"globals": {
|
||||||
"document": false,
|
"document": false,
|
||||||
"navigator": false,
|
"navigator": false,
|
||||||
@@ -36,23 +35,21 @@
|
|||||||
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"accessor-pairs": 2,
|
"accessor-pairs": 2,
|
||||||
"arrow-spacing": [2, {"before": true, "after": true}],
|
"arrow-spacing": [2, { "before": true, "after": true }],
|
||||||
"block-spacing": [2, "always"],
|
"block-spacing": [2, "always"],
|
||||||
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
|
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
|
||||||
"comma-dangle": 0,
|
"comma-dangle": 0,
|
||||||
"comma-spacing": [2, {"before": false, "after": true}],
|
"comma-spacing": [2, { "before": false, "after": true }],
|
||||||
"comma-style": [2, "last"],
|
"comma-style": [2, "last"],
|
||||||
"constructor-super": 2,
|
"constructor-super": 2,
|
||||||
"curly": [0, "multi-line"],
|
"curly": [0, "multi-line"],
|
||||||
"dot-location": [2, "property"],
|
"dot-location": [2, "property"],
|
||||||
"eol-last": 2,
|
"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,
|
"indent": 0,
|
||||||
"jsx-quotes": [2, "prefer-double"],
|
"jsx-quotes": [2, "prefer-double"],
|
||||||
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
|
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||||
"keyword-spacing": [2, {"before": true, "after": true}],
|
"keyword-spacing": [2, { "before": true, "after": true }],
|
||||||
"new-cap": 0,
|
"new-cap": 0,
|
||||||
"new-parens": 0,
|
"new-parens": 0,
|
||||||
"no-array-constructor": 2,
|
"no-array-constructor": 2,
|
||||||
@@ -84,12 +81,12 @@
|
|||||||
"no-irregular-whitespace": 2,
|
"no-irregular-whitespace": 2,
|
||||||
"no-iterator": 2,
|
"no-iterator": 2,
|
||||||
"no-label-var": 2,
|
"no-label-var": 2,
|
||||||
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
|
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
|
||||||
"no-lone-blocks": 2,
|
"no-lone-blocks": 2,
|
||||||
"no-mixed-spaces-and-tabs": 2,
|
"no-mixed-spaces-and-tabs": 2,
|
||||||
"no-multi-spaces": 2,
|
"no-multi-spaces": 2,
|
||||||
"no-multi-str": 2,
|
"no-multi-str": 2,
|
||||||
"no-multiple-empty-lines": [2, {"max": 2}],
|
"no-multiple-empty-lines": [2, { "max": 2 }],
|
||||||
"no-native-reassign": 2,
|
"no-native-reassign": 2,
|
||||||
"no-negated-in-lhs": 2,
|
"no-negated-in-lhs": 2,
|
||||||
"no-new": 0,
|
"no-new": 0,
|
||||||
@@ -103,7 +100,6 @@
|
|||||||
"no-octal-escape": 2,
|
"no-octal-escape": 2,
|
||||||
"no-path-concat": 0,
|
"no-path-concat": 0,
|
||||||
"no-proto": 2,
|
"no-proto": 2,
|
||||||
"no-redeclare": 2,
|
|
||||||
"no-regex-spaces": 2,
|
"no-regex-spaces": 2,
|
||||||
"no-return-assign": 0,
|
"no-return-assign": 0,
|
||||||
"no-self-assign": 2,
|
"no-self-assign": 2,
|
||||||
@@ -118,34 +114,24 @@
|
|||||||
"no-undef": 2,
|
"no-undef": 2,
|
||||||
"no-undef-init": 2,
|
"no-undef-init": 2,
|
||||||
"no-unexpected-multiline": 2,
|
"no-unexpected-multiline": 2,
|
||||||
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
|
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||||
"no-unreachable": 2,
|
"no-unreachable": 2,
|
||||||
"no-unused-vars": [
|
"no-unused-vars": "off",
|
||||||
2,
|
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||||
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
|
|
||||||
],
|
|
||||||
"no-useless-call": 2,
|
"no-useless-call": 2,
|
||||||
"no-useless-constructor": 2,
|
"no-useless-constructor": 2,
|
||||||
"no-with": 2,
|
"no-with": 2,
|
||||||
"one-var": [0, {"initialized": "never"}],
|
"one-var": [0, { "initialized": "never" }],
|
||||||
"operator-linebreak": [
|
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
|
||||||
2,
|
|
||||||
"after",
|
|
||||||
{"overrides": {"?": "before", ":": "before"}}
|
|
||||||
],
|
|
||||||
"padded-blocks": [2, "never"],
|
"padded-blocks": [2, "never"],
|
||||||
"quotes": [
|
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
|
||||||
2,
|
|
||||||
"single",
|
|
||||||
{"avoidEscape": true, "allowTemplateLiterals": true}
|
|
||||||
],
|
|
||||||
"semi": [2, "never"],
|
"semi": [2, "never"],
|
||||||
"semi-spacing": [2, {"before": false, "after": true}],
|
"semi-spacing": [2, { "before": false, "after": true }],
|
||||||
"space-before-blocks": [2, "always"],
|
"space-before-blocks": [2, "always"],
|
||||||
"space-before-function-paren": 0,
|
"space-before-function-paren": 0,
|
||||||
"space-in-parens": [2, "never"],
|
"space-in-parens": [2, "never"],
|
||||||
"space-infix-ops": 2,
|
"space-infix-ops": 2,
|
||||||
"space-unary-ops": [2, {"words": true, "nonwords": false}],
|
"space-unary-ops": [2, { "words": true, "nonwords": false }],
|
||||||
"spaced-comment": 0,
|
"spaced-comment": 0,
|
||||||
"template-curly-spacing": [2, "never"],
|
"template-curly-spacing": [2, "never"],
|
||||||
"use-isnan": 2,
|
"use-isnan": 2,
|
||||||
|
|||||||
23
.github/workflows/npm-publish.yml
vendored
23
.github/workflows/npm-publish.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: publish npm package
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [v*]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-npm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
- uses: extractions/setup-just@v1
|
|
||||||
- run: just install-dependencies
|
|
||||||
- run: just build
|
|
||||||
- run: just test
|
|
||||||
- run: just emit-types
|
|
||||||
- uses: JS-DevTools/npm-publish@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.NPM_TOKEN }}
|
|
||||||
greater-version-only: true
|
|
||||||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -10,10 +10,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3
|
- uses: oven-sh/setup-bun@v1
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
- uses: extractions/setup-just@v1
|
- uses: extractions/setup-just@v1
|
||||||
- run: just install-dependencies
|
- run: bun i
|
||||||
- run: just build
|
|
||||||
- run: just test
|
- 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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ package-lock.json
|
|||||||
.envrc
|
.envrc
|
||||||
lib
|
lib
|
||||||
test.html
|
test.html
|
||||||
|
bench.js
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
semi: false
|
|
||||||
arrowParens: avoid
|
arrowParens: avoid
|
||||||
|
bracketSpacing: true
|
||||||
insertPragma: false
|
insertPragma: false
|
||||||
printWidth: 80
|
printWidth: 120
|
||||||
proseWrap: preserve
|
proseWrap: preserve
|
||||||
|
semi: false
|
||||||
singleQuote: true
|
singleQuote: true
|
||||||
trailingComma: none
|
trailingComma: all
|
||||||
useTabs: false
|
useTabs: false
|
||||||
jsxBracketSameLine: false
|
|
||||||
bracketSpacing: false
|
|
||||||
|
|||||||
24
LICENSE
Normal file
24
LICENSE
Normal 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>
|
||||||
457
README.md
457
README.md
@@ -1,328 +1,375 @@
|
|||||||
# nostr-tools
|
#  [](https://jsr.io/@nostr/tools) nostr-tools
|
||||||
|
|
||||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||||
|
|
||||||
Only depends on _@scure_ and _@noble_ packages.
|
Only depends on _@scure_ and _@noble_ packages.
|
||||||
|
|
||||||
|
This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install nostr-tools # or yarn add nostr-tools
|
# npm
|
||||||
|
npm install --save nostr-tools
|
||||||
|
|
||||||
|
# jsr
|
||||||
|
npx jsr add @nostr/tools
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
https://jsr.io/@nostr/tools/doc
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Generating a private key and a public key
|
### Generating a private key and a public key
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
|
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
|
|
||||||
let sk = generatePrivateKey() // `sk` is a hex string
|
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To get the secret key in hex format, use
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
|
||||||
|
|
||||||
|
let skHex = bytesToHex(sk)
|
||||||
|
let backToBytes = hexToBytes(skHex)
|
||||||
|
```
|
||||||
|
|
||||||
### Creating, signing and verifying events
|
### Creating, signing and verifying events
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {
|
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
signEvent,
|
|
||||||
getEventHash,
|
|
||||||
getPublicKey
|
|
||||||
} from 'nostr-tools'
|
|
||||||
|
|
||||||
let event = {
|
let event = finalizeEvent({
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'hello',
|
content: 'hello',
|
||||||
pubkey: getPublicKey(privateKey)
|
}, sk)
|
||||||
}
|
|
||||||
|
|
||||||
event.id = getEventHash(event)
|
let isGood = verifyEvent(event)
|
||||||
event.sig = signEvent(event, privateKey)
|
|
||||||
|
|
||||||
let ok = validateEvent(event)
|
|
||||||
let veryOk = verifySignature(event)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interacting with a relay
|
### Interacting with one or multiple relays
|
||||||
|
|
||||||
|
Doesn't matter what you do, you always should be using a `SimplePool`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {
|
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
relayInit,
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
generatePrivateKey,
|
|
||||||
getPublicKey,
|
|
||||||
getEventHash,
|
|
||||||
signEvent
|
|
||||||
} from 'nostr-tools'
|
|
||||||
|
|
||||||
const relay = relayInit('wss://relay.example.com')
|
const pool = new SimplePool()
|
||||||
relay.on('connect', () => {
|
|
||||||
console.log(`connected to ${relay.url}`)
|
|
||||||
})
|
|
||||||
relay.on('error', () => {
|
|
||||||
console.log(`failed to connect to ${relay.url}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
await relay.connect()
|
const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||||
|
|
||||||
// let's query for an event that exists
|
// let's query for one event that exists
|
||||||
let sub = relay.sub([
|
const event = pool.get(
|
||||||
|
relays,
|
||||||
{
|
{
|
||||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||||
}
|
},
|
||||||
])
|
)
|
||||||
sub.on('event', event => {
|
if (event) {
|
||||||
console.log('we got the event we wanted:', event)
|
console.log('it exists indeed on this relay:', event)
|
||||||
})
|
}
|
||||||
sub.on('eose', () => {
|
|
||||||
sub.unsub()
|
|
||||||
})
|
|
||||||
|
|
||||||
// let's publish a new event while simultaneously monitoring the relay for it
|
// let's query for more than one event that exists
|
||||||
let sk = generatePrivateKey()
|
const events = pool.querySync(
|
||||||
let pk = getPublicKey(sk)
|
relays,
|
||||||
|
|
||||||
let sub = relay.sub([
|
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pk]
|
limit: 10
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (events) {
|
||||||
|
console.log('it exists indeed on this relay:', events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// let's publish a new event while simultaneously monitoring the relay for it
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
pool.subscribe(
|
||||||
|
['wss://a.com', 'wss://b.com', 'wss://c.com'],
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pk],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
console.log('got event:', event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
])
|
)
|
||||||
|
|
||||||
sub.on('event', event => {
|
let eventTemplate = {
|
||||||
console.log('got event:', event)
|
|
||||||
})
|
|
||||||
|
|
||||||
let event = {
|
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: pk,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'hello world'
|
content: 'hello world',
|
||||||
}
|
}
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = signEvent(event, sk)
|
|
||||||
|
|
||||||
let pub = relay.publish(event)
|
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||||
pub.on('ok', () => {
|
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||||
console.log(`${relay.url} has accepted our event`)
|
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
|
||||||
})
|
|
||||||
pub.on('failed', reason => {
|
|
||||||
console.log(`failed to publish to ${relay.url}: ${reason}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
let events = await relay.list([{kinds: [0, 1]}])
|
|
||||||
let event = await relay.get({
|
|
||||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
|
||||||
})
|
|
||||||
|
|
||||||
relay.close()
|
relay.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
To use this on Node.js you first must install `ws` and call something like this:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import 'websocket-polyfill'
|
import { useWebSocketImplementation } from 'nostr-tools/pool'
|
||||||
|
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
|
||||||
|
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
useWebSocketImplementation(WebSocket)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interacting with multiple relays
|
### Parsing references (mentions) from a content based on NIP-27
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {SimplePool} from 'nostr-tools'
|
import * as nip27 from '@nostr/tools/nip27'
|
||||||
|
|
||||||
const pool = new SimplePool()
|
for (let block of nip27.parse(evt.content)) {
|
||||||
|
switch (block.type) {
|
||||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
case 'text':
|
||||||
|
console.log(block.text)
|
||||||
let sub = pool.sub(
|
break
|
||||||
[...relays, 'wss://relay.example3.com'],
|
case 'reference': {
|
||||||
[
|
if ('id' in block.pointer) {
|
||||||
{
|
console.log("it's a nevent1 uri", block.pointer)
|
||||||
authors: [
|
} else if ('identifier' in block.pointer) {
|
||||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
console.log("it's a naddr1 uri", block.pointer)
|
||||||
]
|
} else {
|
||||||
|
console.log("it's an npub1 or nprofile1 uri", block.pointer)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
]
|
case 'url': {
|
||||||
)
|
console.log("it's a normal url:", block.url)
|
||||||
|
break
|
||||||
sub.on('event', event => {
|
}
|
||||||
// this will only be called once the first time the event is received
|
case 'image':
|
||||||
// ...
|
case 'video':
|
||||||
})
|
case 'audio':
|
||||||
|
console.log("it's a media url:", block.url)
|
||||||
let pubs = pool.publish(relays, newEvent)
|
case 'relay':
|
||||||
pubs.on('ok', () => {
|
console.log("it's a websocket url, probably a relay address:", block.url)
|
||||||
// this may be called multiple times, once for every relay that accepts the event
|
default:
|
||||||
// ...
|
break
|
||||||
})
|
}
|
||||||
|
}
|
||||||
let events = await pool.list(relays, [{kinds: [0, 1]}])
|
|
||||||
let event = await pool.get(relays, {
|
|
||||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
|
||||||
})
|
|
||||||
|
|
||||||
let relaysForEvent = pool.seenOn(
|
|
||||||
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
||||||
)
|
|
||||||
// relaysForEvent will be an array of URLs from relays a given event was seen on
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
### Connecting to a bunker using NIP-46
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {parseReferences} from 'nostr-tools'
|
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
|
||||||
|
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
|
||||||
|
import { SimplePool } from '@nostr/tools/pool'
|
||||||
|
|
||||||
let references = parseReferences(event)
|
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
|
||||||
let simpleAugmentedContent = event.content
|
const localSecretKey = generateSecretKey()
|
||||||
for (let i = 0; i < references.length; i++) {
|
|
||||||
let {text, profile, event, address} = references[i]
|
// parse a bunker URI
|
||||||
let augmentedReference = profile
|
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
|
||||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
if (!bunkerPointer) {
|
||||||
: event
|
throw new Error('Invalid bunker input')
|
||||||
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
}
|
||||||
: address
|
|
||||||
? `<a href="${text}">[link]</a>`
|
// create the bunker instance
|
||||||
: text
|
const pool = new SimplePool()
|
||||||
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
|
||||||
|
await bunker.connect()
|
||||||
|
|
||||||
|
// and use it
|
||||||
|
const pubkey = await bunker.getPublicKey()
|
||||||
|
const event = await bunker.signEvent({
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello from bunker!'
|
||||||
|
})
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
await signer.close()
|
||||||
|
pool.close([])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parsing thread from any note based on NIP-10
|
||||||
|
|
||||||
|
```js
|
||||||
|
import * as nip10 from '@nostr/tools/nip10'
|
||||||
|
|
||||||
|
// event is a nostr event with tags
|
||||||
|
const refs = nip10.parse(event)
|
||||||
|
|
||||||
|
// get the root event of the thread
|
||||||
|
if (refs.root) {
|
||||||
|
console.log('root event:', refs.root.id)
|
||||||
|
console.log('root event relay hints:', refs.root.relays)
|
||||||
|
console.log('root event author:', refs.root.author)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the immediate parent being replied to
|
||||||
|
if (refs.reply) {
|
||||||
|
console.log('reply to:', refs.reply.id)
|
||||||
|
console.log('reply relay hints:', refs.reply.relays)
|
||||||
|
console.log('reply author:', refs.reply.author)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get any mentioned events
|
||||||
|
for (let mention of refs.mentions) {
|
||||||
|
console.log('mentioned event:', mention.id)
|
||||||
|
console.log('mention relay hints:', mention.relays)
|
||||||
|
console.log('mention author:', mention.author)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get any quoted events
|
||||||
|
for (let quote of refs.quotes) {
|
||||||
|
console.log('quoted event:', quote.id)
|
||||||
|
console.log('quote relay hints:', quote.relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get any referenced profiles
|
||||||
|
for (let profile of refs.profiles) {
|
||||||
|
console.log('referenced profile:', profile.pubkey)
|
||||||
|
console.log('profile relay hints:', profile.relays)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Querying profile data from a NIP-05 address
|
### Querying profile data from a NIP-05 address
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {nip05} from 'nostr-tools'
|
import { queryProfile } from 'nostr-tools/nip05'
|
||||||
|
|
||||||
let profile = await nip05.queryProfile('jb55.com')
|
let profile = await queryProfile('jb55.com')
|
||||||
console.log(profile.pubkey)
|
console.log(profile.pubkey)
|
||||||
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||||
console.log(profile.relays)
|
console.log(profile.relays)
|
||||||
// prints: [wss://relay.damus.io]
|
// prints: [wss://relay.damus.io]
|
||||||
```
|
```
|
||||||
|
|
||||||
To use this on Node.js you first must install `node-fetch@2` and call something like this:
|
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
nip05.useFetchImplementation(require('node-fetch'))
|
import { useFetchImplementation } from 'nostr-tools/nip05'
|
||||||
|
useFetchImplementation(require('node-fetch'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Including NIP-07 types
|
||||||
|
```js
|
||||||
|
import type { WindowNostr } from 'nostr-tools/nip07'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: WindowNostr;
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Encoding and decoding NIP-19 codes
|
### Encoding and decoding NIP-19 codes
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
|
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
|
import * as nip19 from 'nostr-tools/nip19'
|
||||||
|
|
||||||
let sk = generatePrivateKey()
|
let sk = generateSecretKey()
|
||||||
let nsec = nip19.nsecEncode(sk)
|
let nsec = nip19.nsecEncode(sk)
|
||||||
let {type, data} = nip19.decode(nsec)
|
let { type, data } = nip19.decode(nsec)
|
||||||
assert(type === 'nsec')
|
assert(type === 'nsec')
|
||||||
assert(data === sk)
|
assert(data === sk)
|
||||||
|
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let npub = nip19.npubEncode(pk)
|
let npub = nip19.npubEncode(pk)
|
||||||
let {type, data} = nip19.decode(npub)
|
let { type, data } = nip19.decode(npub)
|
||||||
assert(type === 'npub')
|
assert(type === 'npub')
|
||||||
assert(data === pk)
|
assert(data === pk)
|
||||||
|
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = [
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
'wss://relay.nostr.example.mydomain.example.com',
|
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||||
'wss://nostr.banana.com'
|
let { type, data } = nip19.decode(nprofile)
|
||||||
]
|
|
||||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
|
||||||
let {type, data} = nip19.decode(nprofile)
|
|
||||||
assert(type === 'nprofile')
|
assert(type === 'nprofile')
|
||||||
assert(data.pubkey === pk)
|
assert(data.pubkey === pk)
|
||||||
assert(data.relays.length === 2)
|
assert(data.relays.length === 2)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Encrypting and decrypting direct messages
|
### 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
|
```js
|
||||||
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
// sender
|
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||||
let sk1 = generatePrivateKey()
|
initNostrWasm().then(setNostrWasm)
|
||||||
let pk1 = getPublicKey(sk1)
|
|
||||||
|
|
||||||
// receiver
|
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||||
let sk2 = generatePrivateKey()
|
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||||
let pk2 = getPublicKey(sk2)
|
|
||||||
|
|
||||||
// on the sender side
|
|
||||||
let message = 'hello'
|
|
||||||
let ciphertext = await nip04.encrypt(sk1, pk2, message)
|
|
||||||
|
|
||||||
let event = {
|
|
||||||
kind: 4,
|
|
||||||
pubkey: pk1,
|
|
||||||
tags: [['p', pk2]],
|
|
||||||
content: ciphertext,
|
|
||||||
...otherProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
sendEvent(event)
|
|
||||||
|
|
||||||
// on the receiver side
|
|
||||||
sub.on('event', event => {
|
|
||||||
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
|
||||||
pk1 === sender
|
|
||||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Performing and checking for delegation
|
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
|
```js
|
||||||
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
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'
|
||||||
|
|
||||||
// delegator
|
initNostrWasm().then(setNostrWasm)
|
||||||
let sk1 = generatePrivateKey()
|
|
||||||
let pk1 = getPublicKey(sk1)
|
|
||||||
|
|
||||||
// delegatee
|
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||||
let sk2 = generatePrivateKey()
|
const pool = new AbstractSimplePool({ verifyEvent })
|
||||||
let pk2 = getPublicKey(sk2)
|
|
||||||
|
|
||||||
// generate delegation
|
|
||||||
let delegation = nip26.createDelegation(sk1, {
|
|
||||||
pubkey: pk2,
|
|
||||||
kind: 1,
|
|
||||||
since: Math.round(Date.now() / 1000),
|
|
||||||
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */
|
|
||||||
})
|
|
||||||
|
|
||||||
// the delegatee uses the delegation when building an event
|
|
||||||
let event = {
|
|
||||||
pubkey: pk2,
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.round(Date.now() / 1000),
|
|
||||||
content: 'hello from a delegated key',
|
|
||||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]]
|
|
||||||
}
|
|
||||||
|
|
||||||
// finally any receiver of this event can check for the presence of a valid delegation tag
|
|
||||||
let delegator = nip26.getDelegator(event)
|
|
||||||
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
|
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)
|
### Using from the browser (if you don't want to use a bundler)
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||||
<script>
|
<script>
|
||||||
window.NostrTools.generatePrivateKey('...') // and so on
|
window.NostrTools.generateSecretKey('...') // and so on
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Plumbing
|
## Plumbing
|
||||||
|
|
||||||
1. Install [`just`](https://just.systems/)
|
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
|
||||||
2. `just -l`
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Public domain.
|
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||||
|
|
||||||
|
## Contributing to this repository
|
||||||
|
|
||||||
|
Use NIP-34 to send your patches to:
|
||||||
|
|
||||||
|
```
|
||||||
|
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
|
||||||
|
```
|
||||||
|
|||||||
303
abstract-pool.ts
Normal file
303
abstract-pool.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/* global WebSocket */
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbstractRelay as AbstractRelay,
|
||||||
|
SubscriptionParams,
|
||||||
|
Subscription,
|
||||||
|
type AbstractRelayConstructorOptions,
|
||||||
|
} from './abstract-relay.ts'
|
||||||
|
import { normalizeURL } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
|
||||||
|
import { type Filter } from './filter.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
export type SubCloser = { close: (reason?: string) => void }
|
||||||
|
|
||||||
|
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
|
||||||
|
|
||||||
|
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
|
||||||
|
maxWait?: number
|
||||||
|
onclose?: (reasons: string[]) => void
|
||||||
|
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||||
|
// Deprecated: use onauth instead
|
||||||
|
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractSimplePool {
|
||||||
|
protected relays: Map<string, AbstractRelay> = new Map()
|
||||||
|
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
|
||||||
|
public trackRelays: boolean = false
|
||||||
|
|
||||||
|
public verifyEvent: Nostr['verifyEvent']
|
||||||
|
public trustedRelayURLs: Set<string> = new Set()
|
||||||
|
|
||||||
|
private _WebSocket?: typeof WebSocket
|
||||||
|
|
||||||
|
constructor(opts: AbstractPoolConstructorOptions) {
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
this._WebSocket = opts.websocketImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
websocketImplementation: this._WebSocket,
|
||||||
|
})
|
||||||
|
relay.onclose = () => {
|
||||||
|
this.relays.delete(url)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
this.relays.delete(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
|
||||||
|
params.onauth = params.onauth || params.doauth
|
||||||
|
|
||||||
|
return this.subscribeMap(
|
||||||
|
relays.map(url => ({ url, filter })),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||||
|
params.onauth = params.onauth || params.doauth
|
||||||
|
|
||||||
|
return this.subscribeMap(
|
||||||
|
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
|
||||||
|
params.onauth = params.onauth || params.doauth
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||||
|
eosesReceived[i] = true
|
||||||
|
if (eosesReceived.filter(a => a).length === requests.length) {
|
||||||
|
params.oneose?.()
|
||||||
|
handleEose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// batch all closes into a single
|
||||||
|
const closesReceived: string[] = []
|
||||||
|
let handleClose = (i: number, reason: string) => {
|
||||||
|
if (closesReceived[i]) return // do not act twice for the same relay
|
||||||
|
handleEose(i)
|
||||||
|
closesReceived[i] = reason
|
||||||
|
if (closesReceived.filter(a => a).length === requests.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(
|
||||||
|
requests.map(async ({ url, filter }, i) => {
|
||||||
|
url = normalizeURL(url)
|
||||||
|
|
||||||
|
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([filter], {
|
||||||
|
...params,
|
||||||
|
oneose: () => handleEose(i),
|
||||||
|
onclose: reason => {
|
||||||
|
if (reason.startsWith('auth-required: ') && params.onauth) {
|
||||||
|
relay
|
||||||
|
.auth(params.onauth)
|
||||||
|
.then(() => {
|
||||||
|
relay.subscribe([filter], {
|
||||||
|
...params,
|
||||||
|
oneose: () => handleEose(i),
|
||||||
|
onclose: reason => {
|
||||||
|
handleClose(i, reason) // the second time we won't try to auth anymore
|
||||||
|
},
|
||||||
|
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||||
|
eoseTimeout: params.maxWait,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
handleClose(i, reason)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||||
|
eoseTimeout: params.maxWait,
|
||||||
|
})
|
||||||
|
|
||||||
|
subs.push(subscription)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
async close(reason?: string) {
|
||||||
|
await allOpened
|
||||||
|
subs.forEach(sub => {
|
||||||
|
sub.close(reason)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeEose(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
|
||||||
|
): SubCloser {
|
||||||
|
params.onauth = params.onauth || params.doauth
|
||||||
|
|
||||||
|
const subcloser = this.subscribe(relays, filter, {
|
||||||
|
...params,
|
||||||
|
oneose() {
|
||||||
|
subcloser.close('closed automatically on eose')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return subcloser
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeManyEose(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter[],
|
||||||
|
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
|
||||||
|
): SubCloser {
|
||||||
|
params.onauth = params.onauth || params.doauth
|
||||||
|
|
||||||
|
const subcloser = this.subscribeMany(relays, filters, {
|
||||||
|
...params,
|
||||||
|
oneose() {
|
||||||
|
subcloser.close('closed automatically on eose')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return subcloser
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySync(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
const events: Event[] = []
|
||||||
|
this.subscribeEose(relays, filter, {
|
||||||
|
...params,
|
||||||
|
onevent(event: Event) {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
onclose(_: string[]) {
|
||||||
|
resolve(events)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'label' | '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,
|
||||||
|
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
|
||||||
|
): 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)
|
||||||
|
.catch(async err => {
|
||||||
|
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
|
||||||
|
await r.auth(options.onauth)
|
||||||
|
return r.publish(event) // retry
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.then(reason => {
|
||||||
|
if (this.trackRelays) {
|
||||||
|
let set = this.seenOn.get(event.id)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.seenOn.set(event.id, set)
|
||||||
|
}
|
||||||
|
set.add(r)
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listConnectionStatus(): Map<string, boolean> {
|
||||||
|
const map = new Map<string, boolean>()
|
||||||
|
this.relays.forEach((relay, url) => map.set(url, relay.connected))
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.relays.forEach(conn => conn.close())
|
||||||
|
this.relays = new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
424
abstract-relay.ts
Normal file
424
abstract-relay.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/* global WebSocket */
|
||||||
|
|
||||||
|
import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } 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'
|
||||||
|
|
||||||
|
export type AbstractRelayConstructorOptions = {
|
||||||
|
verifyEvent: Nostr['verifyEvent']
|
||||||
|
websocketImplementation?: typeof WebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendingOnClosedConnection extends Error {
|
||||||
|
constructor(message: string, relay: string) {
|
||||||
|
super(`Tried to send message '${message} on a closed connection to ${relay}.`)
|
||||||
|
this.name = 'SendingOnClosedConnection'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 publishTimeout: number = 4400
|
||||||
|
public openSubs: Map<string, Subscription> = new Map()
|
||||||
|
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 authPromise: Promise<string> | undefined
|
||||||
|
private serial: number = 0
|
||||||
|
private verifyEvent: Nostr['verifyEvent']
|
||||||
|
|
||||||
|
private _WebSocket: typeof WebSocket
|
||||||
|
|
||||||
|
constructor(url: string, opts: AbstractRelayConstructorOptions) {
|
||||||
|
this.url = normalizeURL(url)
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
this._WebSocket = opts.websocketImplementation || WebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
|
||||||
|
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.authPromise = 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 this._WebSocket(this.url)
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
this._connected = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = ev => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
reject((ev as any).message || 'websocket error')
|
||||||
|
if (this._connected) {
|
||||||
|
this._connected = false
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection errored')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = ev => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
reject((ev as any).message || 'websocket closed')
|
||||||
|
if (this._connected) {
|
||||||
|
this._connected = false
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection closed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 NostrEvent
|
||||||
|
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 (ep) {
|
||||||
|
clearTimeout(ep.timeout)
|
||||||
|
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 SendingOnClosedConnection(message, this.url)
|
||||||
|
|
||||||
|
this.connectionPromise.then(() => {
|
||||||
|
this.ws?.send(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
|
||||||
|
const challenge = this.challenge
|
||||||
|
if (!challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
|
if (this.authPromise) return this.authPromise
|
||||||
|
|
||||||
|
this.authPromise = new Promise<string>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
|
||||||
|
let timeout = setTimeout(() => {
|
||||||
|
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
|
||||||
|
if (ep) {
|
||||||
|
ep.reject(new Error('auth timed out'))
|
||||||
|
this.openEventPublishes.delete(evt.id)
|
||||||
|
}
|
||||||
|
}, this.publishTimeout)
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||||
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('subscribe auth function failed:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this.authPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
public async publish(event: Event): Promise<string> {
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||||
|
if (ep) {
|
||||||
|
ep.reject(new Error('publish timed out'))
|
||||||
|
this.openEventPublishes.delete(event.id)
|
||||||
|
}
|
||||||
|
}, this.publishTimeout)
|
||||||
|
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
|
||||||
|
})
|
||||||
|
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).substring(1))
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(
|
||||||
|
filters: Filter[],
|
||||||
|
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||||
|
): Subscription {
|
||||||
|
const subscription = this.prepareSubscription(filters, params)
|
||||||
|
subscription.fire()
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareSubscription(
|
||||||
|
filters: Filter[],
|
||||||
|
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||||
|
): Subscription {
|
||||||
|
this.serial++
|
||||||
|
const id = params.id || (params.label ? params.label + ':' : '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.onclose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 && this.relay.connected) {
|
||||||
|
// 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
|
||||||
|
try {
|
||||||
|
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SendingOnClosedConnection) {
|
||||||
|
/* doesn't matter, it's ok */
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
61
benchmarks.ts
Normal file
61
benchmarks.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { run, bench, group, baseline } from 'mitata'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
import { NostrEvent } from './core.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||||
|
import { setNostrWasm, verifyEvent } from './wasm.ts'
|
||||||
|
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()
|
||||||
55
build.js
55
build.js
@@ -1,47 +1,60 @@
|
|||||||
#!/usr/bin/env node
|
const fs = require('node:fs')
|
||||||
|
|
||||||
const fs = require('fs')
|
|
||||||
const esbuild = require('esbuild')
|
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 = {
|
let common = {
|
||||||
entryPoints: ['index.ts'],
|
entryPoints,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
sourcemap: 'external'
|
sourcemap: 'external',
|
||||||
}
|
}
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
...common,
|
...common,
|
||||||
outfile: 'lib/esm/nostr.mjs',
|
outdir: 'lib/esm',
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
packages: 'external'
|
packages: 'external',
|
||||||
|
})
|
||||||
|
.then(() => console.log('esm build success.'))
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
outdir: 'lib/cjs',
|
||||||
|
format: 'cjs',
|
||||||
|
packages: 'external',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const packageJson = JSON.stringify({type: 'module'})
|
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||||
|
|
||||||
console.log('esm build success.')
|
console.log('cjs build success.')
|
||||||
})
|
})
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
...common,
|
...common,
|
||||||
outfile: 'lib/nostr.cjs.js',
|
entryPoints: ['index.ts'],
|
||||||
format: 'cjs',
|
|
||||||
packages: 'external'
|
|
||||||
})
|
|
||||||
.then(() => console.log('cjs build success.'))
|
|
||||||
|
|
||||||
esbuild
|
|
||||||
.build({
|
|
||||||
...common,
|
|
||||||
outfile: 'lib/nostr.bundle.js',
|
outfile: 'lib/nostr.bundle.js',
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
globalName: 'NostrTools',
|
globalName: 'NostrTools',
|
||||||
define: {
|
define: {
|
||||||
window: 'self',
|
window: 'self',
|
||||||
global: 'self',
|
global: 'self',
|
||||||
process: '{"env": {}}'
|
process: '{"env": {}}',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
.then(() => console.log('standalone build success.'))
|
.then(() => console.log('standalone build success.'))
|
||||||
|
|||||||
18
core.test.ts
Normal file
18
core.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { sortEvents } from './core.ts'
|
||||||
|
|
||||||
|
test('sortEvents', () => {
|
||||||
|
const events = [
|
||||||
|
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||||
|
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||||
|
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortedEvents = sortEvents(events)
|
||||||
|
|
||||||
|
expect(sortedEvents).toEqual([
|
||||||
|
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||||
|
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||||
|
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
65
core.ts
Normal file
65
core.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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] !== 'string') return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort events in reverse-chronological order by the `created_at` timestamp,
|
||||||
|
* and then by the event `id` (lexicographically) in case of ties.
|
||||||
|
* This mutates the array.
|
||||||
|
*/
|
||||||
|
export function sortEvents(events: Event[]): Event[] {
|
||||||
|
return events.sort((a: NostrEvent, b: NostrEvent): number => {
|
||||||
|
if (a.created_at !== b.created_at) {
|
||||||
|
return b.created_at - a.created_at
|
||||||
|
}
|
||||||
|
return a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
340
event.test.js
340
event.test.js
@@ -1,340 +0,0 @@
|
|||||||
const {
|
|
||||||
getBlankEvent,
|
|
||||||
finishEvent,
|
|
||||||
serializeEvent,
|
|
||||||
getEventHash,
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
signEvent,
|
|
||||||
getPublicKey,
|
|
||||||
Kind
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
describe('Event', () => {
|
|
||||||
describe('getBlankEvent', () => {
|
|
||||||
it('should return a blank event object', () => {
|
|
||||||
expect(getBlankEvent()).toEqual({
|
|
||||||
kind: 255,
|
|
||||||
content: '',
|
|
||||||
tags: [],
|
|
||||||
created_at: 0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('finishEvent', () => {
|
|
||||||
it('should create a signed event from a template', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const template = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = finishEvent(template, privateKey)
|
|
||||||
|
|
||||||
expect(event.kind).toEqual(template.kind)
|
|
||||||
expect(event.tags).toEqual(template.tags)
|
|
||||||
expect(event.content).toEqual(template.content)
|
|
||||||
expect(event.created_at).toEqual(template.created_at)
|
|
||||||
expect(event.pubkey).toEqual(publicKey)
|
|
||||||
expect(typeof event.id).toEqual('string')
|
|
||||||
expect(typeof event.sig).toEqual('string')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('serializeEvent', () => {
|
|
||||||
it('should serialize a valid event object', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
pubkey: publicKey,
|
|
||||||
created_at: 1617932115,
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!'
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedEvent = serializeEvent(unsignedEvent)
|
|
||||||
|
|
||||||
expect(serializedEvent).toEqual(
|
|
||||||
JSON.stringify([
|
|
||||||
0,
|
|
||||||
publicKey,
|
|
||||||
unsignedEvent.created_at,
|
|
||||||
unsignedEvent.kind,
|
|
||||||
unsignedEvent.tags,
|
|
||||||
unsignedEvent.content
|
|
||||||
])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should throw an error for an invalid event object', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const invalidEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey // missing content
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
serializeEvent(invalidEvent)
|
|
||||||
}).toThrow("can't serialize event with wrong or missing properties")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getEventHash', () => {
|
|
||||||
it('should return the correct event hash', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventHash = getEventHash(unsignedEvent)
|
|
||||||
|
|
||||||
expect(typeof eventHash).toEqual('string')
|
|
||||||
expect(eventHash.length).toEqual(64)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateEvent', () => {
|
|
||||||
it('should return true for a valid event object', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateEvent(unsignedEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for a non object event', () => {
|
|
||||||
const nonObjectEvent = ''
|
|
||||||
|
|
||||||
const isValid = validateEvent(nonObjectEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an event object with missing properties', () => {
|
|
||||||
const invalidEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
created_at: 1617932115 // missing content and pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateEvent(invalidEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an empty object', () => {
|
|
||||||
const emptyObj = {}
|
|
||||||
|
|
||||||
const isValid = validateEvent(emptyObj)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an object with invalid properties', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const invalidEvent = {
|
|
||||||
kind: 1,
|
|
||||||
tags: [],
|
|
||||||
created_at: '1617932115', // should be a number
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateEvent(invalidEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an object with an invalid public key', () => {
|
|
||||||
const invalidEvent = {
|
|
||||||
kind: 1,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: 'invalid_pubkey'
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateEvent(invalidEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an object with invalid tags', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const invalidEvent = {
|
|
||||||
kind: 1,
|
|
||||||
tags: {}, // should be an array
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = validateEvent(invalidEvent)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('verifySignature', () => {
|
|
||||||
it('should return true for a valid event signature', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
|
|
||||||
const event = finishEvent(
|
|
||||||
{
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
const isValid = verifySignature(event)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for an invalid event signature', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
|
|
||||||
const event = finishEvent(
|
|
||||||
{
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
// tamper with the signature
|
|
||||||
event.sig = event.sig.replace(/0/g, '1')
|
|
||||||
|
|
||||||
const isValid = verifySignature(event)
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when verifying an event with a different private key', () => {
|
|
||||||
const privateKey1 =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
|
|
||||||
const privateKey2 =
|
|
||||||
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
|
||||||
const publicKey2 = getPublicKey(privateKey2)
|
|
||||||
|
|
||||||
const event = finishEvent(
|
|
||||||
{
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115
|
|
||||||
},
|
|
||||||
privateKey1
|
|
||||||
)
|
|
||||||
|
|
||||||
// verify with different private key
|
|
||||||
const isValid = verifySignature({
|
|
||||||
...event,
|
|
||||||
pubkey: publicKey2
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('signEvent', () => {
|
|
||||||
it('should sign an event object', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const sig = signEvent(unsignedEvent, privateKey)
|
|
||||||
|
|
||||||
// verify the signature
|
|
||||||
const isValid = verifySignature({
|
|
||||||
...unsignedEvent,
|
|
||||||
sig
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(typeof sig).toEqual('string')
|
|
||||||
expect(sig.length).toEqual(128)
|
|
||||||
expect(isValid).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not sign an event with different private key', () => {
|
|
||||||
const privateKey =
|
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const wrongPrivateKey =
|
|
||||||
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: 'Hello, world!',
|
|
||||||
created_at: 1617932115,
|
|
||||||
pubkey: publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const sig = signEvent(unsignedEvent, wrongPrivateKey)
|
|
||||||
|
|
||||||
// verify the signature
|
|
||||||
const isValid = verifySignature({
|
|
||||||
...unsignedEvent,
|
|
||||||
sig
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(typeof sig).toEqual('string')
|
|
||||||
expect(sig.length).toEqual(128)
|
|
||||||
expect(isValid).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
118
event.ts
118
event.ts
@@ -1,118 +0,0 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
|
||||||
|
|
||||||
import {utf8Encoder} from './utils'
|
|
||||||
import {getPublicKey} from './keys'
|
|
||||||
|
|
||||||
/* eslint-disable no-unused-vars */
|
|
||||||
export enum Kind {
|
|
||||||
Metadata = 0,
|
|
||||||
Text = 1,
|
|
||||||
RecommendRelay = 2,
|
|
||||||
Contacts = 3,
|
|
||||||
EncryptedDirectMessage = 4,
|
|
||||||
EventDeletion = 5,
|
|
||||||
Reaction = 7,
|
|
||||||
BadgeAward = 8,
|
|
||||||
ChannelCreation = 40,
|
|
||||||
ChannelMetadata = 41,
|
|
||||||
ChannelMessage = 42,
|
|
||||||
ChannelHideMessage = 43,
|
|
||||||
ChannelMuteUser = 44,
|
|
||||||
Report = 1984,
|
|
||||||
ZapRequest = 9734,
|
|
||||||
Zap = 9735,
|
|
||||||
RelayList = 10002,
|
|
||||||
ClientAuth = 22242,
|
|
||||||
BadgeDefinition = 30008,
|
|
||||||
ProfileBadge = 30009,
|
|
||||||
Article = 30023
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTemplate = {
|
|
||||||
kind: Kind
|
|
||||||
tags: string[][]
|
|
||||||
content: string
|
|
||||||
created_at: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UnsignedEvent = EventTemplate & {
|
|
||||||
pubkey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Event = UnsignedEvent & {
|
|
||||||
id: string
|
|
||||||
sig: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBlankEvent(): EventTemplate {
|
|
||||||
return {
|
|
||||||
kind: 255,
|
|
||||||
content: '',
|
|
||||||
tags: [],
|
|
||||||
created_at: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function finishEvent(t: EventTemplate, privateKey: string): Event {
|
|
||||||
let event = t as Event
|
|
||||||
event.pubkey = getPublicKey(privateKey)
|
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = signEvent(event, privateKey)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeEvent(evt: UnsignedEvent): string {
|
|
||||||
if (!validateEvent(evt))
|
|
||||||
throw new Error("can't serialize event with wrong or missing properties")
|
|
||||||
|
|
||||||
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 secp256k1.utils.bytesToHex(eventHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifySignature(event: Event): boolean {
|
|
||||||
return secp256k1.schnorr.verifySync(
|
|
||||||
event.sig,
|
|
||||||
getEventHash(event),
|
|
||||||
event.pubkey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signEvent(event: UnsignedEvent, key: string): string {
|
|
||||||
return secp256k1.utils.bytesToHex(
|
|
||||||
secp256k1.schnorr.signSync(getEventHash(event), key)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,44 @@
|
|||||||
/* eslint-env jest */
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||||
const {fj} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('match id', () => {
|
test('match id', () => {
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventId(
|
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}]`,
|
`["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'
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
)
|
),
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventId(
|
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":[]}]`,
|
`["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'
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
)
|
),
|
||||||
).toBeFalsy()
|
).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('match kind', () => {
|
test('match kind', () => {
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventKind(
|
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}]`,
|
`["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
|
1,
|
||||||
)
|
),
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventKind(
|
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":[]}]`,
|
`["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
|
12720,
|
||||||
)
|
),
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('match subscription id', () => {
|
test('match subscription id', () => {
|
||||||
expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||||
expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||||
expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||||
expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||||
'kasjbdjkav'
|
|
||||||
)
|
|
||||||
expect(
|
expect(
|
||||||
fj.getSubscriptionId(
|
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||||
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
|
||||||
)
|
|
||||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||||
})
|
})
|
||||||
200
filter.test.js
200
filter.test.js
@@ -1,200 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
|
|
||||||
|
|
||||||
describe('Filter', () => {
|
|
||||||
describe('matchFilter', () => {
|
|
||||||
it('should return true when all filter conditions are met', () => {
|
|
||||||
const filter = {
|
|
||||||
ids: ['123', '456'],
|
|
||||||
kinds: [1, 2, 3],
|
|
||||||
authors: ['abc'],
|
|
||||||
since: 100,
|
|
||||||
until: 200,
|
|
||||||
'#tag': ['value']
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
id: '123',
|
|
||||||
kind: 1,
|
|
||||||
pubkey: 'abc',
|
|
||||||
created_at: 150,
|
|
||||||
tags: [['tag', 'value']]
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when the event id is not in the filter', () => {
|
|
||||||
const filter = {ids: ['123', '456']}
|
|
||||||
|
|
||||||
const event = {id: '789'}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return true when the event id starts with a prefix', () => {
|
|
||||||
const filter = {ids: ['22', '00']}
|
|
||||||
|
|
||||||
const event = {id: '001'}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when the event kind is not in the filter', () => {
|
|
||||||
const filter = {kinds: [1, 2, 3]}
|
|
||||||
|
|
||||||
const event = {kind: 4}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when the event author is not in the filter', () => {
|
|
||||||
const filter = {authors: ['abc', 'def']}
|
|
||||||
|
|
||||||
const event = {pubkey: 'ghi'}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when a tag is not present in the event', () => {
|
|
||||||
const filter = {'#tag': ['value1', 'value2']}
|
|
||||||
|
|
||||||
const event = {tags: [['not_tag', 'value1']]}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when a tag value is not present in the event', () => {
|
|
||||||
const filter = {'#tag': ['value1', 'value2']}
|
|
||||||
|
|
||||||
const event = {tags: [['tag', 'value3']]}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return true when filter has tags that is present in the event', () => {
|
|
||||||
const filter = {'#tag1': ['foo']}
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
id: '123',
|
|
||||||
kind: 1,
|
|
||||||
pubkey: 'abc',
|
|
||||||
created_at: 150,
|
|
||||||
tags: [
|
|
||||||
['tag1', 'foo'],
|
|
||||||
['tag2', 'bar']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when the event is before the filter since value', () => {
|
|
||||||
const filter = {since: 100}
|
|
||||||
|
|
||||||
const event = {created_at: 50}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when the event is after the filter until value', () => {
|
|
||||||
const filter = {until: 100}
|
|
||||||
|
|
||||||
const event = {created_at: 150}
|
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('matchFilters', () => {
|
|
||||||
it('should return true when at least one filter matches the event', () => {
|
|
||||||
const filters = [
|
|
||||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
|
||||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
|
||||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
|
||||||
]
|
|
||||||
|
|
||||||
const event = {id: '789', kind: 3, pubkey: 'ghi'}
|
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return true when at least one prefix matches the event', () => {
|
|
||||||
const filters = [
|
|
||||||
{ids: ['1'], kinds: [1], authors: ['a']},
|
|
||||||
{ids: ['4'], kinds: [2], authors: ['d']},
|
|
||||||
{ids: ['9'], kinds: [3], authors: ['g']}
|
|
||||||
]
|
|
||||||
|
|
||||||
const event = {id: '987', kind: 3, pubkey: 'ghi'}
|
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return true when event matches one or more filters and some have limit set', () => {
|
|
||||||
const filters = [
|
|
||||||
{ids: ['123'], limit: 1},
|
|
||||||
{kinds: [1], limit: 2},
|
|
||||||
{authors: ['abc'], limit: 3}
|
|
||||||
]
|
|
||||||
|
|
||||||
const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
|
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when no filters match the event', () => {
|
|
||||||
const filters = [
|
|
||||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
|
||||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
|
||||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
|
||||||
]
|
|
||||||
|
|
||||||
const event = {id: '100', kind: 4, pubkey: 'jkl'}
|
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false when event matches none of the filters and some have limit set', () => {
|
|
||||||
const filters = [
|
|
||||||
{ids: ['123'], limit: 1},
|
|
||||||
{kinds: [1], limit: 2},
|
|
||||||
{authors: ['abc'], limit: 3}
|
|
||||||
]
|
|
||||||
const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
|
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
240
filter.test.ts
Normal file
240
filter.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
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 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 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 handle parameterized replaceable events', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
|
||||||
|
expect(
|
||||||
|
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
|
||||||
|
).toEqual(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty tags return 0', () => {
|
||||||
|
expect(getFilterLimit({ '#p': [] })).toEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
99
filter.ts
99
filter.ts
@@ -1,4 +1,5 @@
|
|||||||
import {Event} from './event'
|
import { Event } from './core.ts'
|
||||||
|
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -8,51 +9,97 @@ export type Filter = {
|
|||||||
until?: number
|
until?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
search?: string
|
search?: string
|
||||||
[key: `#${string}`]: string[]
|
[key: `#${string}`]: string[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchFilter(
|
export function matchFilter(filter: Filter, event: Event): boolean {
|
||||||
filter: Filter,
|
|
||||||
event: Event
|
|
||||||
): boolean {
|
|
||||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
return false
|
||||||
return false
|
}
|
||||||
}
|
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) {
|
||||||
|
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 && filter.authors.indexOf(event.pubkey) === -1) {
|
||||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let f in filter) {
|
for (let f in filter) {
|
||||||
if (f[0] === '#') {
|
if (f[0] === '#') {
|
||||||
let tagName = f.slice(1)
|
let tagName = f.slice(1)
|
||||||
let values = filter[`#${tagName}`]
|
let values = filter[`#${tagName}`]
|
||||||
if (
|
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||||
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.since && event.created_at < filter.since) return false
|
||||||
if (filter.until && event.created_at >= filter.until) return false
|
if (filter.until && event.created_at > filter.until) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchFilters(
|
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||||
filters: Filter[],
|
|
||||||
event: Event
|
|
||||||
): boolean {
|
|
||||||
for (let i = 0; i < filters.length; i++) {
|
for (let i = 0; i < filters.length; i++) {
|
||||||
if (matchFilter(filters[i], event)) return true
|
if (matchFilter(filters[i], event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
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 returns a positive integer, or `Infinity` if there is no intrinsic limit.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
// The `limit` property creates an artificial limit.
|
||||||
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
|
||||||
|
// There can only be one event per `id`.
|
||||||
|
filter.ids?.length ?? Infinity,
|
||||||
|
|
||||||
|
// Replaceable events are limited by the number of authors and kinds.
|
||||||
|
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||||
|
? filter.authors.length * filter.kinds.length
|
||||||
|
: Infinity,
|
||||||
|
|
||||||
|
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
|
||||||
|
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
|
||||||
|
? filter.authors.length * filter.kinds.length * filter['#d'].length
|
||||||
|
: Infinity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
21
helpers.ts
Normal file
21
helpers.ts
Normal 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
|
||||||
|
}
|
||||||
56
index.ts
56
index.ts
@@ -1,29 +1,31 @@
|
|||||||
export * from './keys'
|
export * from './pure.ts'
|
||||||
export * from './relay'
|
export { Relay } from './relay.ts'
|
||||||
export * from './event'
|
export * from './filter.ts'
|
||||||
export * from './filter'
|
export { SimplePool } from './pool.ts'
|
||||||
export * from './pool'
|
export * from './references.ts'
|
||||||
export * from './references'
|
|
||||||
|
|
||||||
export * as nip04 from './nip04'
|
export * as nip04 from './nip04.ts'
|
||||||
export * as nip05 from './nip05'
|
export * as nip05 from './nip05.ts'
|
||||||
export * as nip06 from './nip06'
|
export * as nip10 from './nip10.ts'
|
||||||
export * as nip10 from './nip10'
|
export * as nip11 from './nip11.ts'
|
||||||
export * as nip13 from './nip13'
|
export * as nip13 from './nip13.ts'
|
||||||
export * as nip19 from './nip19'
|
export * as nip17 from './nip17.ts'
|
||||||
export * as nip26 from './nip26'
|
export * as nip18 from './nip18.ts'
|
||||||
export * as nip39 from './nip39'
|
export * as nip19 from './nip19.ts'
|
||||||
export * as nip42 from './nip42'
|
export * as nip21 from './nip21.ts'
|
||||||
export * as nip57 from './nip57'
|
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 nip54 from './nip54.ts'
|
||||||
|
export * as nip57 from './nip57.ts'
|
||||||
|
export * as nip59 from './nip59.ts'
|
||||||
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
export * as fj from './fakejson'
|
export * as kinds from './kinds.ts'
|
||||||
export * as utils from './utils'
|
export * as fj from './fakejson.ts'
|
||||||
|
export * as utils from './utils.ts'
|
||||||
// monkey patch secp256k1
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {hmac} from '@noble/hashes/hmac'
|
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
|
||||||
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
|
|
||||||
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
|
|
||||||
secp256k1.utils.sha256Sync = (...msgs) =>
|
|
||||||
sha256(secp256k1.utils.concatBytes(...msgs))
|
|
||||||
|
|||||||
50
jsr.json
Normal file
50
jsr.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@nostr/tools",
|
||||||
|
"version": "2.15.1",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts",
|
||||||
|
"./core": "./core.ts",
|
||||||
|
"./pure": "./pure.ts",
|
||||||
|
"./wasm": "./wasm.ts",
|
||||||
|
"./kinds": "./kinds.ts",
|
||||||
|
"./filter": "./filter.ts",
|
||||||
|
"./abstract-relay": "./abstract-relay.ts",
|
||||||
|
"./relay": "./relay.ts",
|
||||||
|
"./abstract-pool": "./abstract-pool.ts",
|
||||||
|
"./pool": "./pool.ts",
|
||||||
|
"./references": "./references.ts",
|
||||||
|
"./nip04": "./nip04.ts",
|
||||||
|
"./nip05": "./nip05.ts",
|
||||||
|
"./nip06": "./nip06.ts",
|
||||||
|
"./nip07": "./nip07.ts",
|
||||||
|
"./nip10": "./nip10.ts",
|
||||||
|
"./nip11": "./nip11.ts",
|
||||||
|
"./nip13": "./nip13.ts",
|
||||||
|
"./nip17": "./nip17.ts",
|
||||||
|
"./nip18": "./nip18.ts",
|
||||||
|
"./nip19": "./nip19.ts",
|
||||||
|
"./nip21": "./nip21.ts",
|
||||||
|
"./nip25": "./nip25.ts",
|
||||||
|
"./nip27": "./nip27.ts",
|
||||||
|
"./nip28": "./nip28.ts",
|
||||||
|
"./nip29": "./nip29.ts",
|
||||||
|
"./nip30": "./nip30.ts",
|
||||||
|
"./nip39": "./nip39.ts",
|
||||||
|
"./nip42": "./nip42.ts",
|
||||||
|
"./nip44": "./nip44.ts",
|
||||||
|
"./nip46": "./nip46.ts",
|
||||||
|
"./nip49": "./nip49.ts",
|
||||||
|
"./nip54": "./nip54.ts",
|
||||||
|
"./nip57": "./nip57.ts",
|
||||||
|
"./nip58": "./nip58.ts",
|
||||||
|
"./nip59": "./nip59.ts",
|
||||||
|
"./nip75": "./nip75.ts",
|
||||||
|
"./nip94": "./nip94.ts",
|
||||||
|
"./nip98": "./nip98.ts",
|
||||||
|
"./nip99": "./nip99.ts",
|
||||||
|
"./nipb7": "./nipb7.ts",
|
||||||
|
"./fakejson": "./fakejson.ts",
|
||||||
|
"./utils": "./utils.ts"
|
||||||
|
"./signer": "./signer.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
justfile
41
justfile
@@ -1,23 +1,36 @@
|
|||||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||||
|
|
||||||
install-dependencies:
|
|
||||||
yarn --ignore-engines
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
rm -rf lib
|
rm -rf lib
|
||||||
node build.js
|
bun run build.js
|
||||||
|
tsc
|
||||||
|
|
||||||
test: build
|
test:
|
||||||
jest
|
bun test --timeout 20000
|
||||||
|
|
||||||
test-only file: build
|
test-only file:
|
||||||
jest {{file}}
|
bun test {{file}}
|
||||||
|
|
||||||
emit-types:
|
publish: build
|
||||||
tsc # see tsconfig.json
|
# publish to jsr first because it is more strict and will catch some errors
|
||||||
|
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
|
||||||
|
jsr publish --allow-dirty
|
||||||
|
git checkout -- package.json
|
||||||
|
|
||||||
publish: build emit-types
|
# then to npm
|
||||||
npm publish
|
npm publish
|
||||||
|
|
||||||
format:
|
format:
|
||||||
prettier --plugin-search-dir . --write .
|
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
|
||||||
|
|||||||
20
keys.test.js
20
keys.test.js
@@ -1,20 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('test private key generation', () => {
|
|
||||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('test public key generation', () => {
|
|
||||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('test public key from private key deterministic', () => {
|
|
||||||
let sk = generatePrivateKey()
|
|
||||||
let pk = getPublicKey(sk)
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
expect(getPublicKey(sk)).toEqual(pk)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
9
keys.ts
9
keys.ts
@@ -1,9 +0,0 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
|
|
||||||
export function generatePrivateKey(): string {
|
|
||||||
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicKey(privateKey: string): string {
|
|
||||||
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
|
|
||||||
}
|
|
||||||
41
kinds.test.ts
Normal file
41
kinds.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure.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')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kind type guard', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const repostedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
],
|
||||||
|
content: 'Replied to a post',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
|
||||||
|
expect(isKind(repostedEvent, Repost)).toBeFalse()
|
||||||
|
})
|
||||||
193
kinds.ts
Normal file
193
kinds.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { NostrEvent, validateEvent } from './pure.ts'
|
||||||
|
|
||||||
|
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||||
|
export function isRegularKind(kind: number): boolean {
|
||||||
|
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): boolean {
|
||||||
|
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): boolean {
|
||||||
|
return 20000 <= kind && kind < 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **addressable**, 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 isAddressableKind(kind: number): boolean {
|
||||||
|
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 (isAddressableKind(kind)) return 'parameterized'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
|
||||||
|
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
|
||||||
|
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Metadata = 0
|
||||||
|
export type Metadata = typeof Metadata
|
||||||
|
export const ShortTextNote = 1
|
||||||
|
export type ShortTextNote = typeof ShortTextNote
|
||||||
|
export const RecommendRelay = 2
|
||||||
|
export type RecommendRelay = typeof RecommendRelay
|
||||||
|
export const Contacts = 3
|
||||||
|
export type Contacts = typeof Contacts
|
||||||
|
export const EncryptedDirectMessage = 4
|
||||||
|
export type EncryptedDirectMessage = typeof EncryptedDirectMessage
|
||||||
|
export const EventDeletion = 5
|
||||||
|
export type EventDeletion = typeof EventDeletion
|
||||||
|
export const Repost = 6
|
||||||
|
export type Repost = typeof Repost
|
||||||
|
export const Reaction = 7
|
||||||
|
export type Reaction = typeof Reaction
|
||||||
|
export const BadgeAward = 8
|
||||||
|
export type BadgeAward = typeof BadgeAward
|
||||||
|
export const Seal = 13
|
||||||
|
export type Seal = typeof Seal
|
||||||
|
export const PrivateDirectMessage = 14
|
||||||
|
export type PrivateDirectMessage = typeof PrivateDirectMessage
|
||||||
|
export const GenericRepost = 16
|
||||||
|
export type GenericRepost = typeof GenericRepost
|
||||||
|
export const ChannelCreation = 40
|
||||||
|
export type ChannelCreation = typeof ChannelCreation
|
||||||
|
export const ChannelMetadata = 41
|
||||||
|
export type ChannelMetadata = typeof ChannelMetadata
|
||||||
|
export const ChannelMessage = 42
|
||||||
|
export type ChannelMessage = typeof ChannelMessage
|
||||||
|
export const ChannelHideMessage = 43
|
||||||
|
export type ChannelHideMessage = typeof ChannelHideMessage
|
||||||
|
export const ChannelMuteUser = 44
|
||||||
|
export type ChannelMuteUser = typeof ChannelMuteUser
|
||||||
|
export const OpenTimestamps = 1040
|
||||||
|
export type OpenTimestamps = typeof OpenTimestamps
|
||||||
|
export const GiftWrap = 1059
|
||||||
|
export type GiftWrap = typeof GiftWrap
|
||||||
|
export const FileMetadata = 1063
|
||||||
|
export type FileMetadata = typeof FileMetadata
|
||||||
|
export const LiveChatMessage = 1311
|
||||||
|
export type LiveChatMessage = typeof LiveChatMessage
|
||||||
|
export const ProblemTracker = 1971
|
||||||
|
export type ProblemTracker = typeof ProblemTracker
|
||||||
|
export const Report = 1984
|
||||||
|
export type Report = typeof Report
|
||||||
|
export const Reporting = 1984
|
||||||
|
export type Reporting = typeof Reporting
|
||||||
|
export const Label = 1985
|
||||||
|
export type Label = typeof Label
|
||||||
|
export const CommunityPostApproval = 4550
|
||||||
|
export type CommunityPostApproval = typeof CommunityPostApproval
|
||||||
|
export const JobRequest = 5999
|
||||||
|
export type JobRequest = typeof JobRequest
|
||||||
|
export const JobResult = 6999
|
||||||
|
export type JobResult = typeof JobResult
|
||||||
|
export const JobFeedback = 7000
|
||||||
|
export type JobFeedback = typeof JobFeedback
|
||||||
|
export const ZapGoal = 9041
|
||||||
|
export type ZapGoal = typeof ZapGoal
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export type ZapRequest = typeof ZapRequest
|
||||||
|
export const Zap = 9735
|
||||||
|
export type Zap = typeof Zap
|
||||||
|
export const Highlights = 9802
|
||||||
|
export type Highlights = typeof Highlights
|
||||||
|
export const Mutelist = 10000
|
||||||
|
export type Mutelist = typeof Mutelist
|
||||||
|
export const Pinlist = 10001
|
||||||
|
export type Pinlist = typeof Pinlist
|
||||||
|
export const RelayList = 10002
|
||||||
|
export type RelayList = typeof RelayList
|
||||||
|
export const BookmarkList = 10003
|
||||||
|
export type BookmarkList = typeof BookmarkList
|
||||||
|
export const CommunitiesList = 10004
|
||||||
|
export type CommunitiesList = typeof CommunitiesList
|
||||||
|
export const PublicChatsList = 10005
|
||||||
|
export type PublicChatsList = typeof PublicChatsList
|
||||||
|
export const BlockedRelaysList = 10006
|
||||||
|
export type BlockedRelaysList = typeof BlockedRelaysList
|
||||||
|
export const SearchRelaysList = 10007
|
||||||
|
export type SearchRelaysList = typeof SearchRelaysList
|
||||||
|
export const InterestsList = 10015
|
||||||
|
export type InterestsList = typeof InterestsList
|
||||||
|
export const UserEmojiList = 10030
|
||||||
|
export type UserEmojiList = typeof UserEmojiList
|
||||||
|
export const DirectMessageRelaysList = 10050
|
||||||
|
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
|
||||||
|
export const FileServerPreference = 10096
|
||||||
|
export type FileServerPreference = typeof FileServerPreference
|
||||||
|
export const NWCWalletInfo = 13194
|
||||||
|
export type NWCWalletInfo = typeof NWCWalletInfo
|
||||||
|
export const LightningPubRPC = 21000
|
||||||
|
export type LightningPubRPC = typeof LightningPubRPC
|
||||||
|
export const ClientAuth = 22242
|
||||||
|
export type ClientAuth = typeof ClientAuth
|
||||||
|
export const NWCWalletRequest = 23194
|
||||||
|
export type NWCWalletRequest = typeof NWCWalletRequest
|
||||||
|
export const NWCWalletResponse = 23195
|
||||||
|
export type NWCWalletResponse = typeof NWCWalletResponse
|
||||||
|
export const NostrConnect = 24133
|
||||||
|
export type NostrConnect = typeof NostrConnect
|
||||||
|
export const HTTPAuth = 27235
|
||||||
|
export type HTTPAuth = typeof HTTPAuth
|
||||||
|
export const Followsets = 30000
|
||||||
|
export type Followsets = typeof Followsets
|
||||||
|
export const Genericlists = 30001
|
||||||
|
export type Genericlists = typeof Genericlists
|
||||||
|
export const Relaysets = 30002
|
||||||
|
export type Relaysets = typeof Relaysets
|
||||||
|
export const Bookmarksets = 30003
|
||||||
|
export type Bookmarksets = typeof Bookmarksets
|
||||||
|
export const Curationsets = 30004
|
||||||
|
export type Curationsets = typeof Curationsets
|
||||||
|
export const ProfileBadges = 30008
|
||||||
|
export type ProfileBadges = typeof ProfileBadges
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
|
export type BadgeDefinition = typeof BadgeDefinition
|
||||||
|
export const Interestsets = 30015
|
||||||
|
export type Interestsets = typeof Interestsets
|
||||||
|
export const CreateOrUpdateStall = 30017
|
||||||
|
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
|
||||||
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
|
||||||
|
export const LongFormArticle = 30023
|
||||||
|
export type LongFormArticle = typeof LongFormArticle
|
||||||
|
export const DraftLong = 30024
|
||||||
|
export type DraftLong = typeof DraftLong
|
||||||
|
export const Emojisets = 30030
|
||||||
|
export type Emojisets = typeof Emojisets
|
||||||
|
export const Application = 30078
|
||||||
|
export type Application = typeof Application
|
||||||
|
export const LiveEvent = 30311
|
||||||
|
export type LiveEvent = typeof LiveEvent
|
||||||
|
export const UserStatuses = 30315
|
||||||
|
export type UserStatuses = typeof UserStatuses
|
||||||
|
export const ClassifiedListing = 30402
|
||||||
|
export type ClassifiedListing = typeof ClassifiedListing
|
||||||
|
export const DraftClassifiedListing = 30403
|
||||||
|
export type DraftClassifiedListing = typeof DraftClassifiedListing
|
||||||
|
export const Date = 31922
|
||||||
|
export type Date = typeof Date
|
||||||
|
export const Time = 31923
|
||||||
|
export type Time = typeof Time
|
||||||
|
export const Calendar = 31924
|
||||||
|
export type Calendar = typeof Calendar
|
||||||
|
export const CalendarEventRSVP = 31925
|
||||||
|
export type CalendarEventRSVP = typeof CalendarEventRSVP
|
||||||
|
export const Handlerrecommendation = 31989
|
||||||
|
export type Handlerrecommendation = typeof Handlerrecommendation
|
||||||
|
export const Handlerinformation = 31990
|
||||||
|
export type Handlerinformation = typeof Handlerinformation
|
||||||
|
export const CommunityDefinition = 34550
|
||||||
|
export type CommunityDefinition = typeof CommunityDefinition
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
globalThis.crypto = require('crypto')
|
|
||||||
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('encrypt and decrypt message', async () => {
|
|
||||||
let sk1 = generatePrivateKey()
|
|
||||||
let sk2 = generatePrivateKey()
|
|
||||||
let pk1 = getPublicKey(sk1)
|
|
||||||
let pk2 = getPublicKey(sk2)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
|
|
||||||
).toEqual('hello')
|
|
||||||
})
|
|
||||||
41
nip04.test.ts
Normal file
41
nip04.test.ts
Normal 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)
|
||||||
|
})
|
||||||
56
nip04.ts
56
nip04.ts
@@ -1,64 +1,38 @@
|
|||||||
import {randomBytes} from '@noble/hashes/utils'
|
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
import {base64} from '@scure/base'
|
import { cbc } from '@noble/ciphers/aes'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
export async function encrypt(
|
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
|
||||||
privkey: string,
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
pubkey: string,
|
|
||||||
text: string
|
|
||||||
): Promise<string> {
|
|
||||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
const normalizedKey = getNormalizedX(key)
|
const normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
let iv = Uint8Array.from(randomBytes(16))
|
let iv = Uint8Array.from(randomBytes(16))
|
||||||
let plaintext = utf8Encoder.encode(text)
|
let plaintext = utf8Encoder.encode(text)
|
||||||
let cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
let ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
|
||||||
normalizedKey,
|
|
||||||
{name: 'AES-CBC'},
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
)
|
|
||||||
let ciphertext = await crypto.subtle.encrypt(
|
|
||||||
{name: 'AES-CBC', iv},
|
|
||||||
cryptoKey,
|
|
||||||
plaintext
|
|
||||||
)
|
|
||||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||||
|
|
||||||
return `${ctb64}?iv=${ivb64}`
|
return `${ctb64}?iv=${ivb64}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(
|
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
|
||||||
privkey: string,
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
pubkey: string,
|
|
||||||
data: string
|
|
||||||
): Promise<string> {
|
|
||||||
let [ctb64, ivb64] = data.split('?iv=')
|
let [ctb64, ivb64] = data.split('?iv=')
|
||||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
let normalizedKey = getNormalizedX(key)
|
let normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
let cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
normalizedKey,
|
|
||||||
{name: 'AES-CBC'},
|
|
||||||
false,
|
|
||||||
['decrypt']
|
|
||||||
)
|
|
||||||
let ciphertext = base64.decode(ctb64)
|
|
||||||
let iv = base64.decode(ivb64)
|
let iv = base64.decode(ivb64)
|
||||||
|
let ciphertext = base64.decode(ctb64)
|
||||||
|
|
||||||
let plaintext = await crypto.subtle.decrypt(
|
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
|
||||||
{name: 'AES-CBC', iv},
|
|
||||||
cryptoKey,
|
|
||||||
ciphertext
|
|
||||||
)
|
|
||||||
|
|
||||||
let text = utf8Decoder.decode(plaintext)
|
return utf8Decoder.decode(plaintext)
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const fetch = require('node-fetch')
|
|
||||||
const {nip05} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('fetch nip05 profiles', async () => {
|
|
||||||
nip05.useFetchImplementation(fetch)
|
|
||||||
|
|
||||||
let p1 = await nip05.queryProfile('jb55.com')
|
|
||||||
expect(p1.pubkey).toEqual(
|
|
||||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
||||||
)
|
|
||||||
expect(p1.relays).toEqual(['wss://relay.damus.io'])
|
|
||||||
|
|
||||||
let p2 = await nip05.queryProfile('jb55@jb55.com')
|
|
||||||
expect(p2.pubkey).toEqual(
|
|
||||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
||||||
)
|
|
||||||
expect(p2.relays).toEqual(['wss://relay.damus.io'])
|
|
||||||
|
|
||||||
let p3 = await nip05.queryProfile('channel.ninja@channel.ninja')
|
|
||||||
expect(p3.pubkey).toEqual(
|
|
||||||
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
46
nip05.test.ts
Normal file
46
nip05.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
|
||||||
|
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
|
||||||
|
|
||||||
|
test('validate NIP05_REGEX', () => {
|
||||||
|
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
|
||||||
|
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
|
||||||
|
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
|
||||||
|
|
||||||
|
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
|
||||||
|
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
|
||||||
|
|
||||||
|
expect(isNip05('bob@bob.com.br')).toBeTrue()
|
||||||
|
expect(isNip05('b&b@bob.com.br')).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetch nip05 profiles', async () => {
|
||||||
|
const fetchStub = async (url: string) => ({
|
||||||
|
status: 200,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
'https://compile-error.net/.well-known/nostr.json?name=_': {
|
||||||
|
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
|
||||||
|
},
|
||||||
|
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
|
||||||
|
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
|
||||||
|
relays: {
|
||||||
|
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
|
||||||
|
'wss://pyramid.fiatjaf.com',
|
||||||
|
'wss://nos.lol',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}[url]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useFetchImplementation(fetchStub)
|
||||||
|
|
||||||
|
let p2 = await queryProfile('compile-error.net')
|
||||||
|
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
|
||||||
|
|
||||||
|
let p3 = await queryProfile('_@fiatjaf.com')
|
||||||
|
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
|
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||||
|
})
|
||||||
86
nip05.ts
86
nip05.ts
@@ -1,60 +1,66 @@
|
|||||||
import {ProfilePointer} from './nip19'
|
import { ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
var _fetch: any
|
export type Nip05 = `${string}@${string}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_-]+)+)$/
|
||||||
|
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let _fetch: any
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_fetch = fetch
|
_fetch = fetch
|
||||||
} catch {}
|
} catch (_) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
export function useFetchImplementation(fetchImplementation: any) {
|
export function useFetchImplementation(fetchImplementation: unknown) {
|
||||||
_fetch = fetchImplementation
|
_fetch = fetchImplementation
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchDomain(
|
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||||
domain: string,
|
|
||||||
query = ''
|
|
||||||
): Promise<{[name: string]: string}> {
|
|
||||||
try {
|
try {
|
||||||
let res = await (
|
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
|
const res = await _fetch(url, { redirect: 'manual' })
|
||||||
).json()
|
if (res.status !== 200) {
|
||||||
|
throw Error('Wrong response code')
|
||||||
return res.names
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.names
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryProfile(
|
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||||
fullname: string
|
const match = fullname.match(NIP05_REGEX)
|
||||||
): Promise<ProfilePointer | null> {
|
if (!match) return null
|
||||||
let [name, domain] = fullname.split('@')
|
|
||||||
|
|
||||||
if (!domain) {
|
const [, name = '_', domain] = match
|
||||||
// if there is no @, it is because it is just a domain, so assume the name is "_"
|
|
||||||
domain = name
|
|
||||||
name = '_'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name.match(/^[A-Za-z0-9-_.]+$/)) return null
|
|
||||||
if (!domain.includes('.')) return null
|
|
||||||
|
|
||||||
let res
|
|
||||||
try {
|
try {
|
||||||
res = await (
|
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
const res = await _fetch(url, { redirect: 'manual' })
|
||||||
).json()
|
if (res.status !== 200) {
|
||||||
} catch (err) {
|
throw Error('Wrong response code')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
|
||||||
|
const pubkey = json.names[name]
|
||||||
|
return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
|
||||||
|
} catch (_e) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!res?.names?.[name]) return null
|
|
||||||
|
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
|
||||||
let pubkey = res.names[name] as string
|
const res = await queryProfile(nip05)
|
||||||
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
|
return res ? res.pubkey === pubkey : false
|
||||||
|
|
||||||
return {
|
|
||||||
pubkey,
|
|
||||||
relays
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
const {nip06} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('generate private key from a mnemonic', async () => {
|
|
||||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
|
||||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
|
|
||||||
expect(privateKey).toEqual(
|
|
||||||
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('generate private key from a mnemonic and passphrase', async () => {
|
|
||||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
|
||||||
const passphrase = '123'
|
|
||||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
|
|
||||||
expect(privateKey).toEqual(
|
|
||||||
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
77
nip06.test.ts
Normal file
77
nip06.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import {
|
||||||
|
privateKeyFromSeedWords,
|
||||||
|
accountFromSeedWords,
|
||||||
|
extendedKeysFromSeedWords,
|
||||||
|
accountFromExtendedKey,
|
||||||
|
} from './nip06.ts'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
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(hexToBytes('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(hexToBytes('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(hexToBytes('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(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate private and public 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, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
|
||||||
|
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||||
|
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate extended keys from mnemonic', () => {
|
||||||
|
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||||
|
const passphrase = ''
|
||||||
|
const extendedAccountIndex = 0
|
||||||
|
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
|
||||||
|
mnemonic,
|
||||||
|
passphrase,
|
||||||
|
extendedAccountIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(privateExtendedKey).toBe(
|
||||||
|
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
|
||||||
|
)
|
||||||
|
expect(publicExtendedKey).toBe(
|
||||||
|
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate account from extended private key', () => {
|
||||||
|
const xprv =
|
||||||
|
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
|
||||||
|
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
|
||||||
|
|
||||||
|
expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
|
||||||
|
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate account from extended public key', () => {
|
||||||
|
const xpub =
|
||||||
|
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
|
||||||
|
const { publicKey } = accountFromExtendedKey(xpub)
|
||||||
|
|
||||||
|
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||||
|
})
|
||||||
77
nip06.ts
77
nip06.ts
@@ -1,20 +1,69 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||||
import {
|
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||||
generateMnemonic,
|
import { HDKey } from '@scure/bip32'
|
||||||
mnemonicToSeedSync,
|
|
||||||
validateMnemonic
|
|
||||||
} from '@scure/bip39'
|
|
||||||
import {HDKey} from '@scure/bip32'
|
|
||||||
|
|
||||||
export function privateKeyFromSeedWords(
|
const DERIVATION_PATH = `m/44'/1237'`
|
||||||
mnemonic: string,
|
|
||||||
passphrase?: string
|
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
|
||||||
): string {
|
|
||||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
|
||||||
if (!privateKey) throw new Error('could not derive private key')
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
return secp256k1.utils.bytesToHex(privateKey)
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountFromSeedWords(
|
||||||
|
mnemonic: string,
|
||||||
|
passphrase?: string,
|
||||||
|
accountIndex = 0,
|
||||||
|
): {
|
||||||
|
privateKey: Uint8Array
|
||||||
|
publicKey: string
|
||||||
|
} {
|
||||||
|
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
|
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
|
||||||
|
const publicKey = bytesToHex(seed.publicKey!.slice(1))
|
||||||
|
const privateKey = seed.privateKey
|
||||||
|
if (!privateKey || !publicKey) {
|
||||||
|
throw new Error('could not derive key pair')
|
||||||
|
}
|
||||||
|
return { privateKey, publicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extendedKeysFromSeedWords(
|
||||||
|
mnemonic: string,
|
||||||
|
passphrase?: string,
|
||||||
|
extendedAccountIndex = 0,
|
||||||
|
): {
|
||||||
|
privateExtendedKey: string
|
||||||
|
publicExtendedKey: string
|
||||||
|
} {
|
||||||
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
|
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
|
||||||
|
let privateExtendedKey = seed.privateExtendedKey
|
||||||
|
let publicExtendedKey = seed.publicExtendedKey
|
||||||
|
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
|
||||||
|
return { privateExtendedKey, publicExtendedKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountFromExtendedKey(
|
||||||
|
base58key: string,
|
||||||
|
accountIndex = 0,
|
||||||
|
): {
|
||||||
|
privateKey?: Uint8Array
|
||||||
|
publicKey: string
|
||||||
|
} {
|
||||||
|
let extendedKey = HDKey.fromExtendedKey(base58key)
|
||||||
|
let version = base58key.slice(0, 4)
|
||||||
|
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
|
||||||
|
let publicKey = bytesToHex(child.publicKey!.slice(1))
|
||||||
|
if (!publicKey) throw new Error('could not derive public key')
|
||||||
|
if (version === 'xprv') {
|
||||||
|
let privateKey = child.privateKey!
|
||||||
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
|
return { privateKey, publicKey }
|
||||||
|
}
|
||||||
|
return { publicKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSeedWords(): string {
|
export function generateSeedWords(): string {
|
||||||
|
|||||||
14
nip07.ts
Normal file
14
nip07.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { EventTemplate, VerifiedEvent } from './core.ts'
|
||||||
|
|
||||||
|
export interface WindowNostr {
|
||||||
|
getPublicKey(): Promise<string>
|
||||||
|
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||||
|
nip04?: {
|
||||||
|
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||||
|
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||||
|
}
|
||||||
|
nip44?: {
|
||||||
|
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||||
|
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
353
nip10.test.js
353
nip10.test.js
@@ -1,353 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {nip10} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
describe('parse NIP10-referenced events', () => {
|
|
||||||
test('legacy + a lot of events', () => {
|
|
||||||
let event = {
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
|
||||||
mentions: [
|
|
||||||
{
|
|
||||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
reply: {
|
|
||||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('legacy + 3 events', () => {
|
|
||||||
let event = {
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
|
||||||
mentions: [
|
|
||||||
{
|
|
||||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
reply: {
|
|
||||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('legacy + 2 events', () => {
|
|
||||||
let event = {
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
|
||||||
mentions: [],
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
reply: {
|
|
||||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
|
||||||
relays: []
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('legacy + 1 event', () => {
|
|
||||||
let event = {
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
|
||||||
mentions: [],
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
reply: undefined,
|
|
||||||
root: {
|
|
||||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
|
||||||
relays: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.todo('recommended + a lot of events')
|
|
||||||
test.todo('recommended + 3 events')
|
|
||||||
test.todo('recommended + 2 events')
|
|
||||||
|
|
||||||
test('recommended + 1 event', () => {
|
|
||||||
let event = {
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'p',
|
|
||||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
|
||||||
'wss://relay.mostr.pub'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'e',
|
|
||||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
|
||||||
'wss://relay.mostr.pub',
|
|
||||||
'reply'
|
|
||||||
],
|
|
||||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
|
||||||
mentions: [],
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey:
|
|
||||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
reply: {
|
|
||||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
|
||||||
relays: ['wss://relay.mostr.pub']
|
|
||||||
},
|
|
||||||
root: undefined
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
264
nip10.test.ts
Normal file
264
nip10.test.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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: [
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||||
|
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||||
|
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||||
|
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
quotes: [],
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
root: {
|
||||||
|
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('modern', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
quotes: [],
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
root: {
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('modern, inverted, author hint', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
|
||||||
|
[
|
||||||
|
'e',
|
||||||
|
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
'wss://banana.com',
|
||||||
|
'root',
|
||||||
|
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
],
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
quotes: [],
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
root: {
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||||
|
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1 event, relay hint from author', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
|
||||||
|
[
|
||||||
|
'e',
|
||||||
|
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||||
|
'',
|
||||||
|
'root',
|
||||||
|
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
quotes: [],
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: ['wss://banana.com'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||||
|
relays: ['wss://banana.com'],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||||
|
relays: ['wss://banana.com'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('many p 1 reply', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||||
|
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||||
|
[
|
||||||
|
'e',
|
||||||
|
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||||
|
'wss://relay.mostr.pub',
|
||||||
|
'reply',
|
||||||
|
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||||
|
],
|
||||||
|
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
quotes: [],
|
||||||
|
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'],
|
||||||
|
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
165
nip10.ts
165
nip10.ts
@@ -1,7 +1,7 @@
|
|||||||
import type {Event} from './event'
|
import type { Event } from './core.ts'
|
||||||
import type {EventPointer, ProfilePointer} from './nip19'
|
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
export type NIP10Result = {
|
export function parse(event: Pick<Event, 'tags'>): {
|
||||||
/**
|
/**
|
||||||
* Pointer to the root of the thread.
|
* Pointer to the root of the thread.
|
||||||
*/
|
*/
|
||||||
@@ -13,84 +13,135 @@ export type NIP10Result = {
|
|||||||
reply: EventPointer | undefined
|
reply: EventPointer | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pointers to events which may or may not be in the reply chain.
|
* Pointers to events that may or may not be in the reply chain.
|
||||||
*/
|
*/
|
||||||
mentions: EventPointer[]
|
mentions: EventPointer[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointers to events that were directly quoted.
|
||||||
|
*/
|
||||||
|
quotes: EventPointer[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of pubkeys that are involved in the thread in no particular order.
|
* List of pubkeys that are involved in the thread in no particular order.
|
||||||
*/
|
*/
|
||||||
profiles: ProfilePointer[]
|
profiles: ProfilePointer[]
|
||||||
}
|
} {
|
||||||
|
const result: ReturnType<typeof parse> = {
|
||||||
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
|
||||||
const result: NIP10Result = {
|
|
||||||
reply: undefined,
|
reply: undefined,
|
||||||
root: undefined,
|
root: undefined,
|
||||||
mentions: [],
|
mentions: [],
|
||||||
profiles: []
|
profiles: [],
|
||||||
|
quotes: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const eTags: string[][] = []
|
let maybeParent: EventPointer | undefined
|
||||||
|
let maybeRoot: EventPointer | undefined
|
||||||
|
|
||||||
|
for (let i = event.tags.length - 1; i >= 0; i--) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
|
||||||
for (const tag of event.tags) {
|
|
||||||
if (tag[0] === 'e' && tag[1]) {
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
eTags.push(tag)
|
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
undefined | string,
|
||||||
|
undefined | string,
|
||||||
|
undefined | string,
|
||||||
|
]
|
||||||
|
|
||||||
|
const eventPointer: EventPointer = {
|
||||||
|
id: eTagEventId,
|
||||||
|
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||||
|
author: eTagAuthor,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eTagMarker === 'root') {
|
||||||
|
result.root = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eTagMarker === 'reply') {
|
||||||
|
result.reply = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eTagMarker === 'mention') {
|
||||||
|
result.mentions.push(eventPointer)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!maybeParent) {
|
||||||
|
maybeParent = eventPointer
|
||||||
|
} else {
|
||||||
|
maybeRoot = eventPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
result.mentions.push(eventPointer)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag[0] === 'q' && tag[1]) {
|
||||||
|
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
|
||||||
|
result.quotes.push({
|
||||||
|
id: eTagEventId,
|
||||||
|
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag[0] === 'p' && tag[1]) {
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
result.profiles.push({
|
result.profiles.push({
|
||||||
pubkey: tag[1],
|
pubkey: tag[1],
|
||||||
relays: tag[2] ? [tag[2]] : []
|
relays: tag[2] ? [tag[2]] : [],
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
|
||||||
const eTag = eTags[eTagIndex]
|
if (!result.root) {
|
||||||
|
result.root = maybeRoot || maybeParent || result.reply
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
if (!result.reply) {
|
||||||
|
result.reply = maybeParent || result.root
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove root and reply from mentions, inherit relay hints from authors if any
|
||||||
|
;[result.reply, result.root].forEach(ref => {
|
||||||
|
if (!ref) return
|
||||||
|
|
||||||
|
let idx = result.mentions.indexOf(ref)
|
||||||
|
if (idx !== -1) {
|
||||||
|
result.mentions.splice(idx, 1)
|
||||||
|
}
|
||||||
|
if (ref.author) {
|
||||||
|
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||||
|
if (author && author.relays) {
|
||||||
|
if (!ref.relays) {
|
||||||
|
ref.relays = []
|
||||||
|
}
|
||||||
|
author.relays.forEach(url => {
|
||||||
|
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
|
||||||
|
})
|
||||||
|
author.relays = ref.relays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
result.mentions.forEach(ref => {
|
||||||
|
if (ref!.author) {
|
||||||
|
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||||
|
if (author && author.relays) {
|
||||||
|
if (!ref.relays) {
|
||||||
|
ref.relays = []
|
||||||
|
}
|
||||||
|
author.relays.forEach(url => {
|
||||||
|
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
|
||||||
|
})
|
||||||
|
author.relays = ref.relays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
18
nip11.test.ts
Normal file
18
nip11.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { useFetchImplementation, fetchRelayInformation } from './nip11.ts'
|
||||||
|
|
||||||
|
// TODO: replace with a mock
|
||||||
|
describe('requesting relay as for NIP11', () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
test('testing a relay', async () => {
|
||||||
|
const info = await fetchRelayInformation('wss://nos.lol')
|
||||||
|
expect(info.name).toEqual('nos.lol')
|
||||||
|
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
|
||||||
|
expect(info.supported_nips).toContain(1)
|
||||||
|
expect(info.supported_nips).toContain(11)
|
||||||
|
expect(info.supported_nips).toContain(70)
|
||||||
|
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
|
||||||
|
})
|
||||||
|
})
|
||||||
294
nip11.ts
Normal file
294
nip11.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any): void {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelayInformation(url: string): Promise<RelayInformation> {
|
||||||
|
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_subscriptions 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 restricted_writes: this relay requires some kind
|
||||||
|
* of condition to be fulfilled in order to accept events
|
||||||
|
* (not necessarily, but including
|
||||||
|
* @param payment_required this relay requires payment
|
||||||
|
* before a new connection may perform any action.
|
||||||
|
* @param created_at_lower_limit: 'created_at' lower limit
|
||||||
|
* @param created_at_upper_limit: 'created_at' upper limit
|
||||||
|
*/
|
||||||
|
export interface Limitations {
|
||||||
|
max_message_length: number
|
||||||
|
max_subscriptions: 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
|
||||||
|
created_at_lower_limit: number
|
||||||
|
created_at_upper_limit: number
|
||||||
|
restricted_writes: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
const {nip13} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('identifies proof-of-work difficulty', async () => {
|
|
||||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
|
||||||
const difficulty = nip13.getPow(id)
|
|
||||||
expect(difficulty).toEqual(21)
|
|
||||||
})
|
|
||||||
30
nip13.test.ts
Normal file
30
nip13.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { getPow, minePow } from './nip13.ts'
|
||||||
|
|
||||||
|
test('identifies proof-of-work difficulty', async () => {
|
||||||
|
;[
|
||||||
|
['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
|
||||||
|
['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
|
||||||
|
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
|
||||||
|
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
|
||||||
|
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
|
||||||
|
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
|
||||||
|
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
74
nip13.ts
74
nip13.ts
@@ -1,42 +1,60 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { type UnsignedEvent, type Event } from './pure.ts'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
/** Get POW difficulty from a Nostr hex ID. */
|
/** Get POW difficulty from a Nostr hex ID. */
|
||||||
export function getPow(id: string): number {
|
export function getPow(hex: string): number {
|
||||||
return getLeadingZeroBits(secp256k1.utils.hexToBytes(id))
|
let count = 0
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
for (let i = 0; i < 64; i += 8) {
|
||||||
* Get number of leading 0 bits. Adapted from nostream.
|
const nibble = parseInt(hex.substring(i, i + 8), 16)
|
||||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
if (nibble === 0) {
|
||||||
*/
|
count += 32
|
||||||
function getLeadingZeroBits(hash: Uint8Array): number {
|
} else {
|
||||||
let total: number, i: number, bits: number
|
count += Math.clz32(nibble)
|
||||||
|
|
||||||
for (i = 0, total = 0; i < hash.length; i++) {
|
|
||||||
bits = msb(hash[i])
|
|
||||||
total += bits
|
|
||||||
if (bits !== 8) {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total
|
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapted from nostream.
|
* Mine an event with the desired POW. This function mutates the event.
|
||||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
|
||||||
*/
|
*/
|
||||||
function msb(b: number) {
|
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||||
let n = 0
|
let count = 0
|
||||||
|
|
||||||
if (b === 0) {
|
const event = unsigned as Omit<Event, 'sig'>
|
||||||
return 8
|
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 = fastEventHash(event)
|
||||||
|
|
||||||
|
if (getPow(event.id) >= difficulty) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-cond-assign
|
return event
|
||||||
while (b >>= 1) {
|
}
|
||||||
n++
|
|
||||||
}
|
export function fastEventHash(evt: UnsignedEvent): string {
|
||||||
|
return bytesToHex(
|
||||||
return 7 - n
|
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
97
nip17.test.ts
Normal file
97
nip17.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { getPublicKey } from './pure.ts'
|
||||||
|
import { decode } from './nip19.ts'
|
||||||
|
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
|
||||||
|
|
||||||
|
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
|
||||||
|
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
|
||||||
|
|
||||||
|
const recipients = [
|
||||||
|
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
|
||||||
|
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
|
||||||
|
]
|
||||||
|
const message = 'Hello, this is a direct message!'
|
||||||
|
const conversationTitle = 'Private Group Conversation' // Optional
|
||||||
|
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
|
||||||
|
|
||||||
|
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
|
||||||
|
|
||||||
|
test('wrapEvent', () => {
|
||||||
|
const expected = {
|
||||||
|
content: '',
|
||||||
|
id: '',
|
||||||
|
created_at: 1728537932,
|
||||||
|
kind: 1059,
|
||||||
|
pubkey: '',
|
||||||
|
sig: '',
|
||||||
|
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(wrappedEvent.kind).toEqual(expected.kind)
|
||||||
|
expect(wrappedEvent.tags).toEqual(expected.tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wrapManyEvents', () => {
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
kind: 1059,
|
||||||
|
content: '',
|
||||||
|
created_at: 1729581521,
|
||||||
|
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||||
|
pubkey: '',
|
||||||
|
id: '',
|
||||||
|
sig: '',
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 1059,
|
||||||
|
content: '',
|
||||||
|
created_at: 1729594619,
|
||||||
|
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||||
|
pubkey: '',
|
||||||
|
id: '',
|
||||||
|
sig: '',
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 1059,
|
||||||
|
content: '',
|
||||||
|
created_at: 1729560014,
|
||||||
|
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
|
||||||
|
pubkey: '',
|
||||||
|
id: '',
|
||||||
|
sig: '',
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
|
||||||
|
|
||||||
|
wrappedEvents.forEach((event, index) => {
|
||||||
|
expect(event.kind).toEqual(expected[index].kind)
|
||||||
|
expect(event.tags).toEqual(expected[index].tags)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unwrapEvent', () => {
|
||||||
|
const expected = {
|
||||||
|
kind: 14,
|
||||||
|
content: 'Hello, this is a direct message!',
|
||||||
|
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||||
|
tags: [
|
||||||
|
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
|
||||||
|
['e', 'previousEventId123', '', 'reply'],
|
||||||
|
['subject', 'Private Group Conversation'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const result = unwrapEvent(wrappedEvent, sk1)
|
||||||
|
|
||||||
|
expect(result.kind).toEqual(expected.kind)
|
||||||
|
expect(result.content).toEqual(expected.content)
|
||||||
|
expect(result.pubkey).toEqual(expected.pubkey)
|
||||||
|
expect(result.tags).toEqual(expected.tags)
|
||||||
|
})
|
||||||
77
nip17.ts
Normal file
77
nip17.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { PrivateDirectMessage } from './kinds.ts'
|
||||||
|
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
|
||||||
|
import * as nip59 from './nip59.ts'
|
||||||
|
|
||||||
|
type Recipient = {
|
||||||
|
publicKey: string
|
||||||
|
relayUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplyTo = {
|
||||||
|
eventId: string
|
||||||
|
relayUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent(
|
||||||
|
recipients: Recipient | Recipient[],
|
||||||
|
message: string,
|
||||||
|
conversationTitle?: string,
|
||||||
|
replyTo?: ReplyTo,
|
||||||
|
): EventTemplate {
|
||||||
|
const baseEvent: EventTemplate = {
|
||||||
|
created_at: Math.ceil(Date.now() / 1000),
|
||||||
|
kind: PrivateDirectMessage,
|
||||||
|
tags: [],
|
||||||
|
content: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
|
||||||
|
|
||||||
|
recipientsArray.forEach(({ publicKey, relayUrl }) => {
|
||||||
|
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (replyTo) {
|
||||||
|
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversationTitle) {
|
||||||
|
baseEvent.tags.push(['subject', conversationTitle])
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapEvent(
|
||||||
|
senderPrivateKey: Uint8Array,
|
||||||
|
recipient: Recipient,
|
||||||
|
message: string,
|
||||||
|
conversationTitle?: string,
|
||||||
|
replyTo?: ReplyTo,
|
||||||
|
): NostrEvent {
|
||||||
|
const event = createEvent(recipient, message, conversationTitle, replyTo)
|
||||||
|
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapManyEvents(
|
||||||
|
senderPrivateKey: Uint8Array,
|
||||||
|
recipients: Recipient[],
|
||||||
|
message: string,
|
||||||
|
conversationTitle?: string,
|
||||||
|
replyTo?: ReplyTo,
|
||||||
|
): NostrEvent[] {
|
||||||
|
if (!recipients || recipients.length === 0) {
|
||||||
|
throw new Error('At least one recipient is required.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||||
|
|
||||||
|
// wrap the event for the sender and then for each recipient
|
||||||
|
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
|
||||||
|
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unwrapEvent = nip59.unwrapEvent
|
||||||
|
|
||||||
|
export const unwrapManyEvents = nip59.unwrapManyEvents
|
||||||
170
nip18.test.ts
Normal file
170
nip18.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } 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('GenericRepost', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: 1617932114,
|
||||||
|
kind: BadgeDefinitionKind,
|
||||||
|
tags: [
|
||||||
|
['d', 'badge-id'],
|
||||||
|
['name', 'Badge Name'],
|
||||||
|
['description', 'Badge Description'],
|
||||||
|
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||||
|
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||||
|
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
|
||||||
|
test('should create a generic reposted event', () => {
|
||||||
|
const template = { created_at: 1617932115 }
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(GenericRepost)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['e', repostedEvent.id, relayUrl],
|
||||||
|
['p', repostedEvent.pubkey],
|
||||||
|
['k', '30009'],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||||
|
expect(event.created_at).toEqual(template.created_at)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finishRepostEvent', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
|
||||||
|
test('should create an event with empty content if the reposted event is protected', () => {
|
||||||
|
const repostedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [['-']],
|
||||||
|
content: 'Replied to a post',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
const template = {
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.content).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
106
nip18.ts
Normal file
106
nip18.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
|
||||||
|
import { EventPointer } from './nip19.ts'
|
||||||
|
import { Event, finalizeEvent, verifyEvent } from './pure.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 {
|
||||||
|
let kind: Repost | GenericRepost
|
||||||
|
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
|
||||||
|
if (reposted.kind === ShortTextNote) {
|
||||||
|
kind = Repost
|
||||||
|
} else {
|
||||||
|
kind = GenericRepost
|
||||||
|
tags.push(['k', String(reposted.kind)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||||
|
if (![Repost, GenericRepost].includes(event.kind)) {
|
||||||
|
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
|
||||||
|
}
|
||||||
111
nip19.test.js
111
nip19.test.js
@@ -1,111 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('encode and decode nsec', () => {
|
|
||||||
let sk = generatePrivateKey()
|
|
||||||
let nsec = nip19.nsecEncode(sk)
|
|
||||||
expect(nsec).toMatch(/nsec1\w+/)
|
|
||||||
let {type, data} = nip19.decode(nsec)
|
|
||||||
expect(type).toEqual('nsec')
|
|
||||||
expect(data).toEqual(sk)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('encode and decode npub', () => {
|
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
|
||||||
let npub = nip19.npubEncode(pk)
|
|
||||||
expect(npub).toMatch(/npub1\w+/)
|
|
||||||
let {type, data} = nip19.decode(npub)
|
|
||||||
expect(type).toEqual('npub')
|
|
||||||
expect(data).toEqual(pk)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('encode and decode nprofile', () => {
|
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
|
||||||
let relays = [
|
|
||||||
'wss://relay.nostr.example.mydomain.example.com',
|
|
||||||
'wss://nostr.banana.com'
|
|
||||||
]
|
|
||||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
|
||||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
|
||||||
let {type, data} = nip19.decode(nprofile)
|
|
||||||
expect(type).toEqual('nprofile')
|
|
||||||
expect(data.pubkey).toEqual(pk)
|
|
||||||
expect(data.relays).toContain(relays[0])
|
|
||||||
expect(data.relays).toContain(relays[1])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decode nprofile without relays', () => {
|
|
||||||
expect(
|
|
||||||
nip19.decode(
|
|
||||||
nip19.nprofileEncode({
|
|
||||||
pubkey:
|
|
||||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
|
||||||
relays: []
|
|
||||||
})
|
|
||||||
).data
|
|
||||||
).toHaveProperty(
|
|
||||||
'pubkey',
|
|
||||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('encode and decode naddr', () => {
|
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
|
||||||
let relays = [
|
|
||||||
'wss://relay.nostr.example.mydomain.example.com',
|
|
||||||
'wss://nostr.banana.com'
|
|
||||||
]
|
|
||||||
let naddr = nip19.naddrEncode({
|
|
||||||
pubkey: pk,
|
|
||||||
relays,
|
|
||||||
kind: 30023,
|
|
||||||
identifier: 'banana'
|
|
||||||
})
|
|
||||||
expect(naddr).toMatch(/naddr1\w+/)
|
|
||||||
let {type, data} = nip19.decode(naddr)
|
|
||||||
expect(type).toEqual('naddr')
|
|
||||||
expect(data.pubkey).toEqual(pk)
|
|
||||||
expect(data.relays).toContain(relays[0])
|
|
||||||
expect(data.relays).toContain(relays[1])
|
|
||||||
expect(data.kind).toEqual(30023)
|
|
||||||
expect(data.identifier).toEqual('banana')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decode naddr from habla.news', () => {
|
|
||||||
let {type, data} = nip19.decode(
|
|
||||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
|
||||||
)
|
|
||||||
expect(type).toEqual('naddr')
|
|
||||||
expect(data.pubkey).toEqual(
|
|
||||||
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
|
||||||
)
|
|
||||||
expect(data.kind).toEqual(30023)
|
|
||||||
expect(data.identifier).toEqual('references')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
|
||||||
let {type, data} = nip19.decode(
|
|
||||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(type).toEqual('naddr')
|
|
||||||
expect(data.pubkey).toEqual(
|
|
||||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
|
||||||
)
|
|
||||||
expect(data.relays).toContain(
|
|
||||||
'wss://relay.nostr.example.mydomain.example.com'
|
|
||||||
)
|
|
||||||
expect(data.relays).toContain('wss://nostr.banana.com')
|
|
||||||
expect(data.kind).toEqual(30023)
|
|
||||||
expect(data.identifier).toEqual('banana')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('encode and decode nrelay', () => {
|
|
||||||
let url = "wss://relay.nostr.example"
|
|
||||||
let nrelay = nip19.nrelayEncode(url)
|
|
||||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
|
||||||
let {type, data} = nip19.decode(nrelay)
|
|
||||||
expect(type).toEqual('nrelay')
|
|
||||||
expect(data).toEqual(url)
|
|
||||||
})
|
|
||||||
283
nip19.test.ts
Normal file
283
nip19.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
decode,
|
||||||
|
naddrEncode,
|
||||||
|
neventEncode,
|
||||||
|
NostrTypeGuard,
|
||||||
|
nprofileEncode,
|
||||||
|
npubEncode,
|
||||||
|
nsecEncode
|
||||||
|
} from './nip19.ts'
|
||||||
|
import { generateSecretKey, getPublicKey } from './pure.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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NostrTypeGuard', () => {
|
||||||
|
test('isNProfile', () => {
|
||||||
|
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNProfile invalid nprofile', () => {
|
||||||
|
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNProfile with invalid nprofile', () => {
|
||||||
|
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent(
|
||||||
|
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent with invalid nevent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent(
|
||||||
|
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent with invalid nevent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNAddr', () => {
|
||||||
|
const is = NostrTypeGuard.isNAddr(
|
||||||
|
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNAddr with invalid nadress', () => {
|
||||||
|
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec with invalid nsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec with invalid nsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub with invalid npub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub with invalid npub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote with invalid note', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote with invalid note', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec(
|
||||||
|
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec(
|
||||||
|
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
})
|
||||||
233
nip19.ts
233
nip19.ts
@@ -1,9 +1,46 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||||
import {bech32} from '@scure/base'
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
const Bech32MaxSize = 5000
|
export type NProfile = `nprofile1${string}`
|
||||||
|
export type NEvent = `nevent1${string}`
|
||||||
|
export type NAddr = `naddr1${string}`
|
||||||
|
export type NSec = `nsec1${string}`
|
||||||
|
export type NPub = `npub1${string}`
|
||||||
|
export type Note = `note1${string}`
|
||||||
|
export type Ncryptsec = `ncryptsec1${string}`
|
||||||
|
|
||||||
|
export const NostrTypeGuard = {
|
||||||
|
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
|
||||||
|
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
|
||||||
|
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
export type ProfilePointer = {
|
||||||
pubkey: string // hex
|
pubkey: string // hex
|
||||||
@@ -14,6 +51,7 @@ export type EventPointer = {
|
|||||||
id: string // hex
|
id: string // hex
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
author?: string
|
author?: string
|
||||||
|
kind?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddressPointer = {
|
export type AddressPointer = {
|
||||||
@@ -23,17 +61,56 @@ export type AddressPointer = {
|
|||||||
relays?: string[]
|
relays?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DecodeResult =
|
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
|
||||||
| {type: 'nprofile'; data: ProfilePointer}
|
try {
|
||||||
| {type: 'nrelay'; data: string}
|
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
|
||||||
| {type: 'nevent'; data: EventPointer}
|
return decode(nip19code)
|
||||||
| {type: 'naddr'; data: AddressPointer}
|
} catch (_err) {
|
||||||
| {type: 'nsec'; data: string}
|
return { type: 'invalid', data: null }
|
||||||
| {type: 'npub'; data: string}
|
}
|
||||||
| {type: 'note'; data: string}
|
}
|
||||||
|
|
||||||
export function decode(nip19: string): DecodeResult {
|
export type DecodedNevent = {
|
||||||
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
type: 'nevent'
|
||||||
|
data: EventPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedNprofile = {
|
||||||
|
type: 'nprofile'
|
||||||
|
data: ProfilePointer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedNaddr = {
|
||||||
|
type: 'naddr'
|
||||||
|
data: AddressPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedNsec = {
|
||||||
|
type: 'nsec'
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedNpub = {
|
||||||
|
type: 'npub'
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedNote = {
|
||||||
|
type: 'note'
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodedResult = DecodedNevent | DecodedNprofile | DecodedNaddr | DecodedNpub | DecodedNsec | DecodedNote
|
||||||
|
|
||||||
|
export function decode(nip19: NEvent): DecodedNevent
|
||||||
|
export function decode(nip19: NProfile): DecodedNprofile
|
||||||
|
export function decode(nip19: NAddr): DecodedNaddr
|
||||||
|
export function decode(nip19: NSec): DecodedNsec
|
||||||
|
export function decode(nip19: NPub): DecodedNpub
|
||||||
|
export function decode(nip19: Note): DecodedNote
|
||||||
|
export function decode(code: string): DecodedResult
|
||||||
|
export function decode(code: string): DecodedResult {
|
||||||
|
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
|
||||||
let data = new Uint8Array(bech32.fromWords(words))
|
let data = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
switch (prefix) {
|
switch (prefix) {
|
||||||
@@ -45,27 +122,26 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
return {
|
return {
|
||||||
type: 'nprofile',
|
type: 'nprofile',
|
||||||
data: {
|
data: {
|
||||||
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
|
pubkey: bytesToHex(tlv[0][0]),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'nevent': {
|
case 'nevent': {
|
||||||
let tlv = parseTLV(data)
|
let tlv = parseTLV(data)
|
||||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
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[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||||
if (tlv[2] && tlv[2][0].length !== 32)
|
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||||
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 {
|
return {
|
||||||
type: 'nevent',
|
type: 'nevent',
|
||||||
data: {
|
data: {
|
||||||
id: secp256k1.utils.bytesToHex(tlv[0][0]),
|
id: bytesToHex(tlv[0][0]),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
author: tlv[2]?.[0]
|
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||||
? secp256k1.utils.bytesToHex(tlv[2][0])
|
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||||
: undefined
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,34 +157,26 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
type: 'naddr',
|
type: 'naddr',
|
||||||
data: {
|
data: {
|
||||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||||
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
|
pubkey: bytesToHex(tlv[2][0]),
|
||||||
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
|
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'nrelay': {
|
|
||||||
let tlv = parseTLV(data)
|
|
||||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'nrelay',
|
|
||||||
data: utf8Decoder.decode(tlv[0][0])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'nsec':
|
case 'nsec':
|
||||||
|
return { type: prefix, data }
|
||||||
|
|
||||||
case 'npub':
|
case 'npub':
|
||||||
case 'note':
|
case 'note':
|
||||||
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
|
return { type: prefix, data: bytesToHex(data) }
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`unknown prefix ${prefix}`)
|
throw new Error(`unknown prefix ${prefix}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TLV = {[t: number]: Uint8Array[]}
|
type TLV = { [t: number]: Uint8Array[] }
|
||||||
|
|
||||||
function parseTLV(data: Uint8Array): TLV {
|
function parseTLV(data: Uint8Array): TLV {
|
||||||
let result: TLV = {}
|
let result: TLV = {}
|
||||||
@@ -118,84 +186,85 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
let l = rest[1]
|
let l = rest[1]
|
||||||
let v = rest.slice(2, 2 + l)
|
let v = rest.slice(2, 2 + l)
|
||||||
rest = rest.slice(2 + l)
|
rest = rest.slice(2 + l)
|
||||||
if (v.length < l) continue
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
result[t] = result[t] || []
|
result[t] = result[t] || []
|
||||||
result[t].push(v)
|
result[t].push(v)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nsecEncode(hex: string): string {
|
export function nsecEncode(key: Uint8Array): NSec {
|
||||||
return encodeBytes('nsec', hex)
|
return encodeBytes('nsec', key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function npubEncode(hex: string): string {
|
export function npubEncode(hex: string): NPub {
|
||||||
return encodeBytes('npub', hex)
|
return encodeBytes('npub', hexToBytes(hex))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noteEncode(hex: string): string {
|
export function noteEncode(hex: string): Note {
|
||||||
return encodeBytes('note', hex)
|
return encodeBytes('note', hexToBytes(hex))
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBytes(prefix: string, hex: string): string {
|
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||||
let data = secp256k1.utils.hexToBytes(hex)
|
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode(prefix, words, Bech32MaxSize)
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nprofileEncode(profile: ProfilePointer): string {
|
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||||
|
return encodeBech32(prefix, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nprofileEncode(profile: ProfilePointer): NProfile {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
|
0: [hexToBytes(profile.pubkey)],
|
||||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nprofile', data)
|
||||||
return bech32.encode('nprofile', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function neventEncode(event: EventPointer): string {
|
export function neventEncode(event: EventPointer): NEvent {
|
||||||
|
let kindArray
|
||||||
|
if (event.kind !== undefined) {
|
||||||
|
kindArray = integerToUint8Array(event.kind)
|
||||||
|
}
|
||||||
|
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [secp256k1.utils.hexToBytes(event.id)],
|
0: [hexToBytes(event.id)],
|
||||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
|
2: event.author ? [hexToBytes(event.author)] : [],
|
||||||
|
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
|
||||||
return bech32.encode('nevent', words, Bech32MaxSize)
|
return encodeBech32('nevent', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function naddrEncode(addr: AddressPointer): string {
|
export function naddrEncode(addr: AddressPointer): NAddr {
|
||||||
let kind = new ArrayBuffer(4)
|
let kind = new ArrayBuffer(4)
|
||||||
new DataView(kind).setUint32(0, addr.kind, false)
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [utf8Encoder.encode(addr.identifier)],
|
0: [utf8Encoder.encode(addr.identifier)],
|
||||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
|
2: [hexToBytes(addr.pubkey)],
|
||||||
3: [new Uint8Array(kind)]
|
3: [new Uint8Array(kind)],
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('naddr', data)
|
||||||
return bech32.encode('naddr', words, Bech32MaxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nrelayEncode(url: string): string {
|
|
||||||
let data = encodeTLV({
|
|
||||||
0: [utf8Encoder.encode(url)]
|
|
||||||
})
|
|
||||||
let words = bech32.toWords(data)
|
|
||||||
return bech32.encode('nrelay', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
let entries: Uint8Array[] = []
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
Object.entries(tlv).forEach(([t, vs]) => {
|
Object.entries(tlv)
|
||||||
vs.forEach(v => {
|
.reverse()
|
||||||
let entry = new Uint8Array(v.length + 2)
|
.forEach(([t, vs]) => {
|
||||||
entry.set([parseInt(t)], 0)
|
vs.forEach(v => {
|
||||||
entry.set([v.length], 1)
|
let entry = new Uint8Array(v.length + 2)
|
||||||
entry.set(v, 2)
|
entry.set([parseInt(t)], 0)
|
||||||
entries.push(entry)
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
return secp256k1.utils.concatBytes(...entries)
|
return concatBytes(...entries)
|
||||||
}
|
}
|
||||||
|
|||||||
24
nip21.test.ts
Normal file
24
nip21.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
54
nip21.ts
Normal file
54
nip21.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { AddressPointer, BECH32_REGEX, decode, EventPointer, ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||||
|
export const NOSTR_URI_REGEX: RegExp = 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:
|
||||||
|
| {
|
||||||
|
type: 'nevent'
|
||||||
|
data: EventPointer
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'nprofile'
|
||||||
|
data: ProfilePointer
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'naddr'
|
||||||
|
data: AddressPointer
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'npub'
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'nsec'
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'note'
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
78
nip25.test.ts
Normal 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
62
nip25.ts
Normal 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],
|
||||||
|
}
|
||||||
|
}
|
||||||
105
nip26.test.js
105
nip26.test.js
@@ -1,105 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('parse good delegation from NIP', async () => {
|
|
||||||
expect(
|
|
||||||
nip26.getDelegator({
|
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
|
||||||
pubkey:
|
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
|
||||||
created_at: 1660896109,
|
|
||||||
kind: 1,
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'delegation',
|
|
||||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
|
||||||
'kind=1&created_at>1640995200',
|
|
||||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
content: 'Hello world',
|
|
||||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
|
||||||
})
|
|
||||||
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('parse bad delegations', async () => {
|
|
||||||
expect(
|
|
||||||
nip26.getDelegator({
|
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
|
||||||
pubkey:
|
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
|
||||||
created_at: 1660896109,
|
|
||||||
kind: 1,
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'delegation',
|
|
||||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
|
|
||||||
'kind=1&created_at>1640995200',
|
|
||||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
content: 'Hello world',
|
|
||||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
|
||||||
})
|
|
||||||
).toEqual(null)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
nip26.getDelegator({
|
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
|
||||||
pubkey:
|
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
|
||||||
created_at: 1660896109,
|
|
||||||
kind: 1,
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'delegation',
|
|
||||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
|
||||||
'kind=1&created_at>1740995200',
|
|
||||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
content: 'Hello world',
|
|
||||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
|
||||||
})
|
|
||||||
).toEqual(null)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
nip26.getDelegator({
|
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
|
||||||
pubkey:
|
|
||||||
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
|
||||||
created_at: 1660896109,
|
|
||||||
kind: 1,
|
|
||||||
tags: [
|
|
||||||
[
|
|
||||||
'delegation',
|
|
||||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
|
||||||
'kind=1&created_at>1640995200',
|
|
||||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
content: 'Hello world',
|
|
||||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
|
||||||
})
|
|
||||||
).toEqual(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('create and verify delegation', async () => {
|
|
||||||
let sk1 = generatePrivateKey()
|
|
||||||
let pk1 = getPublicKey(sk1)
|
|
||||||
let sk2 = generatePrivateKey()
|
|
||||||
let pk2 = getPublicKey(sk2)
|
|
||||||
let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
|
|
||||||
expect(delegation).toHaveProperty('from', pk1)
|
|
||||||
expect(delegation).toHaveProperty('to', pk2)
|
|
||||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
|
||||||
|
|
||||||
let event = {
|
|
||||||
kind: 1,
|
|
||||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
|
||||||
pubkey: pk2
|
|
||||||
}
|
|
||||||
expect(nip26.getDelegator(event)).toEqual(pk1)
|
|
||||||
})
|
|
||||||
90
nip26.ts
90
nip26.ts
@@ -1,90 +0,0 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
|
||||||
|
|
||||||
import {Event} from './event'
|
|
||||||
import {utf8Encoder} from './utils'
|
|
||||||
import {getPublicKey} from './keys'
|
|
||||||
|
|
||||||
export type Parameters = {
|
|
||||||
pubkey: string // the key to whom the delegation will be given
|
|
||||||
kind: number | undefined
|
|
||||||
until: number | undefined // delegation will only be valid until this date
|
|
||||||
since: number | undefined // delegation will be valid from this date on
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Delegation = {
|
|
||||||
from: string // the pubkey who signed the delegation
|
|
||||||
to: string // the pubkey that is allowed to use the delegation
|
|
||||||
cond: string // the string of conditions as they should be included in the event tag
|
|
||||||
sig: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDelegation(
|
|
||||||
privateKey: string,
|
|
||||||
parameters: Parameters
|
|
||||||
): Delegation {
|
|
||||||
let conditions = []
|
|
||||||
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
|
|
||||||
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
|
|
||||||
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
|
|
||||||
let cond = conditions.join('&')
|
|
||||||
|
|
||||||
if (cond === '')
|
|
||||||
throw new Error('refusing to create a delegation without any conditions')
|
|
||||||
|
|
||||||
let sighash = sha256(
|
|
||||||
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
let sig = secp256k1.utils.bytesToHex(
|
|
||||||
secp256k1.schnorr.signSync(sighash, privateKey)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: getPublicKey(privateKey),
|
|
||||||
to: parameters.pubkey,
|
|
||||||
cond,
|
|
||||||
sig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDelegator(event: Event): string | null {
|
|
||||||
// find delegation tag
|
|
||||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
|
||||||
if (!tag) return null
|
|
||||||
|
|
||||||
let pubkey = tag[1]
|
|
||||||
let cond = tag[2]
|
|
||||||
let sig = tag[3]
|
|
||||||
|
|
||||||
// check conditions
|
|
||||||
let conditions = cond.split('&')
|
|
||||||
for (let i = 0; i < conditions.length; i++) {
|
|
||||||
let [key, operator, value] = conditions[i].split(/\b/)
|
|
||||||
|
|
||||||
// the supported conditions are just 'kind' and 'created_at' for now
|
|
||||||
if (key === 'kind' && operator === '=' && event.kind === parseInt(value))
|
|
||||||
continue
|
|
||||||
else if (
|
|
||||||
key === 'created_at' &&
|
|
||||||
operator === '<' &&
|
|
||||||
event.created_at < parseInt(value)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else if (
|
|
||||||
key === 'created_at' &&
|
|
||||||
operator === '>' &&
|
|
||||||
event.created_at > parseInt(value)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else return null // invalid condition
|
|
||||||
}
|
|
||||||
|
|
||||||
// check signature
|
|
||||||
let sighash = sha256(
|
|
||||||
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
|
|
||||||
)
|
|
||||||
if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
|
|
||||||
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
77
nip27.test.ts
Normal file
77
nip27.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { parse } from './nip27.ts'
|
||||||
|
|
||||||
|
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||||
|
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||||
|
const blocks = Array.from(parse(content))
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||||
|
{ type: 'text', text: ' check out my profile:' },
|
||||||
|
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||||
|
{ type: 'text', text: '; and this cool image ' },
|
||||||
|
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('second: parse content with 3 urls of different types', () => {
|
||||||
|
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||||
|
http://music.com/song.mp3
|
||||||
|
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
|
||||||
|
const blocks = Array.from(parse(content))
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: 'text', text: ':' },
|
||||||
|
{ type: 'relay', url: 'wss://oa.ao/' },
|
||||||
|
{ type: 'text', text: "; this was a relay and now here's a video -> " },
|
||||||
|
{ type: 'video', url: 'https://videos.com/video.mp4' },
|
||||||
|
{ type: 'text', text: '! and some music:\n' },
|
||||||
|
{ type: 'audio', url: 'http://music.com/song.mp3' },
|
||||||
|
{ type: 'text', text: '\nand a regular link: ' },
|
||||||
|
{ type: 'url', url: 'https://regular.com/page?ok=true' },
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
|
||||||
|
},
|
||||||
|
{ type: 'url', url: 'https://ok.com/' },
|
||||||
|
{ type: 'text', text: '!' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||||
|
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||||
|
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
|
||||||
|
with a video https://example.com/vid.webm and finally https://example.com/docs`
|
||||||
|
const blocks = Array.from(parse(content))
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: 'text', text: 'Look at these profiles ' },
|
||||||
|
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
{
|
||||||
|
type: 'reference',
|
||||||
|
pointer: {
|
||||||
|
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||||
|
relays: ['wss://qwieu.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'text', text: ' check this event ' },
|
||||||
|
{
|
||||||
|
type: 'reference',
|
||||||
|
pointer: {
|
||||||
|
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
|
||||||
|
relays: ['wss://zjbdksa.aswjdkn'],
|
||||||
|
author: undefined,
|
||||||
|
kind: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'text', text: "\n here's an image " },
|
||||||
|
{ type: 'image', url: 'https://example.com/pic.png' },
|
||||||
|
{ type: 'text', text: ' and another profile ' },
|
||||||
|
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||||
|
{ type: 'text', text: '\n with a video ' },
|
||||||
|
{ type: 'video', url: 'https://example.com/vid.webm' },
|
||||||
|
{ type: 'text', text: ' and finally ' },
|
||||||
|
{ type: 'url', url: 'https://example.com/docs' },
|
||||||
|
])
|
||||||
|
})
|
||||||
169
nip27.ts
Normal file
169
nip27.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
|
||||||
|
|
||||||
|
export type Block =
|
||||||
|
| {
|
||||||
|
type: 'text'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'reference'
|
||||||
|
pointer: ProfilePointer | AddressPointer | EventPointer
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'url'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'relay'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'video'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'audio'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const noCharacter = /\W/m
|
||||||
|
const noURLCharacter = /\W |\W$|$|,| /m
|
||||||
|
|
||||||
|
export function* parse(content: string): Iterable<Block> {
|
||||||
|
const max = content.length
|
||||||
|
let prevIndex = 0
|
||||||
|
let index = 0
|
||||||
|
while (index < max) {
|
||||||
|
let u = content.indexOf(':', index)
|
||||||
|
if (u === -1) {
|
||||||
|
// reached end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.substring(u - 5, u) === 'nostr') {
|
||||||
|
const m = content.substring(u + 60).match(noCharacter)
|
||||||
|
const end = m ? u + 60 + m.index! : max
|
||||||
|
try {
|
||||||
|
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||||
|
let { data, type } = decode(content.substring(u + 1, end))
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'npub':
|
||||||
|
pointer = { pubkey: data } as ProfilePointer
|
||||||
|
break
|
||||||
|
case 'nsec':
|
||||||
|
case 'note':
|
||||||
|
// ignore this, treat it as not a valid uri
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
pointer = data as any
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevIndex !== u - 5) {
|
||||||
|
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
|
||||||
|
}
|
||||||
|
yield { type: 'reference', pointer }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
} catch (_err) {
|
||||||
|
// ignore this, not a valid nostr uri
|
||||||
|
index = u + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
|
||||||
|
const m = content.substring(u + 4).match(noURLCharacter)
|
||||||
|
const end = m ? u + 4 + m.index! : max
|
||||||
|
const prefixLen = content[u - 1] === 's' ? 5 : 4
|
||||||
|
try {
|
||||||
|
let url = new URL(content.substring(u - prefixLen, end))
|
||||||
|
if (url.hostname.indexOf('.') === -1) {
|
||||||
|
throw new Error('invalid url')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevIndex !== u - prefixLen) {
|
||||||
|
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith('.png') ||
|
||||||
|
url.pathname.endsWith('.jpg') ||
|
||||||
|
url.pathname.endsWith('.jpeg') ||
|
||||||
|
url.pathname.endsWith('.gif') ||
|
||||||
|
url.pathname.endsWith('.webp')
|
||||||
|
) {
|
||||||
|
yield { type: 'image', url: url.toString() }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith('.mp4') ||
|
||||||
|
url.pathname.endsWith('.avi') ||
|
||||||
|
url.pathname.endsWith('.webm') ||
|
||||||
|
url.pathname.endsWith('.mkv')
|
||||||
|
) {
|
||||||
|
yield { type: 'video', url: url.toString() }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith('.mp3') ||
|
||||||
|
url.pathname.endsWith('.aac') ||
|
||||||
|
url.pathname.endsWith('.ogg') ||
|
||||||
|
url.pathname.endsWith('.opus')
|
||||||
|
) {
|
||||||
|
yield { type: 'audio', url: url.toString() }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
yield { type: 'url', url: url.toString() }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
} catch (_err) {
|
||||||
|
// ignore this, not a valid url
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
|
||||||
|
const m = content.substring(u + 4).match(noURLCharacter)
|
||||||
|
const end = m ? u + 4 + m.index! : max
|
||||||
|
const prefixLen = content[u - 1] === 's' ? 3 : 2
|
||||||
|
try {
|
||||||
|
let url = new URL(content.substring(u - prefixLen, end))
|
||||||
|
if (url.hostname.indexOf('.') === -1) {
|
||||||
|
throw new Error('invalid ws url')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevIndex !== u - prefixLen) {
|
||||||
|
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||||
|
}
|
||||||
|
yield { type: 'relay', url: url.toString() }
|
||||||
|
index = end
|
||||||
|
prevIndex = index
|
||||||
|
continue
|
||||||
|
} catch (_err) {
|
||||||
|
// ignore this, not a valid url
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ignore this, it is nothing
|
||||||
|
index = u + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevIndex !== max) {
|
||||||
|
yield { type: 'text', text: content.substring(prevIndex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
130
nip28.test.ts
Normal file
130
nip28.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
158
nip28.ts
Normal file
158
nip28.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Event, finalizeEvent } from './pure.ts'
|
||||||
|
import {
|
||||||
|
ChannelCreation,
|
||||||
|
ChannelHideMessage,
|
||||||
|
ChannelMessage,
|
||||||
|
ChannelMetadata as KindChannelMetadata,
|
||||||
|
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: KindChannelMetadata,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
614
nip29.ts
Normal file
614
nip29.ts
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||||
|
import { Subscription } from './abstract-relay.ts'
|
||||||
|
import type { Event, EventTemplate } from './core.ts'
|
||||||
|
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
|
||||||
|
import { AddressPointer, decode, NostrTypeGuard } from './nip19.ts'
|
||||||
|
import { normalizeURL } from './utils.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group.
|
||||||
|
*/
|
||||||
|
export type Group = {
|
||||||
|
relay: string
|
||||||
|
metadata: GroupMetadata
|
||||||
|
admins?: GroupAdmin[]
|
||||||
|
members?: GroupMember[]
|
||||||
|
reference: GroupReference
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the metadata for a NIP29 group.
|
||||||
|
*/
|
||||||
|
export type GroupMetadata = {
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
name?: string
|
||||||
|
picture?: string
|
||||||
|
about?: string
|
||||||
|
isPublic?: boolean
|
||||||
|
isOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group reference.
|
||||||
|
*/
|
||||||
|
export type GroupReference = {
|
||||||
|
id: string
|
||||||
|
host: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group member.
|
||||||
|
*/
|
||||||
|
export type GroupMember = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group admin.
|
||||||
|
*/
|
||||||
|
export type GroupAdmin = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
permissions: GroupAdminPermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the permissions that a NIP29 group admin can have.
|
||||||
|
*/
|
||||||
|
export enum GroupAdminPermission {
|
||||||
|
/** @deprecated use PutUser instead */
|
||||||
|
AddUser = 'add-user',
|
||||||
|
EditMetadata = 'edit-metadata',
|
||||||
|
DeleteEvent = 'delete-event',
|
||||||
|
RemoveUser = 'remove-user',
|
||||||
|
/** @deprecated removed from NIP */
|
||||||
|
AddPermission = 'add-permission',
|
||||||
|
/** @deprecated removed from NIP */
|
||||||
|
RemovePermission = 'remove-permission',
|
||||||
|
/** @deprecated removed from NIP */
|
||||||
|
EditGroupStatus = 'edit-group-status',
|
||||||
|
PutUser = 'put-user',
|
||||||
|
CreateGroup = 'create-group',
|
||||||
|
DeleteGroup = 'delete-group',
|
||||||
|
CreateInvite = 'create-invite',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a group metadata event template.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @returns An event template with the generated group metadata that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
group.metadata.name && tags.push(['name', group.metadata.name])
|
||||||
|
group.metadata.picture && tags.push(['picture', group.metadata.picture])
|
||||||
|
group.metadata.about && tags.push(['about', group.metadata.about])
|
||||||
|
group.metadata.isPublic && tags.push(['public'])
|
||||||
|
group.metadata.isOpen && tags.push(['open'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39000,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group metadata event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event is valid.
|
||||||
|
*/
|
||||||
|
export function validateGroupMetadataEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39000) return false
|
||||||
|
|
||||||
|
if (!event.pubkey) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for group admins.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param admins - An array of group admins.
|
||||||
|
* @returns The generated event template with the group admins that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
for (const admin of admins) {
|
||||||
|
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39001,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group admins event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupAdminsEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39001) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate permissions
|
||||||
|
for (const [tag, _value, _label, ...permissions] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
for (let i = 0; i < permissions.length; i += 1) {
|
||||||
|
if (typeof permissions[i] !== 'string') return false
|
||||||
|
|
||||||
|
// validate permission name from the GroupAdminPermission enum
|
||||||
|
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for a group with its members.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param members - An array of group members.
|
||||||
|
* @returns The generated event template with the group members that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
for (const member of members) {
|
||||||
|
tags.push(['p', member.pubkey, member.label || ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39002,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group members event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupMembersEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39002) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the normalized relay URL based on the provided group reference.
|
||||||
|
*
|
||||||
|
* @param groupReference - The group reference object containing the host.
|
||||||
|
* @returns The normalized relay URL.
|
||||||
|
*/
|
||||||
|
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
|
||||||
|
return normalizeURL(groupReference.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches relay information by group reference.
|
||||||
|
*
|
||||||
|
* @param groupReference The group reference.
|
||||||
|
* @returns A promise that resolves to the relay information.
|
||||||
|
*/
|
||||||
|
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
|
||||||
|
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
|
||||||
|
return fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group metadata event from the specified pool.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
|
||||||
|
* @throws {Error} If the group is not found on the specified relay.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupMetadataEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39000],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupMetadataEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group metadata event and returns the corresponding GroupMetadata object.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The parsed GroupMetadata object.
|
||||||
|
* @throws An error if the group metadata event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
|
||||||
|
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
|
||||||
|
|
||||||
|
const metadata: GroupMetadata = {
|
||||||
|
id: '',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tag, value] of event.tags) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'd':
|
||||||
|
metadata.id = value
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
metadata.name = value
|
||||||
|
break
|
||||||
|
case 'picture':
|
||||||
|
metadata.picture = value
|
||||||
|
break
|
||||||
|
case 'about':
|
||||||
|
metadata.about = value
|
||||||
|
break
|
||||||
|
case 'public':
|
||||||
|
metadata.isPublic = true
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
metadata.isOpen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group admins event from the specified pool.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information.
|
||||||
|
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
|
||||||
|
* @throws {Error} If the group admins event is not found on the specified relay.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupAdminsEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39001],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupAdminsEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group admins event and returns an array of GroupAdmin objects.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupAdmin objects.
|
||||||
|
* @throws Throws an error if the group admins event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
|
||||||
|
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
|
||||||
|
|
||||||
|
const admins: GroupAdmin[] = []
|
||||||
|
|
||||||
|
for (const [tag, value, label, ...permissions] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
admins.push({
|
||||||
|
pubkey: value,
|
||||||
|
label,
|
||||||
|
permissions: permissions as GroupAdminPermission[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return admins
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group members event from the specified relay.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||||
|
* @param {GroupReference} options.groupReference - The group reference object.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
|
||||||
|
* @throws {Error} If the group members event is not found.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupMembersEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMembersEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39002],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupMembersEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group members event and returns an array of GroupMember objects.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupMember objects.
|
||||||
|
* @throws Throws an error if the group members event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupMembersEvent(event: Event): GroupMember[] {
|
||||||
|
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
|
||||||
|
|
||||||
|
const members: GroupMember[] = []
|
||||||
|
|
||||||
|
for (const [tag, value, label] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
members.push({
|
||||||
|
pubkey: value,
|
||||||
|
label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
||||||
|
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
||||||
|
* If the relay information is not provided, it will be fetched using the normalized relay URL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options for loading the group.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference of the group to load.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
|
||||||
|
* @returns {Promise<Group>} A promise that resolves to the loaded group.
|
||||||
|
*/
|
||||||
|
export async function loadGroup({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
normalizedRelayURL,
|
||||||
|
relayInformation,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Group> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const metadata = parseGroupMetadataEvent(metadataEvent)
|
||||||
|
|
||||||
|
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const admins = parseGroupAdminsEvent(adminsEvent)
|
||||||
|
|
||||||
|
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const members = parseGroupMembersEvent(membersEvent)
|
||||||
|
|
||||||
|
const group: Group = {
|
||||||
|
relay: normalizedRelayURL,
|
||||||
|
metadata,
|
||||||
|
admins,
|
||||||
|
members,
|
||||||
|
reference: groupReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a group from the specified pool using the provided group code.
|
||||||
|
*
|
||||||
|
* @param {AbstractSimplePool} pool - The pool to load the group from.
|
||||||
|
* @param {string} code - The code representing the group.
|
||||||
|
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
|
||||||
|
* @throws {Error} - If the group code is invalid.
|
||||||
|
*/
|
||||||
|
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||||
|
const groupReference = parseGroupCode(code)
|
||||||
|
|
||||||
|
if (!groupReference) throw new Error('invalid group code')
|
||||||
|
|
||||||
|
return loadGroup({ pool, groupReference })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group code and returns a GroupReference object.
|
||||||
|
*
|
||||||
|
* @param code The group code to parse.
|
||||||
|
* @returns A GroupReference object if the code is valid, otherwise null.
|
||||||
|
*/
|
||||||
|
export function parseGroupCode(code: string): null | GroupReference {
|
||||||
|
if (NostrTypeGuard.isNAddr(code)) {
|
||||||
|
try {
|
||||||
|
let { data } = decode(code)
|
||||||
|
|
||||||
|
let { relays, identifier } = data
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a group reference into a string.
|
||||||
|
*
|
||||||
|
* @param gr - The group reference to encode.
|
||||||
|
* @returns The encoded group reference as a string.
|
||||||
|
*/
|
||||||
|
export function encodeGroupReference(gr: GroupReference): string {
|
||||||
|
const { host, id } = gr
|
||||||
|
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
|
||||||
|
|
||||||
|
return `${normalizedHost}'${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to relay groups metadata events and calls the provided event handler function
|
||||||
|
* when an event is received.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options for subscribing to relay groups metadata events.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
|
||||||
|
* @param {string} options.relayURL - The URL of the relay.
|
||||||
|
* @param {Function} options.onError - The error handler function.
|
||||||
|
* @param {Function} options.onEvent - The event handler function.
|
||||||
|
* @param {Function} [options.onConnect] - The connect handler function.
|
||||||
|
* @returns {Function} - A function to close the subscription
|
||||||
|
*/
|
||||||
|
export function subscribeRelayGroupsMetadataEvents({
|
||||||
|
pool,
|
||||||
|
relayURL,
|
||||||
|
onError,
|
||||||
|
onEvent,
|
||||||
|
onConnect,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
relayURL: string
|
||||||
|
onError: (err: Error) => void
|
||||||
|
onEvent: (event: Event) => void
|
||||||
|
onConnect?: () => void
|
||||||
|
}): () => void {
|
||||||
|
let sub: Subscription
|
||||||
|
|
||||||
|
const normalizedRelayURL = normalizeURL(relayURL)
|
||||||
|
|
||||||
|
fetchRelayInformation(normalizedRelayURL)
|
||||||
|
.then(async info => {
|
||||||
|
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
|
||||||
|
|
||||||
|
onConnect?.()
|
||||||
|
|
||||||
|
sub = abstractedRelay.prepareSubscription(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [39000],
|
||||||
|
limit: 50,
|
||||||
|
authors: [info.pubkey],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event: Event) {
|
||||||
|
onEvent(event)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
sub.close()
|
||||||
|
|
||||||
|
onError(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => sub.close()
|
||||||
|
}
|
||||||
33
nip30.test.ts
Normal file
33
nip30.test.ts
Normal 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
51
nip30.ts
Normal 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 = (): RegExp => 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const fetch = require('node-fetch')
|
|
||||||
const {nip39} = require('./lib/nostr.cjs.js')
|
|
||||||
|
|
||||||
test('validate github claim', async () => {
|
|
||||||
nip39.useFetchImplementation(fetch)
|
|
||||||
|
|
||||||
let result = await nip39.validateGithub(
|
|
||||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
|
||||||
'vitorpamplona',
|
|
||||||
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
|
||||||
)
|
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
|
||||||
15
nip39.test.ts
Normal file
15
nip39.test.ts
Normal 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)
|
||||||
|
})
|
||||||
15
nip39.ts
15
nip39.ts
@@ -8,19 +8,10 @@ export function useFetchImplementation(fetchImplementation: any) {
|
|||||||
_fetch = fetchImplementation
|
_fetch = fetchImplementation
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateGithub(
|
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||||
pubkey: string,
|
|
||||||
username: string,
|
|
||||||
proof: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
try {
|
||||||
let res = await (
|
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||||
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
|
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||||
).text()
|
|
||||||
return (
|
|
||||||
res ===
|
|
||||||
`Verifying that I control the following Nostr public key: ${pubkey}`
|
|
||||||
)
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
44
nip40.test.ts
Normal file
44
nip40.test.ts
Normal 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
49
nip40.ts
Normal 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 }
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
require('websocket-polyfill')
|
|
||||||
const {
|
|
||||||
relayInit,
|
|
||||||
generatePrivateKey,
|
|
||||||
finishEvent,
|
|
||||||
nip42
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('auth flow', done => {
|
|
||||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
|
||||||
relay.connect()
|
|
||||||
const sk = generatePrivateKey()
|
|
||||||
|
|
||||||
relay.on('auth', async challenge => {
|
|
||||||
await expect(
|
|
||||||
nip42.authenticate({
|
|
||||||
challenge,
|
|
||||||
relay,
|
|
||||||
sign: e => finishEvent(e, sk)
|
|
||||||
})
|
|
||||||
).rejects.toBeTruthy()
|
|
||||||
relay.close()
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
16
nip42.test.ts
Normal file
16
nip42.test.ts
Normal 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)
|
||||||
|
})
|
||||||
43
nip42.ts
43
nip42.ts
@@ -1,42 +1,17 @@
|
|||||||
import {EventTemplate, Event, Kind} from './event'
|
import { EventTemplate } from './core.ts'
|
||||||
import {Relay} from './relay'
|
import { ClientAuth } from './kinds.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate via NIP-42 flow.
|
* creates an EventTemplate for an AUTH event to be signed.
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const sign = window.nostr.signEvent
|
|
||||||
* relay.on('auth', challenge =>
|
|
||||||
* authenticate({ relay, sign, challenge })
|
|
||||||
* )
|
|
||||||
*/
|
*/
|
||||||
export const authenticate = async ({
|
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
|
||||||
challenge,
|
return {
|
||||||
relay,
|
kind: ClientAuth,
|
||||||
sign
|
|
||||||
}: {
|
|
||||||
challenge: string
|
|
||||||
relay: Relay
|
|
||||||
sign: (e: EventTemplate) => Promise<Event>
|
|
||||||
}): Promise<void> => {
|
|
||||||
const e: EventTemplate = {
|
|
||||||
kind: Kind.ClientAuth,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags: [
|
||||||
['relay', relay.url],
|
['relay', relayURL],
|
||||||
['challenge', challenge]
|
['challenge', challenge],
|
||||||
],
|
],
|
||||||
content: ''
|
content: '',
|
||||||
}
|
}
|
||||||
const pub = relay.auth(await sign(e))
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pub.on('ok', function ok() {
|
|
||||||
pub.off('ok', ok)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
pub.on('failed', function fail(reason: string) {
|
|
||||||
pub.off('failed', fail)
|
|
||||||
reject(reason)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
46
nip44.test.ts
Normal file
46
nip44.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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' with { 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(hexToBytes(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(hexToBytes(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(hexToBytes(v.sec1), v.pub2)).toThrow(
|
||||||
|
/(Point is not on curve|Cannot find square root)/,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
127
nip44.ts
Normal file
127
nip44.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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 } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
|
||||||
|
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
|
||||||
|
|
||||||
|
export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
|
||||||
|
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||||
|
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageKeys(
|
||||||
|
conversationKey: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeU16BE(num: number): Uint8Array {
|
||||||
|
if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > 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
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(plaintext: string): Uint8Array {
|
||||||
|
const unpadded = utf8Encoder.encode(plaintext)
|
||||||
|
const unpaddedLen = unpadded.length
|
||||||
|
const prefix = writeU16BE(unpaddedLen)
|
||||||
|
const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||||
|
return concatBytes(prefix, unpadded, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpad(padded: Uint8Array): string {
|
||||||
|
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||||
|
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||||
|
if (
|
||||||
|
unpaddedLen < minPlaintextSize ||
|
||||||
|
unpaddedLen > maxPlaintextSize ||
|
||||||
|
unpadded.length !== unpaddedLen ||
|
||||||
|
padded.length !== 2 + calcPaddedLen(unpaddedLen)
|
||||||
|
)
|
||||||
|
throw new Error('invalid padding')
|
||||||
|
return utf8Decoder.decode(unpadded)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): 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
|
||||||
|
function decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string {
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||||
|
const padded = pad(plaintext)
|
||||||
|
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||||
|
const mac = hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||||
|
const { nonce, ciphertext, mac } = decodePayload(payload)
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||||
|
const calculatedMac = hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||||
|
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||||
|
return unpad(padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const v2 = {
|
||||||
|
utils: {
|
||||||
|
getConversationKey,
|
||||||
|
calcPaddedLen,
|
||||||
|
},
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
}
|
||||||
605
nip44.vectors.json
Normal file
605
nip44.vectors.json
Normal 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鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
370
nip46.ts
Normal file
370
nip46.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
|
||||||
|
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||||
|
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||||
|
import { NIP05_REGEX } from './nip05.ts'
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||||
|
import type { RelayRecord } from './relay.ts'
|
||||||
|
import { Signer } from './signer.ts'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
|
||||||
|
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
|
||||||
|
bunkerPointer.relays.forEach(relay => {
|
||||||
|
bunkerURL.searchParams.append('relay', relay)
|
||||||
|
})
|
||||||
|
if (bunkerPointer.secret) {
|
||||||
|
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
|
||||||
|
}
|
||||||
|
return bunkerURL.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 implements Signer {
|
||||||
|
private params: BunkerSignerParams
|
||||||
|
private pool: AbstractSimplePool
|
||||||
|
private subCloser: SubCloser | undefined
|
||||||
|
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
|
||||||
|
private conversationKey: Uint8Array
|
||||||
|
public bp: BunkerPointer
|
||||||
|
|
||||||
|
private cachedPubKey: string | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.params = params
|
||||||
|
this.pool = params.pool || new SimplePool()
|
||||||
|
this.secretKey = clientSecretKey
|
||||||
|
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
|
||||||
|
this.bp = bp
|
||||||
|
this.isOpen = false
|
||||||
|
this.idPrefix = Math.random().toString(36).substring(7)
|
||||||
|
this.serial = 0
|
||||||
|
this.listeners = {}
|
||||||
|
this.waitingForAuth = {}
|
||||||
|
|
||||||
|
this.setupSubscription(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSubscription(params: BunkerSignerParams) {
|
||||||
|
const listeners = this.listeners
|
||||||
|
const waitingForAuth = this.waitingForAuth
|
||||||
|
const convKey = this.conversationKey
|
||||||
|
|
||||||
|
this.subCloser = this.pool.subscribe(
|
||||||
|
this.bp.relays,
|
||||||
|
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
|
||||||
|
{
|
||||||
|
onevent: async (event: NostrEvent) => {
|
||||||
|
const o = JSON.parse(decrypt(event.content, convKey))
|
||||||
|
const { id, result, error } = o
|
||||||
|
|
||||||
|
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||||
|
delete waitingForAuth[id]
|
||||||
|
|
||||||
|
if (params.onauth) {
|
||||||
|
params.onauth(error)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`nostr-tools/nip46: remote signer ${this.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]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose: () => {
|
||||||
|
this.subCloser = undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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')
|
||||||
|
if (!this.subCloser) this.setupSubscription(this.params)
|
||||||
|
|
||||||
|
this.serial++
|
||||||
|
const id = `${this.idPrefix}-${this.serial}`
|
||||||
|
|
||||||
|
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
|
||||||
|
|
||||||
|
// 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', [this.bp.pubkey, this.bp.secret || ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "get_public_key" method on the bunker.
|
||||||
|
* (before we would return the public key hardcoded in the bunker parameters, but
|
||||||
|
* that is not correct as that may be the bunker pubkey and the actual signer
|
||||||
|
* pubkey may be different.)
|
||||||
|
*/
|
||||||
|
async getPublicKey(): Promise<string> {
|
||||||
|
if (!this.cachedPubKey) {
|
||||||
|
this.cachedPubKey = await this.sendRequest('get_public_key', [])
|
||||||
|
}
|
||||||
|
return this.cachedPubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated removed from NIP
|
||||||
|
*/
|
||||||
|
async getRelays(): Promise<RelayRecord> {
|
||||||
|
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: EventTemplate): Promise<VerifiedEvent> {
|
||||||
|
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||||
|
let signed: NostrEvent = JSON.parse(resp)
|
||||||
|
if (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 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_decrypt', [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.
|
||||||
|
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
|
||||||
|
this will default to generating a random key.
|
||||||
|
* @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,
|
||||||
|
localSecretKey: Uint8Array = generateSecretKey(),
|
||||||
|
): Promise<BunkerSigner> {
|
||||||
|
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||||
|
|
||||||
|
let rpc = new BunkerSigner(localSecretKey, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
70
nip47.test.ts
Normal file
70
nip47.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
|
||||||
|
import { decrypt } from './nip04.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
|
||||||
|
describe('parseConnectionString', () => {
|
||||||
|
test('returns pubkey, relay, and secret if connection string has double slash', () => {
|
||||||
|
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('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))
|
||||||
|
})
|
||||||
|
})
|
||||||
44
nip47.ts
Normal file
44
nip47.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { type VerifiedEvent, finalizeEvent } from './pure.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
import { encrypt } from './nip04.ts'
|
||||||
|
|
||||||
|
interface NWCConnection {
|
||||||
|
pubkey: string
|
||||||
|
relay: string
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConnectionString(connectionString: string): NWCConnection {
|
||||||
|
const { host, pathname, searchParams } = new URL(connectionString)
|
||||||
|
const pubkey = pathname || host
|
||||||
|
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,
|
||||||
|
): Promise<VerifiedEvent> {
|
||||||
|
const content = {
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const encryptedContent = 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
95
nip49.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { decrypt, encrypt } from './nip49.ts'
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
]
|
||||||
50
nip49.ts
Normal file
50
nip49.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { scrypt } from '@noble/hashes/scrypt'
|
||||||
|
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||||
|
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||||
|
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
export function encrypt(
|
||||||
|
sec: Uint8Array,
|
||||||
|
password: string,
|
||||||
|
logn: number = 16,
|
||||||
|
ksb: 0x00 | 0x01 | 0x02 = 0x02,
|
||||||
|
): Ncryptsec {
|
||||||
|
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
|
||||||
|
}
|
||||||
42
nip54.test.ts
Normal file
42
nip54.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { normalizeIdentifier } from './nip54.ts'
|
||||||
|
|
||||||
|
describe('normalizeIdentifier', () => {
|
||||||
|
test('converts to lowercase', () => {
|
||||||
|
expect(normalizeIdentifier('HELLO')).toBe('hello')
|
||||||
|
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trims whitespace', () => {
|
||||||
|
expect(normalizeIdentifier(' hello ')).toBe('hello')
|
||||||
|
expect(normalizeIdentifier('\thello\n')).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes Unicode to NFKC form', () => {
|
||||||
|
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
|
||||||
|
expect(normalizeIdentifier('café')).toBe('café')
|
||||||
|
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replaces non-alphanumeric characters with hyphens', () => {
|
||||||
|
expect(normalizeIdentifier('hello world')).toBe('hello-world')
|
||||||
|
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
|
||||||
|
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves numbers', () => {
|
||||||
|
expect(normalizeIdentifier('user123')).toBe('user123')
|
||||||
|
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles multiple consecutive special characters', () => {
|
||||||
|
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
|
||||||
|
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles Unicode letters from different scripts', () => {
|
||||||
|
expect(normalizeIdentifier('привет')).toBe('привет')
|
||||||
|
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
|
||||||
|
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
|
||||||
|
})
|
||||||
|
})
|
||||||
19
nip54.ts
Normal file
19
nip54.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function normalizeIdentifier(name: string): string {
|
||||||
|
// Trim and lowercase
|
||||||
|
name = name.trim().toLowerCase()
|
||||||
|
|
||||||
|
// Normalize Unicode to NFKC form
|
||||||
|
name = name.normalize('NFKC')
|
||||||
|
|
||||||
|
// Convert to array of characters and map each one
|
||||||
|
return Array.from(name)
|
||||||
|
.map(char => {
|
||||||
|
// Check if character is letter or number using Unicode ranges
|
||||||
|
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-'
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
166
nip55.test.ts
Normal file
166
nip55.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import * as nip55 from './nip55.js'
|
||||||
|
|
||||||
|
// Function to parse the NostrSigner URI
|
||||||
|
function parseNostrSignerUri(uri: string) {
|
||||||
|
const [base, query] = uri.split('?')
|
||||||
|
const basePart = base.replace('nostrsigner:', '')
|
||||||
|
|
||||||
|
let jsonObject = null
|
||||||
|
if (basePart) {
|
||||||
|
try {
|
||||||
|
jsonObject = JSON.parse(decodeURIComponent(basePart))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse base JSON:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlSearchParams = new URLSearchParams(query)
|
||||||
|
const queryParams = Object.fromEntries(urlSearchParams.entries())
|
||||||
|
if (queryParams.permissions) {
|
||||||
|
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: jsonObject,
|
||||||
|
...queryParams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
test('Get Public Key URI', () => {
|
||||||
|
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
|
||||||
|
const callbackUrl = 'https://example.com/?event='
|
||||||
|
|
||||||
|
const uri = nip55.getPublicKeyUri({
|
||||||
|
permissions,
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'get_public_key')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
|
||||||
|
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
|
||||||
|
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
|
||||||
|
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sign Event URI', () => {
|
||||||
|
const eventJson = { kind: 1, content: 'test' }
|
||||||
|
|
||||||
|
const uri = nip55.signEventUri({
|
||||||
|
eventJson,
|
||||||
|
id: 'some_id',
|
||||||
|
currentUser: 'hex_pub_key',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||||
|
expect(jsonObject).toHaveProperty('base.content', 'test')
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'sign_event')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||||
|
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Encrypt NIP-04 URI', () => {
|
||||||
|
const callbackUrl = 'https://example.com/?event='
|
||||||
|
|
||||||
|
const uri = nip55.encryptNip04Uri({
|
||||||
|
callbackUrl,
|
||||||
|
pubKey: 'hex_pub_key',
|
||||||
|
content: 'plainText',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
|
||||||
|
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Decrypt NIP-04 URI', () => {
|
||||||
|
const uri = nip55.decryptNip04Uri({
|
||||||
|
id: 'some_id',
|
||||||
|
currentUser: 'hex_pub_key',
|
||||||
|
pubKey: 'hex_pub_key',
|
||||||
|
content: 'encryptedText',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||||
|
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Encrypt NIP-44 URI', () => {
|
||||||
|
const uri = nip55.encryptNip44Uri({
|
||||||
|
id: 'some_id',
|
||||||
|
currentUser: 'hex_pub_key',
|
||||||
|
pubKey: 'hex_pub_key',
|
||||||
|
content: 'plainText',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||||
|
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Decrypt NIP-44 URI', () => {
|
||||||
|
const uri = nip55.decryptNip44Uri({
|
||||||
|
id: 'some_id',
|
||||||
|
currentUser: 'hex_pub_key',
|
||||||
|
pubKey: 'hex_pub_key',
|
||||||
|
content: 'encryptedText',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||||
|
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||||
|
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||||
|
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Decrypt Zap Event URI', () => {
|
||||||
|
const eventJson = { kind: 1, content: 'test' }
|
||||||
|
|
||||||
|
const uri = nip55.decryptZapEventUri({
|
||||||
|
eventJson,
|
||||||
|
id: 'some_id',
|
||||||
|
currentUser: 'hex_pub_key',
|
||||||
|
returnType: 'event',
|
||||||
|
compressionType: 'gzip',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonObject = parseNostrSignerUri(uri)
|
||||||
|
|
||||||
|
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
|
||||||
|
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
|
||||||
|
expect(jsonObject).toHaveProperty('returnType', 'event')
|
||||||
|
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||||
|
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||||
|
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||||
|
})
|
||||||
123
nip55.ts
Normal file
123
nip55.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
type BaseParams = {
|
||||||
|
callbackUrl?: string
|
||||||
|
returnType?: 'signature' | 'event'
|
||||||
|
compressionType?: 'none' | 'gzip'
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionsParams = BaseParams & {
|
||||||
|
permissions?: { type: string; kind?: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventUriParams = BaseParams & {
|
||||||
|
eventJson: Record<string, unknown>
|
||||||
|
id?: string
|
||||||
|
currentUser?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptDecryptParams = BaseParams & {
|
||||||
|
pubKey: string
|
||||||
|
content: string
|
||||||
|
id?: string
|
||||||
|
currentUser?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UriParams = BaseParams & {
|
||||||
|
base: string
|
||||||
|
type: string
|
||||||
|
id?: string
|
||||||
|
currentUser?: string
|
||||||
|
permissions?: { type: string; kind?: number }[]
|
||||||
|
pubKey?: string
|
||||||
|
plainText?: string
|
||||||
|
encryptedText?: string
|
||||||
|
appName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeParams(params: Record<string, unknown>): string {
|
||||||
|
return new URLSearchParams(params as Record<string, string>).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
|
||||||
|
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUri({
|
||||||
|
base,
|
||||||
|
type,
|
||||||
|
callbackUrl,
|
||||||
|
returnType = 'signature',
|
||||||
|
compressionType = 'none',
|
||||||
|
...params
|
||||||
|
}: UriParams): string {
|
||||||
|
const baseParams = {
|
||||||
|
type,
|
||||||
|
compressionType,
|
||||||
|
returnType,
|
||||||
|
callbackUrl,
|
||||||
|
id: params.id,
|
||||||
|
current_user: params.currentUser,
|
||||||
|
permissions:
|
||||||
|
params.permissions && params.permissions.length > 0
|
||||||
|
? encodeURIComponent(JSON.stringify(params.permissions))
|
||||||
|
: undefined,
|
||||||
|
pubKey: params.pubKey,
|
||||||
|
plainText: params.plainText,
|
||||||
|
encryptedText: params.encryptedText,
|
||||||
|
appName: params.appName,
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredParams = filterUndefined(baseParams)
|
||||||
|
return `${base}?${encodeParams(filteredParams)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
|
||||||
|
return buildUri({
|
||||||
|
base: 'nostrsigner:',
|
||||||
|
type,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
|
||||||
|
return buildDefaultUri('get_public_key', { permissions, ...params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||||
|
return buildUri({
|
||||||
|
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||||
|
type: 'sign_event',
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
|
||||||
|
return buildDefaultUri(type, { ...params, plainText: params.content })
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
|
||||||
|
return buildDefaultUri(type, { ...params, encryptedText: params.content })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptNip04Uri(params: EncryptDecryptParams): string {
|
||||||
|
return encryptUri('nip04_encrypt', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptNip04Uri(params: EncryptDecryptParams): string {
|
||||||
|
return decryptUri('nip04_decrypt', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptNip44Uri(params: EncryptDecryptParams): string {
|
||||||
|
return encryptUri('nip44_encrypt', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptNip44Uri(params: EncryptDecryptParams): string {
|
||||||
|
return decryptUri('nip44_decrypt', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||||
|
return buildUri({
|
||||||
|
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||||
|
type: 'decrypt_zap_event',
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
335
nip57.test.js
335
nip57.test.js
@@ -1,335 +0,0 @@
|
|||||||
const {bech32} = require('@scure/base')
|
|
||||||
const {
|
|
||||||
nip57,
|
|
||||||
generatePrivateKey,
|
|
||||||
getPublicKey,
|
|
||||||
finishEvent
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
describe('getZapEndpoint', () => {
|
|
||||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
|
||||||
const metadata = {content: '{}'}
|
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns null if fetch fails', async () => {
|
|
||||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
|
||||||
'https://domain/.well-known/lnurlp/name'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns null if the response does not allow Nostr payments', async () => {
|
|
||||||
const fetchImplementation = jest.fn(() =>
|
|
||||||
Promise.resolve({json: () => ({allowsNostr: false})})
|
|
||||||
)
|
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
|
||||||
'https://domain/.well-known/lnurlp/name'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
|
||||||
const fetchImplementation = jest.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
json: () => ({
|
|
||||||
allowsNostr: true,
|
|
||||||
nostrPubkey: 'pubkey',
|
|
||||||
callback: 'callback'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
|
||||||
|
|
||||||
expect(result).toBe('callback')
|
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
|
||||||
'https://domain/.well-known/lnurlp/name'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('makeZapRequest', () => {
|
|
||||||
test('throws an error if amount is not given', () => {
|
|
||||||
expect(() =>
|
|
||||||
nip57.makeZapRequest({
|
|
||||||
profile: 'profile',
|
|
||||||
event: null,
|
|
||||||
relays: [],
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('throws an error if profile is not given', () => {
|
|
||||||
expect(() =>
|
|
||||||
nip57.makeZapRequest({
|
|
||||||
event: null,
|
|
||||||
amount: 100,
|
|
||||||
relays: [],
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns a valid Zap request', () => {
|
|
||||||
const result = nip57.makeZapRequest({
|
|
||||||
profile: 'profile',
|
|
||||||
event: 'event',
|
|
||||||
amount: 100,
|
|
||||||
relays: ['relay1', 'relay2'],
|
|
||||||
comment: 'comment'
|
|
||||||
})
|
|
||||||
expect(result.kind).toBe(9734)
|
|
||||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
|
||||||
expect(result.content).toBe('comment')
|
|
||||||
expect(result.tags).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
['p', 'profile'],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
])
|
|
||||||
)
|
|
||||||
expect(result.tags).toContainEqual(['e', 'event'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateZapRequest', () => {
|
|
||||||
test('returns an error message for invalid JSON', () => {
|
|
||||||
expect(nip57.validateZapRequest('invalid JSON')).toBe(
|
|
||||||
'Invalid zap request JSON.'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
|
||||||
const zapRequest = {
|
|
||||||
kind: 1234,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', 'profile'],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
'Zap request is not a valid Nostr event.'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the signature on the Zap request is invalid', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = {
|
|
||||||
pubkey: publicKey,
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
'Invalid signature on zap request.'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
|
|
||||||
const zapRequest = finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
"Zap request doesn't have a 'p' tag."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
|
|
||||||
const zapRequest = finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', 'invalid hex'],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
"Zap request 'p' tag is not valid hex."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['e', 'invalid hex'],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
"Zap request 'e' tag is not valid hex."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns an error message if the Zap request does not have a relays tag', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['amount', '100']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
|
||||||
"Zap request doesn't have a 'relays' tag."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns null for a valid Zap request', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('makeZapReceipt', () => {
|
|
||||||
test('returns a valid Zap receipt with a preimage', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = JSON.stringify(
|
|
||||||
finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const preimage = 'preimage'
|
|
||||||
const bolt11 = 'bolt11'
|
|
||||||
const paidAt = new Date()
|
|
||||||
|
|
||||||
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
|
||||||
|
|
||||||
expect(result.kind).toBe(9735)
|
|
||||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
|
||||||
expect(result.content).toBe('')
|
|
||||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
|
||||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
|
||||||
expect(result.tags).toContainEqual(['p', publicKey])
|
|
||||||
expect(result.tags).toContainEqual(['preimage', preimage])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns a valid Zap receipt without a preimage', () => {
|
|
||||||
const privateKey = generatePrivateKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = JSON.stringify(
|
|
||||||
finishEvent(
|
|
||||||
{
|
|
||||||
kind: 9734,
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
content: 'content',
|
|
||||||
tags: [
|
|
||||||
['p', publicKey],
|
|
||||||
['amount', '100'],
|
|
||||||
['relays', 'relay1', 'relay2']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const bolt11 = 'bolt11'
|
|
||||||
const paidAt = new Date()
|
|
||||||
|
|
||||||
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
|
|
||||||
|
|
||||||
expect(result.kind).toBe(9735)
|
|
||||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
|
||||||
expect(result.content).toBe('')
|
|
||||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
|
||||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
|
||||||
expect(result.tags).toContainEqual(['p', publicKey])
|
|
||||||
expect(result.tags).not.toContain('preimage')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
349
nip57.test.ts
Normal file
349
nip57.test.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { describe, test, expect, mock } from 'bun:test'
|
||||||
|
import { finalizeEvent } from './pure.ts'
|
||||||
|
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import {
|
||||||
|
getSatoshisAmountFromBolt11,
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses the amount from bolt11 invoices', () => {
|
||||||
|
expect(
|
||||||
|
getSatoshisAmountFromBolt11(
|
||||||
|
'lnbc4u1p5zcarnpp5djng98r73nxu66nxp6gndjkw24q7rdzgp7p80lt0gk4z3h3krkssdq9tfpygcqzzsxqzjcsp58hz3v5qefdm70g5fnm2cn6q9thzpu6m4f5wjqurhur5xzmf9vl3s9qxpqysgq9v6qv86xaruzeak9jjyz54fygrkn526z7xhm0llh8wl44gcgh0rznhjqdswd4cjurzdgh0pgzrfj4sd7f3mf89jd6kadse008ex7kxgqqa5xrk',
|
||||||
|
),
|
||||||
|
).toEqual(400)
|
||||||
|
expect(
|
||||||
|
getSatoshisAmountFromBolt11(
|
||||||
|
'lnbc8400u1p5zcaz5pp5ltvyhtg4ed7sd8jurj28ugmavezkmqsadpe3t9npufpcrd0uet0scqzyssp5l3hz4ayt5ee0p83ma4a96l2rruhx33eyycewldu2ffa5pk2qx7jq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdq8w3jhxaqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll8qkt3np4rqyqqqqlgqqqqqeqqjqhuhjk5u9r850ncxngne7cfp9s08s2nm6c2rkz7jhl8gjmlx0fga5tlncgeuh4avlsrkq6ljyyhgq8rrxprga03esqhd0gf5455x6tdcqahhw9q',
|
||||||
|
),
|
||||||
|
).toEqual(840000)
|
||||||
|
expect(
|
||||||
|
getSatoshisAmountFromBolt11(
|
||||||
|
'lnbc210n1p5zcuaxpp52nn778cfk46md4ld0hdj2juuzvfrsrdaf4ek2k0yeensae07x2cqdq9tfpygcqzzsxqzjcsp5768c4k79jtnq92pgppan8rjnujcpcqhnqwqwk3lm5dfr7e0k2a7s9qxpqysgqt8lnh9l7ple27t73x7gty570ltas2s33uahc7egke5tdmhxr3ezn590wf2utxyt7d3afnk2lxc2u0enc6n53ck4mxwpmzpxa7ws05aqp0c5x3r',
|
||||||
|
),
|
||||||
|
).toEqual(21)
|
||||||
|
expect(
|
||||||
|
getSatoshisAmountFromBolt11(
|
||||||
|
'lnbc899640n1p5zcuavpp5w72fqrf09286lq33vw364qryrq5nw60z4dhdx56f8w05xkx4massdq9tfpygcqzzsxqzjcsp5qrqn4kpvem5jwpl63kj5pfdlqxg2plaffz0prz7vaqjy29uc66us9qxpqysgqlhzzqmn2jxd2476404krm8nvrarymwq7nj2zecl92xug54ek0mfntdxvxwslf756m8kq0r7jtpantm52fmewc72r5lfmd85505jnemgqw5j0pc',
|
||||||
|
),
|
||||||
|
).toEqual(89964)
|
||||||
|
})
|
||||||
115
nip57.ts
115
nip57.ts
@@ -1,7 +1,8 @@
|
|||||||
import {bech32} from '@scure/base'
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
|
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||||
import {utf8Decoder} from './utils'
|
import { utf8Decoder } from './utils.ts'
|
||||||
|
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
|
||||||
|
|
||||||
var _fetch: any
|
var _fetch: any
|
||||||
|
|
||||||
@@ -16,14 +17,14 @@ export function useFetchImplementation(fetchImplementation: any) {
|
|||||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||||
try {
|
try {
|
||||||
let lnurl: string = ''
|
let lnurl: string = ''
|
||||||
let {lud06, lud16} = JSON.parse(metadata.content)
|
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||||
if (lud06) {
|
if (lud06) {
|
||||||
let {words} = bech32.decode(lud06, 1000)
|
let { words } = bech32.decode(lud06, 1000)
|
||||||
let data = bech32.fromWords(words)
|
let data = bech32.fromWords(words)
|
||||||
lnurl = utf8Decoder.decode(data)
|
lnurl = utf8Decoder.decode(data)
|
||||||
} else if (lud16) {
|
} else if (lud16) {
|
||||||
let [name, domain] = lud16.split('@')
|
let [name, domain] = lud16.split('@')
|
||||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -46,10 +47,10 @@ export function makeZapRequest({
|
|||||||
event,
|
event,
|
||||||
amount,
|
amount,
|
||||||
relays,
|
relays,
|
||||||
comment = ''
|
comment = '',
|
||||||
}: {
|
}: {
|
||||||
profile: string
|
profile: string
|
||||||
event: string | null
|
event: string | Event | null
|
||||||
amount: number
|
amount: number
|
||||||
comment: string
|
comment: string
|
||||||
relays: string[]
|
relays: string[]
|
||||||
@@ -57,20 +58,33 @@ export function makeZapRequest({
|
|||||||
if (!amount) throw new Error('amount not given')
|
if (!amount) throw new Error('amount not given')
|
||||||
if (!profile) throw new Error('profile not given')
|
if (!profile) throw new Error('profile not given')
|
||||||
|
|
||||||
let zr = {
|
let zr: EventTemplate = {
|
||||||
kind: 9734,
|
kind: 9734,
|
||||||
created_at: Math.round(Date.now() / 1000),
|
created_at: Math.round(Date.now() / 1000),
|
||||||
content: comment,
|
content: comment,
|
||||||
tags: [
|
tags: [
|
||||||
['p', profile],
|
['p', profile],
|
||||||
['amount', amount.toString()],
|
['amount', amount.toString()],
|
||||||
['relays', ...relays]
|
['relays', ...relays],
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event) {
|
if (event && typeof event === 'string') {
|
||||||
zr.tags.push(['e', event])
|
zr.tags.push(['e', event])
|
||||||
}
|
}
|
||||||
|
if (event && typeof event === 'object') {
|
||||||
|
// replacable event
|
||||||
|
if (isReplaceableKind(event.kind)) {
|
||||||
|
const a = ['a', `${event.kind}:${event.pubkey}:`]
|
||||||
|
zr.tags.push(a)
|
||||||
|
// addressable event
|
||||||
|
} else if (isAddressableKind(event.kind)) {
|
||||||
|
let d = event.tags.find(([t, v]) => t === 'd' && v)
|
||||||
|
if (!d) throw new Error('d tag not found or is empty')
|
||||||
|
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
|
||||||
|
zr.tags.push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return zr
|
return zr
|
||||||
}
|
}
|
||||||
@@ -84,18 +98,16 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
|||||||
return 'Invalid zap request JSON.'
|
return 'Invalid zap request JSON.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateEvent(zapRequest))
|
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||||
return 'Zap request is not a valid Nostr event.'
|
|
||||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
|
||||||
|
|
||||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||||
if (!p) return "Zap request doesn't have a 'p' tag."
|
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||||
if (!p[1].match(/^[a-f0-9]{64}$/))
|
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||||
return "Zap request 'p' tag is not valid hex."
|
|
||||||
|
|
||||||
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||||
if (e && !e[1].match(/^[a-f0-9]{64}$/))
|
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||||
return "Zap request 'e' tag is not valid hex."
|
|
||||||
|
|
||||||
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||||
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||||
@@ -107,27 +119,21 @@ export function makeZapReceipt({
|
|||||||
zapRequest,
|
zapRequest,
|
||||||
preimage,
|
preimage,
|
||||||
bolt11,
|
bolt11,
|
||||||
paidAt
|
paidAt,
|
||||||
}: {
|
}: {
|
||||||
zapRequest: string
|
zapRequest: string
|
||||||
preimage: string | null
|
preimage?: string
|
||||||
bolt11: string
|
bolt11: string
|
||||||
paidAt: Date
|
paidAt: Date
|
||||||
}): EventTemplate {
|
}): EventTemplate {
|
||||||
let zr: Event = JSON.parse(zapRequest)
|
let zr: Event = JSON.parse(zapRequest)
|
||||||
let tagsFromZapRequest = zr.tags.filter(
|
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||||
([t]) => t === 'e' || t === 'p' || t === 'a'
|
|
||||||
)
|
|
||||||
|
|
||||||
let zap = {
|
let zap: EventTemplate = {
|
||||||
kind: 9735,
|
kind: 9735,
|
||||||
created_at: Math.round(paidAt.getTime() / 1000),
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
...tagsFromZapRequest,
|
|
||||||
['bolt11', bolt11],
|
|
||||||
['description', zapRequest]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preimage) {
|
if (preimage) {
|
||||||
@@ -136,3 +142,52 @@ export function makeZapReceipt({
|
|||||||
|
|
||||||
return zap
|
return zap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSatoshisAmountFromBolt11(bolt11: string): number {
|
||||||
|
if (bolt11.length < 50) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
bolt11 = bolt11.substring(0, 50)
|
||||||
|
const idx = bolt11.lastIndexOf('1')
|
||||||
|
if (idx === -1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const hrp = bolt11.substring(0, idx)
|
||||||
|
if (!hrp.startsWith('lnbc')) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const amount = hrp.substring(4) // equivalent to strings.CutPrefix
|
||||||
|
|
||||||
|
if (amount.length < 1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// if last character is a digit, then the amount can just be interpreted as BTC
|
||||||
|
const char = amount[amount.length - 1]
|
||||||
|
const digit = char.charCodeAt(0) - '0'.charCodeAt(0)
|
||||||
|
const isDigit = digit >= 0 && digit <= 9
|
||||||
|
|
||||||
|
let cutPoint = amount.length - 1
|
||||||
|
if (isDigit) {
|
||||||
|
cutPoint++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutPoint < 1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseInt(amount.substring(0, cutPoint))
|
||||||
|
|
||||||
|
switch (char) {
|
||||||
|
case 'm':
|
||||||
|
return num * 100000
|
||||||
|
case 'u':
|
||||||
|
return num * 100
|
||||||
|
case 'n':
|
||||||
|
return num / 10
|
||||||
|
case 'p':
|
||||||
|
return num / 10000
|
||||||
|
default:
|
||||||
|
return num * 100000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
357
nip58.test.ts
Normal file
357
nip58.test.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { EventTemplate } from './core.ts'
|
||||||
|
import {
|
||||||
|
BadgeAward as BadgeAwardKind,
|
||||||
|
BadgeDefinition as BadgeDefinitionKind,
|
||||||
|
ProfileBadges as ProfileBadgesKind,
|
||||||
|
} from './kinds.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadgeAward,
|
||||||
|
BadgeDefinition,
|
||||||
|
ProfileBadges,
|
||||||
|
generateBadgeAwardEventTemplate,
|
||||||
|
generateBadgeDefinitionEventTemplate,
|
||||||
|
generateProfileBadgesEventTemplate,
|
||||||
|
validateBadgeAwardEvent,
|
||||||
|
validateBadgeDefinitionEvent,
|
||||||
|
validateProfileBadgesEvent,
|
||||||
|
} from './nip58.ts'
|
||||||
|
|
||||||
|
test('BadgeDefinition has required property "d"', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
}
|
||||||
|
expect(badge.d).toEqual('badge-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeDefinition has optional property "name"', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
name: 'Badge Name',
|
||||||
|
}
|
||||||
|
expect(badge.name).toEqual('Badge Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeDefinition has optional property "description"', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
description: 'Badge Description',
|
||||||
|
}
|
||||||
|
expect(badge.description).toEqual('Badge Description')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeDefinition has optional property "image"', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
image: ['https://example.com/badge.png', '1024x1024'],
|
||||||
|
}
|
||||||
|
expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeDefinition has optional property "thumbs"', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
thumbs: [
|
||||||
|
['https://example.com/thumb.png', '100x100'],
|
||||||
|
['https://example.com/thumb2.png', '200x200'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(badge.thumbs).toEqual([
|
||||||
|
['https://example.com/thumb.png', '100x100'],
|
||||||
|
['https://example.com/thumb2.png', '200x200'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeAward has required property "a"', () => {
|
||||||
|
const badgeAward: BadgeAward = {
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
p: [
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(badgeAward.a).toEqual('badge-definition-address')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BadgeAward has required property "p"', () => {
|
||||||
|
const badgeAward: BadgeAward = {
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
p: [
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(badgeAward.p).toEqual([
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ProfileBadges has required property "d"', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [],
|
||||||
|
}
|
||||||
|
expect(profileBadges.d).toEqual('profile_badges')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ProfileBadges has required property "badges"', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [],
|
||||||
|
}
|
||||||
|
expect(profileBadges.badges).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [
|
||||||
|
{
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
e: ['badge-award-event-id'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(profileBadges.badges[0].a).toEqual('badge-definition-address')
|
||||||
|
expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||||
|
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
name: 'Badge Name',
|
||||||
|
description: 'Badge Description',
|
||||||
|
image: ['https://example.com/badge.png', '1024x1024'],
|
||||||
|
thumbs: [
|
||||||
|
['https://example.com/thumb.png', '100x100'],
|
||||||
|
['https://example.com/thumb2.png', '200x200'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['d', 'badge-id'],
|
||||||
|
['name', 'Badge Name'],
|
||||||
|
['description', 'Badge Description'],
|
||||||
|
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||||
|
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||||
|
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => {
|
||||||
|
const badge: BadgeDefinition = {
|
||||||
|
d: 'badge-id',
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||||
|
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeDefinitionKind,
|
||||||
|
tags: [
|
||||||
|
['d', 'badge-id'],
|
||||||
|
['name', 'Badge Name'],
|
||||||
|
['description', 'Badge Description'],
|
||||||
|
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||||
|
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||||
|
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateBadgeDefinitionEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeDefinitionKind,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateBadgeDefinitionEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||||
|
const badgeAward: BadgeAward = {
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
p: [
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['p', 'pubkey1', 'relay1'],
|
||||||
|
['p', 'pubkey2', 'relay2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => {
|
||||||
|
const badgeAward: BadgeAward = {
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
p: [
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['p', 'pubkey1', 'relay1'],
|
||||||
|
['p', 'pubkey2', 'relay2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => {
|
||||||
|
const badgeAward: BadgeAward = {
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
p: [
|
||||||
|
['pubkey1', 'relay1'],
|
||||||
|
['pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['p', 'pubkey1', 'relay1'],
|
||||||
|
['p', 'pubkey2', 'relay2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeAwardKind,
|
||||||
|
tags: [
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['p', 'pubkey1', 'relay1'],
|
||||||
|
['p', 'pubkey2', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateBadgeAwardEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeAwardKind,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateBadgeAwardEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||||
|
expect(eventTemplate.tags).toEqual([['d', 'profile_badges']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [
|
||||||
|
{
|
||||||
|
a: 'badge-definition-address',
|
||||||
|
e: ['badge-award-event-id'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['d', 'profile_badges'],
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['e', 'badge-award-event-id'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => {
|
||||||
|
const profileBadges: ProfileBadges = {
|
||||||
|
d: 'profile_badges',
|
||||||
|
badges: [
|
||||||
|
{
|
||||||
|
a: 'badge-definition-address1',
|
||||||
|
e: ['badge-award-event-id1', 'badge-award-event-id2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
a: 'badge-definition-address2',
|
||||||
|
e: ['badge-award-event-id3'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['d', 'profile_badges'],
|
||||||
|
['a', 'badge-definition-address1'],
|
||||||
|
['e', 'badge-award-event-id1', 'badge-award-event-id2'],
|
||||||
|
['a', 'badge-definition-address2'],
|
||||||
|
['e', 'badge-award-event-id3'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ProfileBadgesKind,
|
||||||
|
tags: [
|
||||||
|
['d', 'profile_badges'],
|
||||||
|
['a', 'badge-definition-address'],
|
||||||
|
['e', 'badge-award-event-id'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateProfileBadgesEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ProfileBadgesKind,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateProfileBadgesEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
245
nip58.ts
Normal file
245
nip58.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import {
|
||||||
|
BadgeAward as BadgeAwardKind,
|
||||||
|
BadgeDefinition as BadgeDefinitionKind,
|
||||||
|
ProfileBadges as ProfileBadgesKind,
|
||||||
|
} from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the structure for defining a badge within the Nostr network.
|
||||||
|
* This structure is used to create templates for badge definition events,
|
||||||
|
* facilitating the recognition and awarding of badges to users for various achievements.
|
||||||
|
*/
|
||||||
|
export type BadgeDefinition = {
|
||||||
|
/**
|
||||||
|
* A unique identifier for the badge. This is used to distinguish badges
|
||||||
|
* from one another and should be unique across all badge definitions.
|
||||||
|
* Typically, this could be a short, descriptive string.
|
||||||
|
*/
|
||||||
|
d: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional short name for the badge. This provides a human-readable
|
||||||
|
* title for the badge, making it easier to recognize and refer to.
|
||||||
|
*/
|
||||||
|
name?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional description for the badge. This field can be used to
|
||||||
|
* provide more detailed information about the badge, such as the criteria
|
||||||
|
* for its awarding or its significance.
|
||||||
|
*/
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional image URL and dimensions for the badge. The first element
|
||||||
|
* of the tuple is the URL pointing to a high-resolution image representing
|
||||||
|
* the badge, and the second element specifies the image's dimensions in
|
||||||
|
* the format "widthxheight". The recommended dimensions are 1024x1024 pixels.
|
||||||
|
*/
|
||||||
|
image?: [string, string]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional list of thumbnail images for the badge. Each element in the
|
||||||
|
* array is a tuple, where the first element is the URL pointing to a thumbnail
|
||||||
|
* version of the badge image, and the second element specifies the thumbnail's
|
||||||
|
* dimensions in the format "widthxheight". Multiple thumbnails can be provided
|
||||||
|
* to support different display sizes.
|
||||||
|
*/
|
||||||
|
thumbs?: Array<[string, string]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the structure for awarding a badge to one or more recipients
|
||||||
|
* within the Nostr network. This structure is used to create templates for
|
||||||
|
* badge award events, which are immutable and signify the recognition of
|
||||||
|
* individuals' achievements or contributions.
|
||||||
|
*/
|
||||||
|
export type BadgeAward = {
|
||||||
|
/**
|
||||||
|
* A reference to the Badge Definition event. This is typically composed
|
||||||
|
* of the event ID of the badge definition. It establishes a clear linkage
|
||||||
|
* between the badge being awarded and its original definition, ensuring
|
||||||
|
* that recipients are awarded the correct badge.
|
||||||
|
*/
|
||||||
|
a: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An array of p tags, each containing a pubkey and its associated relays.
|
||||||
|
*/
|
||||||
|
p: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the collection of badges a user chooses to display on their profile.
|
||||||
|
* This structure is crucial for applications that allow users to showcase achievements
|
||||||
|
* or recognitions in the form of badges, following the specifications of NIP-58.
|
||||||
|
*/
|
||||||
|
export type ProfileBadges = {
|
||||||
|
/**
|
||||||
|
* A unique identifier for the profile badges collection. According to NIP-58,
|
||||||
|
* this should be set to "profile_badges" to differentiate it from other event types.
|
||||||
|
*/
|
||||||
|
d: 'profile_badges'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of badges that the user has elected to display on their profile. Each item
|
||||||
|
* in the array represents a specific badge, including references to both its definition
|
||||||
|
* and the award event.
|
||||||
|
*/
|
||||||
|
badges: Array<{
|
||||||
|
/**
|
||||||
|
* The event address of the badge definition. This is a reference to the specific badge
|
||||||
|
* being displayed, linking back to the badge's original definition event. It allows
|
||||||
|
* clients to fetch and display the badge's details, such as its name, description,
|
||||||
|
* and image.
|
||||||
|
*/
|
||||||
|
a: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event id of the badge award with corresponding relays. This references the event
|
||||||
|
* in which the badge was awarded to the user. It is crucial for verifying the
|
||||||
|
* authenticity of the badge display, ensuring that the user was indeed awarded the
|
||||||
|
* badge they are choosing to display.
|
||||||
|
*/
|
||||||
|
e: string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an EventTemplate based on the provided BadgeDefinition.
|
||||||
|
*
|
||||||
|
* @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object.
|
||||||
|
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||||
|
*/
|
||||||
|
export function generateBadgeDefinitionEventTemplate({
|
||||||
|
d,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
name,
|
||||||
|
thumbs,
|
||||||
|
}: BadgeDefinition): EventTemplate {
|
||||||
|
// Mandatory tags
|
||||||
|
const tags: string[][] = [['d', d]]
|
||||||
|
|
||||||
|
// Append optional tags
|
||||||
|
name && tags.push(['name', name])
|
||||||
|
description && tags.push(['description', description])
|
||||||
|
image && tags.push(['image', ...image])
|
||||||
|
if (thumbs) {
|
||||||
|
for (const thumb of thumbs) {
|
||||||
|
tags.push(['thumb', ...thumb])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the EventTemplate object
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeDefinitionKind,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a badge definition event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event is a valid badge definition event.
|
||||||
|
*/
|
||||||
|
export function validateBadgeDefinitionEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== BadgeDefinitionKind) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an EventTemplate based on the provided BadgeAward.
|
||||||
|
*
|
||||||
|
* @param {BadgeAward} badgeAward - The BadgeAward object.
|
||||||
|
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||||
|
*/
|
||||||
|
export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate {
|
||||||
|
// Mandatory tags
|
||||||
|
const tags: string[][] = [['a', a]]
|
||||||
|
for (const _p of p) {
|
||||||
|
tags.push(['p', ..._p])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the EventTemplate object
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: BadgeAwardKind,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a badge award event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event is a valid badge award event.
|
||||||
|
*/
|
||||||
|
export function validateBadgeAwardEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== BadgeAwardKind) return false
|
||||||
|
|
||||||
|
const requiredTags = ['a', 'p'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an EventTemplate based on the provided ProfileBadges.
|
||||||
|
*
|
||||||
|
* @param {ProfileBadges} profileBadges - The ProfileBadges object.
|
||||||
|
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||||
|
*/
|
||||||
|
export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate {
|
||||||
|
// Mandatory tags
|
||||||
|
const tags: string[][] = [['d', 'profile_badges']]
|
||||||
|
|
||||||
|
// Append optional tags
|
||||||
|
for (const badge of badges) {
|
||||||
|
tags.push(['a', badge.a], ['e', ...badge.e])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the EventTemplate object
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ProfileBadgesKind,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a profile badges event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event is a valid profile badges event.
|
||||||
|
*/
|
||||||
|
export function validateProfileBadgesEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== ProfileBadgesKind) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
113
nip59.test.ts
Normal file
113
nip59.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
|
||||||
|
import { decode } from './nip19.ts'
|
||||||
|
import { NostrEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { GiftWrap } from './kinds.ts'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
|
||||||
|
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
|
||||||
|
const recipientPublicKey = getPublicKey(recipientPrivateKey)
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
content: 'Are you going to the party tonight?',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||||
|
|
||||||
|
test('wrapEvent', () => {
|
||||||
|
const expected = {
|
||||||
|
content: '',
|
||||||
|
id: '',
|
||||||
|
created_at: 1728537932,
|
||||||
|
kind: 1059,
|
||||||
|
pubkey: '',
|
||||||
|
sig: '',
|
||||||
|
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
}
|
||||||
|
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||||
|
|
||||||
|
expect(result.kind).toEqual(expected.kind)
|
||||||
|
expect(result.tags).toEqual(expected.tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wrapManyEvent', () => {
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
kind: 1059,
|
||||||
|
content: '',
|
||||||
|
created_at: 1729581521,
|
||||||
|
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||||
|
pubkey: '',
|
||||||
|
id: '',
|
||||||
|
sig: '',
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 1059,
|
||||||
|
content: '',
|
||||||
|
created_at: 1729594619,
|
||||||
|
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||||
|
pubkey: '',
|
||||||
|
id: '',
|
||||||
|
sig: '',
|
||||||
|
[Symbol('verified')]: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
|
||||||
|
|
||||||
|
wrappedEvents.forEach((event, index) => {
|
||||||
|
expect(event.kind).toEqual(expected[index].kind)
|
||||||
|
expect(event.tags).toEqual(expected[index].tags)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unwrapEvent', () => {
|
||||||
|
const expected = {
|
||||||
|
kind: 1,
|
||||||
|
content: 'Are you going to the party tonight?',
|
||||||
|
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
|
||||||
|
|
||||||
|
expect(result.kind).toEqual(expected.kind)
|
||||||
|
expect(result.content).toEqual(expected.content)
|
||||||
|
expect(result.pubkey).toEqual(expected.pubkey)
|
||||||
|
expect(result.tags).toEqual(expected.tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getWrappedEvents and unwrapManyEvents', async () => {
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
created_at: 1729721879,
|
||||||
|
content: 'Hello!',
|
||||||
|
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||||
|
kind: 14,
|
||||||
|
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||||
|
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created_at: 1729722025,
|
||||||
|
content: 'How are you?',
|
||||||
|
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||||
|
kind: 14,
|
||||||
|
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||||
|
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
|
||||||
|
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
|
||||||
|
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
|
||||||
|
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
|
||||||
|
|
||||||
|
unwrappedEvents.forEach((event, index) => {
|
||||||
|
expect(event).toEqual(expected[index])
|
||||||
|
})
|
||||||
|
})
|
||||||
107
nip59.ts
Normal file
107
nip59.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
|
||||||
|
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||||
|
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { Seal, GiftWrap } from './kinds.ts'
|
||||||
|
|
||||||
|
type Rumor = UnsignedEvent & { id: string }
|
||||||
|
|
||||||
|
const TWO_DAYS = 2 * 24 * 60 * 60
|
||||||
|
|
||||||
|
const now = () => Math.round(Date.now() / 1000)
|
||||||
|
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
|
||||||
|
|
||||||
|
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
|
||||||
|
|
||||||
|
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
|
||||||
|
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
|
||||||
|
|
||||||
|
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
|
||||||
|
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
|
||||||
|
|
||||||
|
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
|
||||||
|
const rumor = {
|
||||||
|
created_at: now(),
|
||||||
|
content: '',
|
||||||
|
tags: [],
|
||||||
|
...event,
|
||||||
|
pubkey: getPublicKey(privateKey),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
rumor.id = getEventHash(rumor)
|
||||||
|
|
||||||
|
return rumor as Rumor
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: Seal,
|
||||||
|
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
|
||||||
|
created_at: randomNow(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
|
||||||
|
const randomKey = generateSecretKey()
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: GiftWrap,
|
||||||
|
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
|
||||||
|
created_at: randomNow(),
|
||||||
|
tags: [['p', recipientPublicKey]],
|
||||||
|
},
|
||||||
|
randomKey,
|
||||||
|
) as NostrEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapEvent(
|
||||||
|
event: Partial<UnsignedEvent>,
|
||||||
|
senderPrivateKey: Uint8Array,
|
||||||
|
recipientPublicKey: string,
|
||||||
|
): NostrEvent {
|
||||||
|
const rumor = createRumor(event, senderPrivateKey)
|
||||||
|
|
||||||
|
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
|
||||||
|
return createWrap(seal, recipientPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapManyEvents(
|
||||||
|
event: Partial<UnsignedEvent>,
|
||||||
|
senderPrivateKey: Uint8Array,
|
||||||
|
recipientsPublicKeys: string[],
|
||||||
|
): NostrEvent[] {
|
||||||
|
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
|
||||||
|
throw new Error('At least one recipient is required.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||||
|
|
||||||
|
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
|
||||||
|
|
||||||
|
recipientsPublicKeys.forEach(recipientPublicKey => {
|
||||||
|
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
return wrappeds
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
|
||||||
|
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
|
||||||
|
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
|
||||||
|
let unwrappedEvents: Rumor[] = []
|
||||||
|
|
||||||
|
wrappedEvents.forEach(e => {
|
||||||
|
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
|
||||||
|
|
||||||
|
return unwrappedEvents
|
||||||
|
}
|
||||||
203
nip75.test.ts
Normal file
203
nip75.test.ts
Normal 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
115
nip75.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import { ZapGoal } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
390
nip94.test.ts
Normal file
390
nip94.test.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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',
|
||||||
|
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||||
|
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||||
|
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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'],
|
||||||
|
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||||
|
})
|
||||||
|
})
|
||||||
211
nip94.ts
Normal file
211
nip94.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: fallback URLs in case url fails.
|
||||||
|
*/
|
||||||
|
fallback?: 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])
|
||||||
|
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
|
||||||
|
|
||||||
|
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
|
||||||
|
case 'fallback':
|
||||||
|
fileMetadata.fallback ??= []
|
||||||
|
fileMetadata.fallback.push(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMetadata
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user