mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
443 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bf7e00d32a | ||
|
|
9241089997 | ||
|
|
32c47e9bd8 | ||
|
|
6e58fe371c | ||
|
|
26e35d50e0 | ||
|
|
ef3184a6e0 | ||
|
|
56fe3dd5dd | ||
|
|
f1bb5030c8 | ||
|
|
ac212cb5c8 | ||
|
|
204ae0eff1 | ||
|
|
f17ab41d72 | ||
|
|
f6f5ee8223 | ||
|
|
a05506468d | ||
|
|
674ff66b6f | ||
|
|
731705047a | ||
|
|
94b382a49f | ||
|
|
199411a971 | ||
|
|
a1dc6f41b9 | ||
|
|
5b59b93d86 | ||
|
|
12acd7bdca | ||
|
|
3bdb68020d | ||
|
|
b0a58e2ca4 | ||
|
|
b063be76ae | ||
|
|
e3cea5db16 | ||
|
|
9ee58bd6c7 | ||
|
|
f1eb9a3bc7 | ||
|
|
ce081bb4cb | ||
|
|
7413072e9f | ||
|
|
4c464b39cf | ||
|
|
11ef43abdc | ||
|
|
3e67f9b014 | ||
|
|
0933fba6d5 | ||
|
|
51b8f42529 | ||
|
|
24d885aaeb | ||
|
|
74c77a2e9f | ||
|
|
ce73b96565 | ||
|
|
8818e4f88a | ||
|
|
5a63c75f24 | ||
|
|
60e01a9006 | ||
|
|
687f387385 | ||
|
|
6d116a2f7f | ||
|
|
51c3aec788 | ||
|
|
613b2c177f | ||
|
|
24f5068fdb | ||
|
|
5733f9c4e4 | ||
|
|
6b73bbf8a3 | ||
|
|
d244b62c7a | ||
|
|
b00af9a30a | ||
|
|
be7c981c14 | ||
|
|
5539e5cf89 | ||
|
|
73decbc8e0 | ||
|
|
b3d95cecdd | ||
|
|
82228036ef | ||
|
|
01435ab9f5 | ||
|
|
63cbc4133a | ||
|
|
049f183d27 | ||
|
|
f9e3119ab4 | ||
|
|
f992c9c967 | ||
|
|
dbf625d6ac | ||
|
|
8622bd11dd | ||
|
|
0970eee70f | ||
|
|
086f8830e3 | ||
|
|
e48d722227 | ||
|
|
0d77013aab | ||
|
|
4c415280aa | ||
|
|
4188aaf7c8 | ||
|
|
673f4abab8 | ||
|
|
bcefaa0757 | ||
|
|
649af36a86 | ||
|
|
96a6f7af87 | ||
|
|
a4c713efcb | ||
|
|
9d345a8f01 | ||
|
|
c362212778 | ||
|
|
a8938a3a0f | ||
|
|
a21329da3f | ||
|
|
63f4a49a69 | ||
|
|
27749d91b8 | ||
|
|
9530849f0a | ||
|
|
b8aa75b6e1 | ||
|
|
344762820c | ||
|
|
f43d23d344 | ||
|
|
bf55ad6b5a | ||
|
|
04a46b815c | ||
|
|
165ff44dff | ||
|
|
7bfd23af3c | ||
|
|
3d93ec8446 | ||
|
|
0f841138cd | ||
|
|
336948b1d1 | ||
|
|
d46794c681 | ||
|
|
93cef5d886 | ||
|
|
2324f9548e | ||
|
|
f9748d9cc3 | ||
|
|
3a22dd3da6 | ||
|
|
d13039dc11 | ||
|
|
95b03902cc | ||
|
|
ab5ea8de36 | ||
|
|
a330b97590 | ||
|
|
24406b5679 | ||
|
|
6dbcc87d93 | ||
|
|
0ddcfdce68 | ||
|
|
87bf349ce8 | ||
|
|
54dfc7b972 | ||
|
|
32793146a4 | ||
|
|
c42cd925ce | ||
|
|
43ccb72476 | ||
|
|
b2b7999517 | ||
|
|
a568afc295 | ||
|
|
9bcaed6e60 | ||
|
|
5a9cbbb557 | ||
|
|
e9acc59809 | ||
|
|
18fe9637b9 | ||
|
|
ff3bf4a51c | ||
|
|
7ff97b5488 | ||
|
|
df169ea42b | ||
|
|
341f2bcb8d | ||
|
|
b2d1dd2110 | ||
|
|
75d7be5a54 | ||
|
|
b5c8255b2f | ||
|
|
4485c8ed5e | ||
|
|
3710866430 | ||
|
|
da59e3ce90 | ||
|
|
cc8e34163d | ||
|
|
9082953ede | ||
|
|
61f397463d | ||
|
|
312b6fd035 | ||
|
|
7f1bd4f4a8 | ||
|
|
26089ef958 | ||
|
|
2e305b7cd4 | ||
|
|
51c1a54ddf | ||
|
|
cb05ee188f | ||
|
|
fa9e169c46 | ||
|
|
bb1e3f2fa6 | ||
|
|
160987472f | ||
|
|
8b18341ebb | ||
|
|
901445dea1 | ||
|
|
91b67cd0d5 | ||
|
|
1e696e0f3b | ||
|
|
4b36848b2d | ||
|
|
3cb351a5f4 | ||
|
|
5db1934fa4 | ||
|
|
50c3f24b25 | ||
|
|
39ea47660d | ||
|
|
8071e2f4fa | ||
|
|
cc2250da1f | ||
|
|
c37d10bb9d | ||
|
|
97e28fdf9a | ||
|
|
87c0f0d061 | ||
|
|
83c397b839 | ||
|
|
cd7d1cec48 | ||
|
|
613a843838 | ||
|
|
74a0d5454a | ||
|
|
c0d1e41424 | ||
|
|
f7e510e1c8 | ||
|
|
c08bdac7a7 | ||
|
|
c5b64404f6 | ||
|
|
c7b26fdba2 | ||
|
|
ac698ef67d | ||
|
|
8262a81cb2 | ||
|
|
26e6da6ba3 | ||
|
|
8aa31bb437 | ||
|
|
4bd4469357 | ||
|
|
89ae21f796 | ||
|
|
41a1614d89 | ||
|
|
0500415a4e | ||
|
|
cee4357cab | ||
|
|
d5cf5930d1 | ||
|
|
a78e2036aa | ||
|
|
adc1854ac6 | ||
|
|
83148e8bdf | ||
|
|
364c37cac5 | ||
|
|
385cdb4ac6 | ||
|
|
3f1025f551 | ||
|
|
482c5affd4 | ||
|
|
679ac0c133 | ||
|
|
b96159ad36 | ||
|
|
6dede4a688 | ||
|
|
50c8bb72f9 | ||
|
|
72781e0eab | ||
|
|
bf120c1348 | ||
|
|
3630d377e5 | ||
|
|
53b0091bf4 | ||
|
|
1a7cc5f21f | ||
|
|
1162935f58 | ||
|
|
a49d971f6a | ||
|
|
897919be3b | ||
|
|
39aca167fb | ||
|
|
de8bdd8370 | ||
|
|
46a0a342db | ||
|
|
4fe2a9c91a | ||
|
|
e62b833464 | ||
|
|
100c77d2aa | ||
|
|
12be5a5338 | ||
|
|
b955ba2a09 | ||
|
|
ec805be4ab | ||
|
|
92fb339afb | ||
|
|
f8f125270a | ||
|
|
1b798b2eee | ||
|
|
ae717a1a4a | ||
|
|
b2015c8fe5 | ||
|
|
c5d2e3b037 | ||
|
|
0ef5d1e19c | ||
|
|
7d9d10fdb1 | ||
|
|
a1e1ce131a | ||
|
|
cdb07bb175 | ||
|
|
1f1bcff803 | ||
|
|
896af30619 | ||
|
|
a8542c4b56 | ||
|
|
9f9e822c6d | ||
|
|
821a8f7895 | ||
|
|
2f7e3f8473 | ||
|
|
536dbcbffe | ||
|
|
ed52d2a8d4 | ||
|
|
faf8e62120 | ||
|
|
dc489bf387 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
|
"extends": ["prettier"],
|
||||||
|
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint", "babel"],
|
||||||
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 9,
|
"ecmaVersion": 9,
|
||||||
"ecmaFeatures": {
|
"ecmaFeatures": {
|
||||||
@@ -14,14 +19,11 @@
|
|||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"plugins": [
|
|
||||||
"babel"
|
|
||||||
],
|
|
||||||
|
|
||||||
"globals": {
|
"globals": {
|
||||||
"document": false,
|
"document": false,
|
||||||
"navigator": false,
|
"navigator": false,
|
||||||
"window": false,
|
"window": false,
|
||||||
|
"crypto": false,
|
||||||
"location": false,
|
"location": false,
|
||||||
"URL": false,
|
"URL": false,
|
||||||
"URLSearchParams": false,
|
"URLSearchParams": false,
|
||||||
@@ -43,9 +45,7 @@
|
|||||||
"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"],
|
"handle-callback-err": [2, "^(err|error)$"],
|
||||||
"generator-star-spacing": [2, { "before": true, "after": true }],
|
|
||||||
"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 }],
|
||||||
@@ -100,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,
|
||||||
@@ -117,7 +116,7 @@
|
|||||||
"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": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
|
"no-unused-vars": [2, { "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,
|
||||||
|
|||||||
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: test every commit
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- uses: extractions/setup-just@v1
|
||||||
|
- run: bun i
|
||||||
|
- run: just test
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- uses: extractions/setup-just@v1
|
||||||
|
- run: bun i
|
||||||
|
- run: just lint
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,3 +2,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
yarn.lock
|
yarn.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.envrc
|
||||||
|
lib
|
||||||
|
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>
|
||||||
330
README.md
330
README.md
@@ -1,72 +1,276 @@
|
|||||||
# nostr-tools
|
#  nostr-tools
|
||||||
|
|
||||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||||
|
|
||||||
## Usage
|
Only depends on _@scure_ and _@noble_ packages.
|
||||||
|
|
||||||
```js
|
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||||
import {relayPool} from 'nostr-tools'
|
|
||||||
|
|
||||||
const pool = relayPool()
|
## Installation
|
||||||
|
|
||||||
pool.setPrivateKey('<hex>') // optional
|
```bash
|
||||||
|
npm install nostr-tools # or yarn add nostr-tools
|
||||||
pool.addRelay('ws://some.relay.com', {read: true, write: true})
|
|
||||||
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
|
|
||||||
|
|
||||||
// example callback function for a subscription
|
|
||||||
function onEvent(event, relay) {
|
|
||||||
console.log(`got an event from ${relay.url} which is already validated.`, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribing to a single user
|
|
||||||
// author is the user's public key
|
|
||||||
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
|
|
||||||
|
|
||||||
// or bulk follow
|
|
||||||
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
|
|
||||||
|
|
||||||
// reuse a subscription channel
|
|
||||||
const mySubscription = pool.sub({cb: ..., filter: ....})
|
|
||||||
mySubscription.sub({filter: ....})
|
|
||||||
mySubscription.sub({cb: ...})
|
|
||||||
mySubscription.unsub()
|
|
||||||
|
|
||||||
// get specific event
|
|
||||||
const specificChannel = pool.sub({
|
|
||||||
cb: (event, relay) => {
|
|
||||||
console.log('got specific event from relay', event, relay)
|
|
||||||
specificChannel.unsub()
|
|
||||||
},
|
|
||||||
filter: {id: '<hex>'}
|
|
||||||
})
|
|
||||||
|
|
||||||
// or get a specific event plus all the events that reference it in the 'e' tag
|
|
||||||
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
|
|
||||||
|
|
||||||
// get all events
|
|
||||||
pool.sub({cb: (event, relay) => {...}, filter: {}})
|
|
||||||
|
|
||||||
// get recent events
|
|
||||||
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
|
|
||||||
|
|
||||||
// publishing events(inside an async function):
|
|
||||||
const ev = await pool.publish(eventObject, (status, url) => {
|
|
||||||
if (status === 0) {
|
|
||||||
console.log(`publish request sent to ${url}`)
|
|
||||||
}
|
|
||||||
if (status === 1) {
|
|
||||||
console.log(`event published by ${url}`, ev)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// it will be signed automatically with the key supplied above
|
|
||||||
// or pass an already signed event to bypass this
|
|
||||||
|
|
||||||
// subscribing to a new relay
|
|
||||||
pool.addRelay('<url>')
|
|
||||||
// will automatically subscribe to the all the events called with .sub above
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
|
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||||
|
|
||||||
For other utils please read the source (for now).
|
## Usage
|
||||||
|
|
||||||
|
### Generating a private key and a public key
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
|
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||||
|
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating, signing and verifying events
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
let event = finalizeEvent({
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello',
|
||||||
|
}, sk)
|
||||||
|
|
||||||
|
let isGood = verifyEvent(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interacting with a relay
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
|
const relay = await Relay.connect('wss://relay.example.com')
|
||||||
|
console.log(`connected to ${relay.url}`)
|
||||||
|
|
||||||
|
// let's query for an event that exists
|
||||||
|
const sub = relay.subscribe([
|
||||||
|
{
|
||||||
|
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
onevent(event) {
|
||||||
|
console.log('we got the event we wanted:', event)
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// let's publish a new event while simultaneously monitoring the relay for it
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
relay.sub([
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pk],
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
onevent(event) {
|
||||||
|
console.log('got event:', event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let eventTemplate = {
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello world',
|
||||||
|
}
|
||||||
|
|
||||||
|
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||||
|
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||||
|
await relay.publish(signedEvent)
|
||||||
|
|
||||||
|
relay.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import 'websocket-polyfill'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interacting with multiple relays
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SimplePool } from 'nostr-tools'
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
|
||||||
|
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||||
|
|
||||||
|
let h = pool.subscribeMany(
|
||||||
|
[...relays, 'wss://relay.example3.com'],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
// this will only be called once the first time the event is received
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
h.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relays, newEvent))
|
||||||
|
console.log('published to at least one relay!')
|
||||||
|
|
||||||
|
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||||
|
let event = await pool.get(relays, {
|
||||||
|
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseReferences } from 'nostr-tools'
|
||||||
|
|
||||||
|
let references = parseReferences(event)
|
||||||
|
let simpleAugmentedContent = event.content
|
||||||
|
for (let i = 0; i < references.length; i++) {
|
||||||
|
let { text, profile, event, address } = references[i]
|
||||||
|
let augmentedReference = profile
|
||||||
|
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||||
|
: event
|
||||||
|
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
||||||
|
: address
|
||||||
|
? `<a href="${text}">[link]</a>`
|
||||||
|
: text
|
||||||
|
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying profile data from a NIP-05 address
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { nip05 } from 'nostr-tools'
|
||||||
|
|
||||||
|
let profile = await nip05.queryProfile('jb55.com')
|
||||||
|
console.log(profile.pubkey)
|
||||||
|
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||||
|
console.log(profile.relays)
|
||||||
|
// prints: [wss://relay.damus.io]
|
||||||
|
```
|
||||||
|
|
||||||
|
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
nip05.useFetchImplementation(require('node-fetch'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encoding and decoding NIP-19 codes
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let nsec = nip19.nsecEncode(sk)
|
||||||
|
let { type, data } = nip19.decode(nsec)
|
||||||
|
assert(type === 'nsec')
|
||||||
|
assert(data === sk)
|
||||||
|
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let npub = nip19.npubEncode(pk)
|
||||||
|
let { type, data } = nip19.decode(npub)
|
||||||
|
assert(type === 'npub')
|
||||||
|
assert(data === pk)
|
||||||
|
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||||
|
let { type, data } = nip19.decode(nprofile)
|
||||||
|
assert(type === 'nprofile')
|
||||||
|
assert(data.pubkey === pk)
|
||||||
|
assert(data.relays.length === 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import modes
|
||||||
|
|
||||||
|
### Using just the packages you want
|
||||||
|
|
||||||
|
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||||
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
|
import { Relay, Subscription } from 'nostr-tools/relay'
|
||||||
|
import { matchFilter } from 'nostr-tools/filter'
|
||||||
|
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
||||||
|
// and so on and so forth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using it with `nostr-wasm`
|
||||||
|
|
||||||
|
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
|
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||||
|
initNostrWasm().then(setNostrWasm)
|
||||||
|
|
||||||
|
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||||
|
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||||
|
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
|
initNostrWasm().then(setNostrWasm)
|
||||||
|
|
||||||
|
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||||
|
const pool = new AbstractSimplePool({ verifyEvent })
|
||||||
|
```
|
||||||
|
|
||||||
|
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
|
||||||
|
|
||||||
|
```
|
||||||
|
benchmark time (avg) (min … max) p75 p99 p995
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
• relay read message and verify event (many events)
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
|
||||||
|
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
|
||||||
|
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
|
||||||
|
|
||||||
|
summary for relay read message and verify event
|
||||||
|
wasm
|
||||||
|
86.77x slower than trusted
|
||||||
|
6.86x faster than pure js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using from the browser (if you don't want to use a bundler)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.NostrTools.generateSecretKey('...') // and so on
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plumbing
|
||||||
|
|
||||||
|
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||||
|
|||||||
190
abstract-pool.ts
Normal file
190
abstract-pool.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||||
|
import { normalizeURL } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event, Nostr } from './core.ts'
|
||||||
|
import { type Filter } from './filter.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
export type SubCloser = { close: () => void }
|
||||||
|
|
||||||
|
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||||
|
maxWait?: number
|
||||||
|
onclose?: (reasons: string[]) => void
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractSimplePool {
|
||||||
|
private relays = new Map<string, AbstractRelay>()
|
||||||
|
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||||
|
public trackRelays: boolean = false
|
||||||
|
|
||||||
|
public verifyEvent: Nostr['verifyEvent']
|
||||||
|
public trustedRelayURLs = new Set<string>()
|
||||||
|
|
||||||
|
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||||
|
url = normalizeURL(url)
|
||||||
|
|
||||||
|
let relay = this.relays.get(url)
|
||||||
|
if (!relay) {
|
||||||
|
relay = new AbstractRelay(url, {
|
||||||
|
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||||
|
})
|
||||||
|
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||||
|
this.relays.set(url, relay)
|
||||||
|
}
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
close(relays: string[]) {
|
||||||
|
relays.map(normalizeURL).forEach(url => {
|
||||||
|
this.relays.get(url)?.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||||
|
if (this.trackRelays) {
|
||||||
|
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||||
|
let set = this.seenOn.get(id)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.seenOn.set(id, set)
|
||||||
|
}
|
||||||
|
set.add(relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _knownIds = new Set<string>()
|
||||||
|
const subs: Subscription[] = []
|
||||||
|
|
||||||
|
// batch all EOSEs into a single
|
||||||
|
const eosesReceived: boolean[] = []
|
||||||
|
let handleEose = (i: number) => {
|
||||||
|
eosesReceived[i] = true
|
||||||
|
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.oneose?.()
|
||||||
|
handleEose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// batch all closes into a single
|
||||||
|
const closesReceived: string[] = []
|
||||||
|
let handleClose = (i: number, reason: string) => {
|
||||||
|
handleEose(i)
|
||||||
|
closesReceived[i] = reason
|
||||||
|
if (closesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.onclose?.(closesReceived)
|
||||||
|
handleClose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAlreadyHaveEventHandler = (id: string) => {
|
||||||
|
if (params.alreadyHaveEvent?.(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const have = _knownIds.has(id)
|
||||||
|
_knownIds.add(id)
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
// open a subscription in all given relays
|
||||||
|
const allOpened = Promise.all(
|
||||||
|
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
handleClose(i, 'duplicate url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay: AbstractRelay
|
||||||
|
try {
|
||||||
|
relay = await this.ensureRelay(url, {
|
||||||
|
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
handleClose(i, (err as any)?.message || String(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = relay.subscribe(filters, {
|
||||||
|
...params,
|
||||||
|
oneose: () => handleEose(i),
|
||||||
|
onclose: reason => handleClose(i, reason),
|
||||||
|
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||||
|
eoseTimeout: params.maxWait,
|
||||||
|
})
|
||||||
|
|
||||||
|
subs.push(subscription)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
async close() {
|
||||||
|
await allOpened
|
||||||
|
subs.forEach(sub => {
|
||||||
|
sub.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeManyEose(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter[],
|
||||||
|
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||||
|
): SubCloser {
|
||||||
|
const subcloser = this.subscribeMany(relays, filters, {
|
||||||
|
...params,
|
||||||
|
oneose() {
|
||||||
|
subcloser.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return subcloser
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySync(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
const events: Event[] = []
|
||||||
|
this.subscribeManyEose(relays, [filter], {
|
||||||
|
...params,
|
||||||
|
onevent(event: Event) {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
onclose(_: string[]) {
|
||||||
|
resolve(events)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event | null> {
|
||||||
|
filter.limit = 1
|
||||||
|
const events = await this.querySync(relays, filter, params)
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
return events[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(relays: string[], event: Event): Promise<string>[] {
|
||||||
|
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
return Promise.reject('duplicate url')
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = await this.ensureRelay(url)
|
||||||
|
return r.publish(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
359
abstract-relay.ts
Normal file
359
abstract-relay.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/* global WebSocket */
|
||||||
|
|
||||||
|
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||||
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
|
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||||
|
import { Queue, normalizeURL } from './utils.ts'
|
||||||
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
|
import { yieldThread } from './helpers.ts'
|
||||||
|
|
||||||
|
export class AbstractRelay {
|
||||||
|
public readonly url: string
|
||||||
|
private _connected: boolean = false
|
||||||
|
|
||||||
|
public onclose: (() => void) | null = null
|
||||||
|
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||||
|
|
||||||
|
public baseEoseTimeout: number = 4400
|
||||||
|
public connectionTimeout: number = 4400
|
||||||
|
public openSubs = new Map<string, Subscription>()
|
||||||
|
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
private connectionPromise: Promise<void> | undefined
|
||||||
|
private openCountRequests = new Map<string, CountResolver>()
|
||||||
|
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||||
|
private ws: WebSocket | undefined
|
||||||
|
private incomingMessageQueue = new Queue<string>()
|
||||||
|
private queueRunning = false
|
||||||
|
private challenge: string | undefined
|
||||||
|
private serial: number = 0
|
||||||
|
private verifyEvent: Nostr['verifyEvent']
|
||||||
|
|
||||||
|
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.url = normalizeURL(url)
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
const relay = new AbstractRelay(url, opts)
|
||||||
|
await relay.connect()
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeAllSubscriptions(reason: string) {
|
||||||
|
for (let [_, sub] of this.openSubs) {
|
||||||
|
sub.close(reason)
|
||||||
|
}
|
||||||
|
this.openSubs.clear()
|
||||||
|
|
||||||
|
for (let [_, ep] of this.openEventPublishes) {
|
||||||
|
ep.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openEventPublishes.clear()
|
||||||
|
|
||||||
|
for (let [_, cr] of this.openCountRequests) {
|
||||||
|
cr.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openCountRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
public get connected(): boolean {
|
||||||
|
return this._connected
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (this.connectionPromise) return this.connectionPromise
|
||||||
|
|
||||||
|
this.challenge = undefined
|
||||||
|
this.connectionPromise = new Promise((resolve, reject) => {
|
||||||
|
this.connectionTimeoutHandle = setTimeout(() => {
|
||||||
|
reject('connection timed out')
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection timed out')
|
||||||
|
}, this.connectionTimeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
this._connected = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = ev => {
|
||||||
|
reject((ev as any).message)
|
||||||
|
if (this._connected) {
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection errored')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = async () => {
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection closed')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = this._onmessage.bind(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.connectionPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runQueue() {
|
||||||
|
this.queueRunning = true
|
||||||
|
while (true) {
|
||||||
|
if (false === this.handleNext()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await yieldThread()
|
||||||
|
}
|
||||||
|
this.queueRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNext(): undefined | false {
|
||||||
|
const json = this.incomingMessageQueue.dequeue()
|
||||||
|
if (!json) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const subid = getSubscriptionId(json)
|
||||||
|
if (subid) {
|
||||||
|
const so = this.openSubs.get(subid as string)
|
||||||
|
if (!so) {
|
||||||
|
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// this will be called only when this message is a EVENT message for a subscription we have
|
||||||
|
// we do this before parsing the JSON to not have to do that for duplicate events
|
||||||
|
// since JSON parsing is slow
|
||||||
|
const id = getHex64(json, 'id')
|
||||||
|
const alreadyHave = so.alreadyHaveEvent?.(id)
|
||||||
|
|
||||||
|
// notify any interested client that the relay has this event
|
||||||
|
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
||||||
|
so.receivedEvent?.(this, id)
|
||||||
|
|
||||||
|
if (alreadyHave) {
|
||||||
|
// if we had already seen this event we can just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(json)
|
||||||
|
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||||
|
// will naturally be caught by the encompassing try..catch block
|
||||||
|
|
||||||
|
switch (data[0]) {
|
||||||
|
case 'EVENT': {
|
||||||
|
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||||
|
const event = data[2] as Event
|
||||||
|
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
|
||||||
|
so.onevent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'COUNT': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const payload = data[2] as { count: number }
|
||||||
|
const cr = this.openCountRequests.get(id) as CountResolver
|
||||||
|
if (cr) {
|
||||||
|
cr.resolve(payload.count)
|
||||||
|
this.openCountRequests.delete(id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'EOSE': {
|
||||||
|
const so = this.openSubs.get(data[1] as string)
|
||||||
|
if (!so) return
|
||||||
|
so.receivedEose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'OK': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const ok: boolean = data[2]
|
||||||
|
const reason: string = data[3]
|
||||||
|
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||||
|
if (ok) ep.resolve(reason)
|
||||||
|
else ep.reject(new Error(reason))
|
||||||
|
this.openEventPublishes.delete(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'CLOSED': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const so = this.openSubs.get(id)
|
||||||
|
if (!so) return
|
||||||
|
so.closed = true
|
||||||
|
so.close(data[2] as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'NOTICE':
|
||||||
|
this.onnotice(data[1] as string)
|
||||||
|
return
|
||||||
|
case 'AUTH': {
|
||||||
|
this.challenge = data[1] as string
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(message: string) {
|
||||||
|
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||||
|
|
||||||
|
this.connectionPromise.then(() => {
|
||||||
|
this.ws?.send(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||||
|
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
|
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async publish(event: Event): Promise<string> {
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
||||||
|
this.serial++
|
||||||
|
const id = params?.id || 'count:' + this.serial
|
||||||
|
const ret = new Promise<number>((resolve, reject) => {
|
||||||
|
this.openCountRequests.set(id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||||
|
const subscription = this.prepareSubscription(filters, params)
|
||||||
|
subscription.fire()
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||||
|
this.serial++
|
||||||
|
const id = params.id || 'sub:' + this.serial
|
||||||
|
const subscription = new Subscription(this, id, filters, params)
|
||||||
|
this.openSubs.set(id, subscription)
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.closeAllSubscriptions('relay connection closed by us')
|
||||||
|
this._connected = false
|
||||||
|
this.ws?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is the function assigned to this.ws.onmessage
|
||||||
|
// it's exposed for testing and debugging purposes
|
||||||
|
public _onmessage(ev: MessageEvent<any>) {
|
||||||
|
this.incomingMessageQueue.enqueue(ev.data as string)
|
||||||
|
if (!this.queueRunning) {
|
||||||
|
this.runQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Subscription {
|
||||||
|
public readonly relay: AbstractRelay
|
||||||
|
public readonly id: string
|
||||||
|
|
||||||
|
public closed: boolean = false
|
||||||
|
public eosed: boolean = false
|
||||||
|
public filters: Filter[]
|
||||||
|
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
||||||
|
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
|
||||||
|
|
||||||
|
public onevent: (evt: Event) => void
|
||||||
|
public oneose: (() => void) | undefined
|
||||||
|
public onclose: ((reason: string) => void) | undefined
|
||||||
|
|
||||||
|
public eoseTimeout: number
|
||||||
|
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||||
|
this.relay = relay
|
||||||
|
this.filters = filters
|
||||||
|
this.id = id
|
||||||
|
this.alreadyHaveEvent = params.alreadyHaveEvent
|
||||||
|
this.receivedEvent = params.receivedEvent
|
||||||
|
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
||||||
|
|
||||||
|
this.oneose = params.oneose
|
||||||
|
this.onclose = params.onclose
|
||||||
|
this.onevent =
|
||||||
|
params.onevent ||
|
||||||
|
(event => {
|
||||||
|
console.warn(
|
||||||
|
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public fire() {
|
||||||
|
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
||||||
|
|
||||||
|
// only now we start counting the eoseTimeout
|
||||||
|
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
public receivedEose() {
|
||||||
|
if (this.eosed) return
|
||||||
|
clearTimeout(this.eoseTimeoutHandle)
|
||||||
|
this.eosed = true
|
||||||
|
this.oneose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(reason: string = 'closed by caller') {
|
||||||
|
if (!this.closed) {
|
||||||
|
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||||
|
// otherwise this._open will be already set to false so we will skip this
|
||||||
|
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||||
|
this.closed = true
|
||||||
|
}
|
||||||
|
this.relay.openSubs.delete(this.id)
|
||||||
|
this.onclose?.(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionParams = {
|
||||||
|
onevent?: (evt: Event) => void
|
||||||
|
oneose?: () => void
|
||||||
|
onclose?: (reason: string) => void
|
||||||
|
alreadyHaveEvent?: (id: string) => boolean
|
||||||
|
receivedEvent?: (relay: AbstractRelay, id: string) => void
|
||||||
|
eoseTimeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountResolver = {
|
||||||
|
resolve: (count: number) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPublishResolver = {
|
||||||
|
resolve: (reason: string) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
61
benchmarks.ts
Normal file
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'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
import { setNostrWasm, verifyEvent } from './wasm'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
import { Relay as PureRelay } from './relay.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
// benchmarking relay reads with verifyEvent
|
||||||
|
const EVENTS = 200
|
||||||
|
let messages: string[] = []
|
||||||
|
let baseContent = ''
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
baseContent += 'a'
|
||||||
|
}
|
||||||
|
const secretKey = generateSecretKey()
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
const tags = []
|
||||||
|
for (let t = 0; t < i; t++) {
|
||||||
|
tags.push(['t', 'nada'])
|
||||||
|
}
|
||||||
|
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
|
||||||
|
const signed = finalizeEvent(event, secretKey)
|
||||||
|
messages.push(JSON.stringify(['EVENT', '_', signed]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setNostrWasm(await initNostrWasm())
|
||||||
|
|
||||||
|
const pureRelay = new PureRelay('wss://pure.com/')
|
||||||
|
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
|
||||||
|
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
|
||||||
|
|
||||||
|
const runWith = (relay: AbstractRelay) => async () => {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
let received = 0
|
||||||
|
let sub = relay.prepareSubscription([{}], {
|
||||||
|
id: '_',
|
||||||
|
onevent(_: NostrEvent) {
|
||||||
|
received++
|
||||||
|
if (received === messages.length - 1) {
|
||||||
|
resolve()
|
||||||
|
sub.closed = true
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (let e = 0; e < messages.length; e++) {
|
||||||
|
relay._onmessage({ data: messages[e] } as any)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
group(`relay read ${EVENTS} messages and verify its events`, () => {
|
||||||
|
baseline('wasm', runWith(wasmRelay))
|
||||||
|
bench('pure js', runWith(pureRelay))
|
||||||
|
bench('trusted', runWith(trustedRelay))
|
||||||
|
})
|
||||||
|
|
||||||
|
// actually running the thing
|
||||||
|
await run()
|
||||||
60
build.js
Executable file
60
build.js
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
const fs = require('node:fs')
|
||||||
|
const esbuild = require('esbuild')
|
||||||
|
const { join } = require('path')
|
||||||
|
|
||||||
|
const entryPoints = fs
|
||||||
|
.readdirSync(process.cwd())
|
||||||
|
.filter(
|
||||||
|
file =>
|
||||||
|
file.endsWith('.ts') &&
|
||||||
|
file !== 'core.ts' &&
|
||||||
|
file !== 'test-helpers.ts' &&
|
||||||
|
file !== 'helpers.ts' &&
|
||||||
|
file !== 'benchmarks.ts' &&
|
||||||
|
!file.endsWith('.test.ts') &&
|
||||||
|
fs.statSync(join(process.cwd(), file)).isFile(),
|
||||||
|
)
|
||||||
|
|
||||||
|
let common = {
|
||||||
|
entryPoints,
|
||||||
|
bundle: true,
|
||||||
|
sourcemap: 'external',
|
||||||
|
}
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
outdir: 'lib/esm',
|
||||||
|
format: 'esm',
|
||||||
|
packages: 'external',
|
||||||
|
})
|
||||||
|
.then(() => console.log('esm build success.'))
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
outdir: 'lib/cjs',
|
||||||
|
format: 'cjs',
|
||||||
|
packages: 'external',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||||
|
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||||
|
|
||||||
|
console.log('cjs build success.')
|
||||||
|
})
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
entryPoints: ['index.ts'],
|
||||||
|
outfile: 'lib/nostr.bundle.js',
|
||||||
|
format: 'iife',
|
||||||
|
globalName: 'NostrTools',
|
||||||
|
define: {
|
||||||
|
window: 'self',
|
||||||
|
global: 'self',
|
||||||
|
process: '{"env": {}}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => console.log('standalone build success.'))
|
||||||
293
core.test.ts
Normal file
293
core.test.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
finalizeEvent,
|
||||||
|
serializeEvent,
|
||||||
|
getEventHash,
|
||||||
|
validateEvent,
|
||||||
|
verifyEvent,
|
||||||
|
verifiedSymbol,
|
||||||
|
getPublicKey,
|
||||||
|
generateSecretKey,
|
||||||
|
} from './pure.ts'
|
||||||
|
import { ShortTextNote } from './kinds.ts'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
test('private key generation', () => {
|
||||||
|
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('public key generation', () => {
|
||||||
|
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('public key from private key deterministic', () => {
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
expect(getPublicKey(sk)).toEqual(pk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finalizeEvent', () => {
|
||||||
|
test('should create a signed event from a template', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finalizeEvent(template, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(template.kind)
|
||||||
|
expect(event.tags).toEqual(template.tags)
|
||||||
|
expect(event.content).toEqual(template.content)
|
||||||
|
expect(event.created_at).toEqual(template.created_at)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serializeEvent', () => {
|
||||||
|
test('should serialize a valid event object', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
pubkey: publicKey,
|
||||||
|
created_at: 1617932115,
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedEvent = serializeEvent(unsignedEvent)
|
||||||
|
|
||||||
|
expect(serializedEvent).toEqual(
|
||||||
|
JSON.stringify([
|
||||||
|
0,
|
||||||
|
publicKey,
|
||||||
|
unsignedEvent.created_at,
|
||||||
|
unsignedEvent.kind,
|
||||||
|
unsignedEvent.tags,
|
||||||
|
unsignedEvent.content,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw an error for an invalid event object', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const invalidEvent = {
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey: publicKey, // missing content
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
// @ts-expect-error
|
||||||
|
serializeEvent(invalidEvent)
|
||||||
|
}).toThrow("can't serialize event with wrong or missing properties")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEventHash', () => {
|
||||||
|
test('should return the correct event hash', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey: publicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventHash = getEventHash(unsignedEvent)
|
||||||
|
|
||||||
|
expect(typeof eventHash).toEqual('string')
|
||||||
|
expect(eventHash.length).toEqual(64)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('should return true for a valid event object', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey: publicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validateEvent(unsignedEvent)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for a non object event', () => {
|
||||||
|
const nonObjectEvent = ''
|
||||||
|
const isValid = validateEvent(nonObjectEvent)
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an event object with missing properties', () => {
|
||||||
|
const invalidEvent = {
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
created_at: 1617932115, // missing content and pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validateEvent(invalidEvent)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an empty object', () => {
|
||||||
|
const emptyObj = {}
|
||||||
|
|
||||||
|
const isValid = validateEvent(emptyObj)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an object with invalid properties', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const invalidEvent = {
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
created_at: '1617932115', // should be a number
|
||||||
|
pubkey: publicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validateEvent(invalidEvent)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an object with an invalid public key', () => {
|
||||||
|
const invalidEvent = {
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey: 'invalid_pubkey',
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validateEvent(invalidEvent)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an object with invalid tags', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const invalidEvent = {
|
||||||
|
kind: 1,
|
||||||
|
tags: {}, // should be an array
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey: publicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validateEvent(invalidEvent)
|
||||||
|
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyEvent', () => {
|
||||||
|
test('should return true for a valid event signature', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isValid = verifyEvent(event)
|
||||||
|
expect(isValid).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an invalid event signature', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// tamper with the signature
|
||||||
|
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||||
|
|
||||||
|
const isValid = verifyEvent(event)
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when verifying an event with a different private key', () => {
|
||||||
|
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
|
||||||
|
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
|
||||||
|
const publicKey2 = getPublicKey(privateKey2)
|
||||||
|
|
||||||
|
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey1,
|
||||||
|
)
|
||||||
|
|
||||||
|
// verify with different private key
|
||||||
|
const isValid = verifyEvent({
|
||||||
|
...event,
|
||||||
|
pubkey: publicKey2,
|
||||||
|
})
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for an invalid event id', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
|
||||||
|
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// tamper with the id
|
||||||
|
event.id = event.id.replace(/^.{3}/g, '666')
|
||||||
|
|
||||||
|
const isValid = verifyEvent(event)
|
||||||
|
expect(isValid).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
core.ts
Normal file
51
core.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface Nostr {
|
||||||
|
generateSecretKey(): Uint8Array
|
||||||
|
getPublicKey(secretKey: Uint8Array): string
|
||||||
|
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Designates a verified event signature. */
|
||||||
|
export const verifiedSymbol = Symbol('verified')
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
pubkey: string
|
||||||
|
id: string
|
||||||
|
sig: string
|
||||||
|
[verifiedSymbol]?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrEvent = Event
|
||||||
|
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||||
|
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
||||||
|
|
||||||
|
/** An event whose signature has been verified. */
|
||||||
|
export interface VerifiedEvent extends Event {
|
||||||
|
[verifiedSymbol]: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||||
|
|
||||||
|
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||||
|
if (!isRecord(event)) return false
|
||||||
|
if (typeof event.kind !== 'number') return false
|
||||||
|
if (typeof event.content !== 'string') return false
|
||||||
|
if (typeof event.created_at !== 'number') return false
|
||||||
|
if (typeof event.pubkey !== 'string') return false
|
||||||
|
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||||
|
|
||||||
|
if (!Array.isArray(event.tags)) return false
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
let tag = event.tags[i]
|
||||||
|
if (!Array.isArray(tag)) return false
|
||||||
|
for (let j = 0; j < tag.length; j++) {
|
||||||
|
if (typeof tag[j] === 'object') return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
58
event.js
58
event.js
@@ -1,58 +0,0 @@
|
|||||||
import {Buffer} from 'buffer'
|
|
||||||
import createHash from 'create-hash'
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
|
|
||||||
export function getBlankEvent() {
|
|
||||||
return {
|
|
||||||
kind: 255,
|
|
||||||
pubkey: null,
|
|
||||||
content: '',
|
|
||||||
tags: [],
|
|
||||||
created_at: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeEvent(evt) {
|
|
||||||
return JSON.stringify([
|
|
||||||
0,
|
|
||||||
evt.pubkey,
|
|
||||||
evt.created_at,
|
|
||||||
evt.kind,
|
|
||||||
evt.tags,
|
|
||||||
evt.content
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventHash(event) {
|
|
||||||
let eventHash = createHash('sha256')
|
|
||||||
.update(Buffer.from(serializeEvent(event)))
|
|
||||||
.digest()
|
|
||||||
return Buffer.from(eventHash).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateEvent(event) {
|
|
||||||
if (event.id !== getEventHash(event)) return false
|
|
||||||
if (typeof event.content !== 'string') return false
|
|
||||||
if (typeof event.created_at !== 'number') return false
|
|
||||||
|
|
||||||
if (!Array.isArray(event.tags)) return false
|
|
||||||
for (let i = 0; i < event.tags.length; i++) {
|
|
||||||
let tag = event.tags[i]
|
|
||||||
if (!Array.isArray(tag)) return false
|
|
||||||
for (let j = 0; j < tag.length; j++) {
|
|
||||||
if (typeof tag[j] === 'object') return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifySignature(event) {
|
|
||||||
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signEvent(event, key) {
|
|
||||||
return Buffer.from(
|
|
||||||
await secp256k1.schnorr.sign(getEventHash(event), key)
|
|
||||||
).toString('hex')
|
|
||||||
}
|
|
||||||
44
fakejson.test.ts
Normal file
44
fakejson.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||||
|
|
||||||
|
test('match id', () => {
|
||||||
|
expect(
|
||||||
|
matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
|
),
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('match kind', () => {
|
||||||
|
expect(
|
||||||
|
matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||||
|
12720,
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('match subscription id', () => {
|
||||||
|
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||||
|
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||||
|
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||||
|
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||||
|
expect(
|
||||||
|
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||||
|
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||||
|
})
|
||||||
41
fakejson.ts
Normal file
41
fakejson.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export function getHex64(json: string, field: string): string {
|
||||||
|
let len = field.length + 3
|
||||||
|
let idx = json.indexOf(`"${field}":`) + len
|
||||||
|
let s = json.slice(idx).indexOf(`"`) + idx + 1
|
||||||
|
return json.slice(s, s + 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInt(json: string, field: string): number {
|
||||||
|
let len = field.length
|
||||||
|
let idx = json.indexOf(`"${field}":`) + len + 3
|
||||||
|
let sliced = json.slice(idx)
|
||||||
|
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
|
||||||
|
return parseInt(sliced.slice(0, end), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscriptionId(json: string): string | null {
|
||||||
|
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
|
||||||
|
if (idx === -1) return null
|
||||||
|
|
||||||
|
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
|
||||||
|
if (pstart === -1) return null
|
||||||
|
let start = idx + 7 + 1 + pstart
|
||||||
|
|
||||||
|
let pend = json.slice(start + 1, 80).indexOf(`"`)
|
||||||
|
if (pend === -1) return null
|
||||||
|
let end = start + 1 + pend
|
||||||
|
|
||||||
|
return json.slice(start + 1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventId(json: string, id: string): boolean {
|
||||||
|
return id === getHex64(json, 'id')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventPubkey(json: string, pubkey: string): boolean {
|
||||||
|
return pubkey === getHex64(json, 'pubkey')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventKind(json: string, kind: number): boolean {
|
||||||
|
return kind === getInt(json, 'kind')
|
||||||
|
}
|
||||||
30
filter.js
30
filter.js
@@ -1,30 +0,0 @@
|
|||||||
export function matchFilter(filter, event) {
|
|
||||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
|
|
||||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
|
||||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
|
||||||
return false
|
|
||||||
|
|
||||||
for (let f in filter) {
|
|
||||||
if (f[0] === '#') {
|
|
||||||
if (
|
|
||||||
filter[f] &&
|
|
||||||
!event.tags.find(
|
|
||||||
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.since && event.created_at < filter.since) return false
|
|
||||||
if (filter.until && event.created_at >= filter.until) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchFilters(filters, event) {
|
|
||||||
for (let i = 0; i < filters.length; i++) {
|
|
||||||
if (matchFilter(filters[i], event)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
267
filter.test.ts
Normal file
267
filter.test.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
describe('Filter', () => {
|
||||||
|
describe('matchFilter', () => {
|
||||||
|
test('should return true when all filter conditions are met', () => {
|
||||||
|
const filter = {
|
||||||
|
ids: ['123', '456'],
|
||||||
|
kinds: [1, 2, 3],
|
||||||
|
authors: ['abc'],
|
||||||
|
since: 100,
|
||||||
|
until: 200,
|
||||||
|
'#tag': ['value'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
tags: [['tag', 'value']],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event id is not in the filter', () => {
|
||||||
|
const filter = { ids: ['123', '456'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '789' })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when the event id starts with a prefix', () => {
|
||||||
|
const filter = { ids: ['22', '00'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '001' })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event kind is not in the filter', () => {
|
||||||
|
const filter = { kinds: [1, 2, 3] }
|
||||||
|
|
||||||
|
const event = buildEvent({ kind: 4 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event author is not in the filter', () => {
|
||||||
|
const filter = { authors: ['abc', 'def'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ pubkey: 'ghi' })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when a tag is not present in the event', () => {
|
||||||
|
const filter = { '#tag': ['value1', 'value2'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when a tag value is not present in the event', () => {
|
||||||
|
const filter = { '#tag': ['value1', 'value2'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when filter has tags that is present in the event', () => {
|
||||||
|
const filter = { '#tag1': ['foo'] }
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
tags: [
|
||||||
|
['tag1', 'foo'],
|
||||||
|
['tag2', 'bar'],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event is before the filter since value', () => {
|
||||||
|
const filter = { since: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 50 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||||
|
const filter = { since: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 100 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event is after the filter until value', () => {
|
||||||
|
const filter = { until: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 150 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||||
|
const filter = { until: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 100 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchFilters', () => {
|
||||||
|
test('should return true when at least one filter matches the event', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||||
|
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||||
|
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when at least one prefix matches the event', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['1'], kinds: [1], authors: ['a'] },
|
||||||
|
{ ids: ['4'], kinds: [2], authors: ['d'] },
|
||||||
|
{ ids: ['9'], kinds: [3], authors: ['g'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when event matches one or more filters and some have limit set', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], limit: 1 },
|
||||||
|
{ kinds: [1], limit: 2 },
|
||||||
|
{ authors: ['abc'], limit: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when no filters match the event', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||||
|
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||||
|
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when event matches none of the filters and some have limit set', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], limit: 1 },
|
||||||
|
{ kinds: [1], limit: 2 },
|
||||||
|
{ authors: ['abc'], limit: 3 },
|
||||||
|
]
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '456',
|
||||||
|
kind: 2,
|
||||||
|
pubkey: 'def',
|
||||||
|
created_at: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeFilters', () => {
|
||||||
|
test('should merge filters', () => {
|
||||||
|
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||||
|
ids: ['a', 'b', 'c'],
|
||||||
|
limit: 3,
|
||||||
|
authors: ['x'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||||
|
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getFilterLimit', () => {
|
||||||
|
test('should handle ids', () => {
|
||||||
|
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should count the authors times replaceable kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for authors with regular kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for empty filters', () => {
|
||||||
|
expect(getFilterLimit({})).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
88
filter.ts
Normal file
88
filter.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Event } from './core.ts'
|
||||||
|
import { isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
|
export type Filter = {
|
||||||
|
ids?: string[]
|
||||||
|
kinds?: number[]
|
||||||
|
authors?: string[]
|
||||||
|
since?: number
|
||||||
|
until?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
[key: `#${string}`]: string[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchFilter(filter: Filter, event: Event): boolean {
|
||||||
|
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||||
|
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||||
|
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||||
|
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let f in filter) {
|
||||||
|
if (f[0] === '#') {
|
||||||
|
let tagName = f.slice(1)
|
||||||
|
let values = filter[`#${tagName}`]
|
||||||
|
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.since && event.created_at < filter.since) return false
|
||||||
|
if (filter.until && event.created_at > filter.until) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
if (matchFilter(filters[i], event)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFilters(...filters: Filter[]): Filter {
|
||||||
|
let result: Filter = {}
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
let filter = filters[i]
|
||||||
|
Object.entries(filter).forEach(([property, values]) => {
|
||||||
|
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||||
|
// @ts-ignore
|
||||||
|
result[property] = result[property] || []
|
||||||
|
// @ts-ignore
|
||||||
|
for (let v = 0; v < values.length; v++) {
|
||||||
|
// @ts-ignore
|
||||||
|
let value = values[v]
|
||||||
|
// @ts-ignore
|
||||||
|
if (!result[property].includes(value)) result[property].push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||||
|
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||||
|
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
||||||
|
export function getFilterLimit(filter: Filter): number {
|
||||||
|
if (filter.ids && !filter.ids.length) return 0
|
||||||
|
if (filter.kinds && !filter.kinds.length) return 0
|
||||||
|
if (filter.authors && !filter.authors.length) return 0
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
filter.ids?.length ?? Infinity,
|
||||||
|
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||||
|
? filter.authors.length * filter.kinds.length
|
||||||
|
: Infinity,
|
||||||
|
)
|
||||||
|
}
|
||||||
21
helpers.ts
Normal file
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
|
||||||
|
}
|
||||||
27
index.js
27
index.js
@@ -1,27 +0,0 @@
|
|||||||
import {generatePrivateKey, getPublicKey} from './keys'
|
|
||||||
import {relayConnect} from './relay'
|
|
||||||
import {relayPool} from './pool'
|
|
||||||
import {
|
|
||||||
getBlankEvent,
|
|
||||||
signEvent,
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
serializeEvent,
|
|
||||||
getEventHash
|
|
||||||
} from './event'
|
|
||||||
import {matchFilter, matchFilters} from './filter'
|
|
||||||
|
|
||||||
export {
|
|
||||||
generatePrivateKey,
|
|
||||||
relayConnect,
|
|
||||||
relayPool,
|
|
||||||
signEvent,
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
serializeEvent,
|
|
||||||
getEventHash,
|
|
||||||
getPublicKey,
|
|
||||||
getBlankEvent,
|
|
||||||
matchFilter,
|
|
||||||
matchFilters
|
|
||||||
}
|
|
||||||
28
index.ts
Normal file
28
index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export * from './pure.ts'
|
||||||
|
export * from './relay.ts'
|
||||||
|
export * from './filter.ts'
|
||||||
|
export * from './pool.ts'
|
||||||
|
export * from './references.ts'
|
||||||
|
|
||||||
|
export * as nip04 from './nip04.ts'
|
||||||
|
export * as nip05 from './nip05.ts'
|
||||||
|
export * as nip10 from './nip10.ts'
|
||||||
|
export * as nip11 from './nip11.ts'
|
||||||
|
export * as nip13 from './nip13.ts'
|
||||||
|
export * as nip18 from './nip18.ts'
|
||||||
|
export * as nip19 from './nip19.ts'
|
||||||
|
export * as nip21 from './nip21.ts'
|
||||||
|
export * as nip25 from './nip25.ts'
|
||||||
|
export * as nip27 from './nip27.ts'
|
||||||
|
export * as nip28 from './nip28.ts'
|
||||||
|
export * as nip30 from './nip30.ts'
|
||||||
|
export * as nip39 from './nip39.ts'
|
||||||
|
export * as nip42 from './nip42.ts'
|
||||||
|
export * as nip44 from './nip44.ts'
|
||||||
|
export * as nip47 from './nip47.ts'
|
||||||
|
export * as nip57 from './nip57.ts'
|
||||||
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
|
export * as kinds from './kinds.ts'
|
||||||
|
export * as fj from './fakejson.ts'
|
||||||
|
export * as utils from './utils.ts'
|
||||||
32
justfile
Normal file
32
justfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||||
|
|
||||||
|
build:
|
||||||
|
rm -rf lib
|
||||||
|
bun run build.js
|
||||||
|
|
||||||
|
test:
|
||||||
|
bun test --timeout 20000
|
||||||
|
|
||||||
|
test-only file:
|
||||||
|
bun test {{file}}
|
||||||
|
|
||||||
|
emit-types:
|
||||||
|
tsc # see tsconfig.json
|
||||||
|
|
||||||
|
publish: build emit-types
|
||||||
|
npm publish
|
||||||
|
|
||||||
|
format:
|
||||||
|
eslint --ext .ts --fix *.ts
|
||||||
|
prettier --write *.ts
|
||||||
|
|
||||||
|
lint:
|
||||||
|
eslint --ext .ts *.ts
|
||||||
|
prettier --check *.ts
|
||||||
|
|
||||||
|
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
|
||||||
10
keys.js
10
keys.js
@@ -1,10 +0,0 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {Buffer} from 'buffer'
|
|
||||||
|
|
||||||
export function generatePrivateKey() {
|
|
||||||
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicKey(privateKey) {
|
|
||||||
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
|
|
||||||
}
|
|
||||||
21
kinds.test.ts
Normal file
21
kinds.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { classifyKind } from './kinds.ts'
|
||||||
|
|
||||||
|
test('kind classification', () => {
|
||||||
|
expect(classifyKind(1)).toBe('regular')
|
||||||
|
expect(classifyKind(5)).toBe('regular')
|
||||||
|
expect(classifyKind(6)).toBe('regular')
|
||||||
|
expect(classifyKind(7)).toBe('regular')
|
||||||
|
expect(classifyKind(1000)).toBe('regular')
|
||||||
|
expect(classifyKind(9999)).toBe('regular')
|
||||||
|
expect(classifyKind(0)).toBe('replaceable')
|
||||||
|
expect(classifyKind(3)).toBe('replaceable')
|
||||||
|
expect(classifyKind(10000)).toBe('replaceable')
|
||||||
|
expect(classifyKind(19999)).toBe('replaceable')
|
||||||
|
expect(classifyKind(20000)).toBe('ephemeral')
|
||||||
|
expect(classifyKind(29999)).toBe('ephemeral')
|
||||||
|
expect(classifyKind(30000)).toBe('parameterized')
|
||||||
|
expect(classifyKind(39999)).toBe('parameterized')
|
||||||
|
expect(classifyKind(40000)).toBe('unknown')
|
||||||
|
expect(classifyKind(255)).toBe('unknown')
|
||||||
|
})
|
||||||
105
kinds.ts
Normal file
105
kinds.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||||
|
export function isRegularKind(kind: number) {
|
||||||
|
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||||
|
export function isReplaceableKind(kind: number) {
|
||||||
|
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||||
|
export function isEphemeralKind(kind: number) {
|
||||||
|
return 20000 <= kind && kind < 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||||
|
export function isParameterizedReplaceableKind(kind: number) {
|
||||||
|
return 30000 <= kind && kind < 40000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classification of the event kind. */
|
||||||
|
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||||
|
|
||||||
|
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||||
|
export function classifyKind(kind: number): KindClassification {
|
||||||
|
if (isRegularKind(kind)) return 'regular'
|
||||||
|
if (isReplaceableKind(kind)) return 'replaceable'
|
||||||
|
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||||
|
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Metadata = 0
|
||||||
|
export const ShortTextNote = 1
|
||||||
|
export const RecommendRelay = 2
|
||||||
|
export const Contacts = 3
|
||||||
|
export const EncryptedDirectMessage = 4
|
||||||
|
export const EncryptedDirectMessages = 4
|
||||||
|
export const EventDeletion = 5
|
||||||
|
export const Repost = 6
|
||||||
|
export const Reaction = 7
|
||||||
|
export const BadgeAward = 8
|
||||||
|
export const GenericRepost = 16
|
||||||
|
export const ChannelCreation = 40
|
||||||
|
export const ChannelMetadata = 41
|
||||||
|
export const ChannelMessage = 42
|
||||||
|
export const ChannelHideMessage = 43
|
||||||
|
export const ChannelMuteUser = 44
|
||||||
|
export const OpenTimestamps = 1040
|
||||||
|
export const FileMetadata = 1063
|
||||||
|
export const LiveChatMessage = 1311
|
||||||
|
export const ProblemTracker = 1971
|
||||||
|
export const Report = 1984
|
||||||
|
export const Reporting = 1984
|
||||||
|
export const Label = 1985
|
||||||
|
export const CommunityPostApproval = 4550
|
||||||
|
export const JobRequest = 5999
|
||||||
|
export const JobResult = 6999
|
||||||
|
export const JobFeedback = 7000
|
||||||
|
export const ZapGoal = 9041
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export const Zap = 9735
|
||||||
|
export const Highlights = 9802
|
||||||
|
export const Mutelist = 10000
|
||||||
|
export const Pinlist = 10001
|
||||||
|
export const RelayList = 10002
|
||||||
|
export const BookmarkList = 10003
|
||||||
|
export const CommunitiesList = 10004
|
||||||
|
export const PublicChatsList = 10005
|
||||||
|
export const BlockedRelaysList = 10006
|
||||||
|
export const SearchRelaysList = 10007
|
||||||
|
export const InterestsList = 10015
|
||||||
|
export const UserEmojiList = 10030
|
||||||
|
export const NWCWalletInfo = 13194
|
||||||
|
export const LightningPubRPC = 21000
|
||||||
|
export const ClientAuth = 22242
|
||||||
|
export const NWCWalletRequest = 23194
|
||||||
|
export const NWCWalletResponse = 23195
|
||||||
|
export const NostrConnect = 24133
|
||||||
|
export const HTTPAuth = 27235
|
||||||
|
export const Followsets = 30000
|
||||||
|
export const Genericlists = 30001
|
||||||
|
export const Relaysets = 30002
|
||||||
|
export const Bookmarksets = 30003
|
||||||
|
export const Curationsets = 30004
|
||||||
|
export const ProfileBadges = 30008
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
|
export const Interestsets = 30015
|
||||||
|
export const CreateOrUpdateStall = 30017
|
||||||
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
export const LongFormArticle = 30023
|
||||||
|
export const DraftLong = 30024
|
||||||
|
export const Emojisets = 30030
|
||||||
|
export const Application = 30078
|
||||||
|
export const LiveEvent = 30311
|
||||||
|
export const UserStatuses = 30315
|
||||||
|
export const ClassifiedListing = 30402
|
||||||
|
export const DraftClassifiedListing = 30403
|
||||||
|
export const Date = 31922
|
||||||
|
export const Time = 31923
|
||||||
|
export const Calendar = 31924
|
||||||
|
export const CalendarEventRSVP = 31925
|
||||||
|
export const Handlerrecommendation = 31989
|
||||||
|
export const Handlerinformation = 31990
|
||||||
|
export const CommunityDefinition = 34550
|
||||||
42
nip04.js
42
nip04.js
@@ -1,42 +0,0 @@
|
|||||||
import aes from 'browserify-cipher'
|
|
||||||
import {Buffer} from 'buffer'
|
|
||||||
import {randomBytes} from '@noble/hashes/utils'
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
|
|
||||||
export function encrypt(privkey, pubkey, text) {
|
|
||||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
|
||||||
const normalizedKey = getNormalizedX(key)
|
|
||||||
|
|
||||||
let iv = Uint8Array.from(randomBytes(16))
|
|
||||||
var cipher = aes.createCipheriv(
|
|
||||||
'aes-256-cbc',
|
|
||||||
Buffer.from(normalizedKey, 'hex'),
|
|
||||||
iv
|
|
||||||
)
|
|
||||||
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
|
|
||||||
encryptedMessage += cipher.final('base64')
|
|
||||||
|
|
||||||
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decrypt(privkey, pubkey, ciphertext) {
|
|
||||||
let [cip, iv] = ciphertext.split('?iv=')
|
|
||||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
|
||||||
let normalizedKey = getNormalizedX(key)
|
|
||||||
|
|
||||||
var decipher = aes.createDecipheriv(
|
|
||||||
'aes-256-cbc',
|
|
||||||
Buffer.from(normalizedKey, 'hex'),
|
|
||||||
Buffer.from(iv, 'base64')
|
|
||||||
)
|
|
||||||
let decryptedMessage = decipher.update(cip, 'base64')
|
|
||||||
decryptedMessage += decipher.final('utf8')
|
|
||||||
|
|
||||||
return decryptedMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNormalizedX(key) {
|
|
||||||
return typeof key === 'string'
|
|
||||||
? key.substr(2, 64)
|
|
||||||
: Buffer.from(key.slice(1, 33)).toString('hex')
|
|
||||||
}
|
|
||||||
50
nip04.test.ts
Normal file
50
nip04.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { encrypt, decrypt } from './nip04.ts'
|
||||||
|
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
globalThis.crypto = crypto
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
|
||||||
|
test('encrypt and decrypt message', async () => {
|
||||||
|
let sk1 = generateSecretKey()
|
||||||
|
let sk2 = generateSecretKey()
|
||||||
|
let pk1 = getPublicKey(sk1)
|
||||||
|
let pk2 = getPublicKey(sk2)
|
||||||
|
|
||||||
|
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
|
||||||
|
|
||||||
|
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt message from go-nostr', async () => {
|
||||||
|
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||||
|
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||||
|
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||||
|
|
||||||
|
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
|
||||||
|
|
||||||
|
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt big payload from go-nostr', async () => {
|
||||||
|
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||||
|
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||||
|
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||||
|
|
||||||
|
let ciphertext =
|
||||||
|
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
|
||||||
|
let plaintext = ''
|
||||||
|
for (let i = 0; i < 800; i++) {
|
||||||
|
plaintext += 'z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
|
||||||
|
})
|
||||||
46
nip04.ts
Normal file
46
nip04.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||||
|
// @ts-ignore
|
||||||
|
crypto.subtle = crypto.webcrypto.subtle
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||||
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
|
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
|
const normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
|
let iv = Uint8Array.from(randomBytes(16))
|
||||||
|
let plaintext = utf8Encoder.encode(text)
|
||||||
|
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
|
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||||
|
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||||
|
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||||
|
|
||||||
|
return `${ctb64}?iv=${ivb64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||||
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
|
let [ctb64, ivb64] = data.split('?iv=')
|
||||||
|
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
|
let normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
|
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
|
let ciphertext = base64.decode(ctb64)
|
||||||
|
let iv = base64.decode(ivb64)
|
||||||
|
|
||||||
|
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||||
|
|
||||||
|
let text = utf8Decoder.decode(plaintext)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||||
|
return key.slice(1, 33)
|
||||||
|
}
|
||||||
28
nip05.js
28
nip05.js
@@ -1,28 +0,0 @@
|
|||||||
import fetch from 'cross-fetch'
|
|
||||||
|
|
||||||
export async function searchDomain(domain, query = '') {
|
|
||||||
try {
|
|
||||||
let res = await (
|
|
||||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return res.names
|
|
||||||
} catch (_) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryName(fullname) {
|
|
||||||
try {
|
|
||||||
let [name, domain] = fullname.split('@')
|
|
||||||
if (!domain) return null
|
|
||||||
|
|
||||||
let res = await (
|
|
||||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return res.names && res.names[name]
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
nip05.test.ts
Normal file
20
nip05.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
|
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||||
|
|
||||||
|
test('fetch nip05 profiles', async () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
let p1 = await queryProfile('jb55.com')
|
||||||
|
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||||
|
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||||
|
|
||||||
|
let p2 = await queryProfile('jb55@jb55.com')
|
||||||
|
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||||
|
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||||
|
|
||||||
|
let p3 = await queryProfile('_@fiatjaf.com')
|
||||||
|
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
|
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||||
|
})
|
||||||
81
nip05.ts
Normal file
81
nip05.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||||
|
*
|
||||||
|
* - 0: full match
|
||||||
|
* - 1: name (optional)
|
||||||
|
* - 2: domain
|
||||||
|
*/
|
||||||
|
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||||
|
try {
|
||||||
|
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||||
|
|
||||||
|
return res.names
|
||||||
|
} catch (_) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||||
|
const match = fullname.match(NIP05_REGEX)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const [_, name = '_', domain] = match
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||||
|
const { names, relays } = parseNIP05Result(await res.json())
|
||||||
|
|
||||||
|
const pubkey = names[name]
|
||||||
|
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||||
|
} catch (_e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nostr.json result. */
|
||||||
|
export interface NIP05Result {
|
||||||
|
names: {
|
||||||
|
[name: string]: string
|
||||||
|
}
|
||||||
|
relays?: {
|
||||||
|
[pubkey: string]: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the nostr.json and throw if it's not valid. */
|
||||||
|
function parseNIP05Result(json: any): NIP05Result {
|
||||||
|
const result: NIP05Result = {
|
||||||
|
names: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||||
|
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||||
|
result.names[name] = pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.relays) {
|
||||||
|
result.relays = {}
|
||||||
|
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||||
|
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||||
|
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
26
nip06.js
26
nip06.js
@@ -1,26 +0,0 @@
|
|||||||
import {wordlist} from 'micro-bip39/wordlists/english'
|
|
||||||
import {
|
|
||||||
generateMnemonic,
|
|
||||||
mnemonicToSeedSync,
|
|
||||||
validateMnemonic
|
|
||||||
} from 'micro-bip39'
|
|
||||||
import {HDKey} from 'micro-bip32'
|
|
||||||
|
|
||||||
export function privateKeyFromSeed(seed) {
|
|
||||||
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
|
|
||||||
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
|
|
||||||
'hex'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seedFromWords(mnemonic) {
|
|
||||||
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSeedWords() {
|
|
||||||
return generateMnemonic(wordlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateWords(words) {
|
|
||||||
return validateMnemonic(words, wordlist)
|
|
||||||
}
|
|
||||||
28
nip06.test.ts
Normal file
28
nip06.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||||
|
|
||||||
|
test('generate private key from a mnemonic', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||||
|
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate private key for account 1 from a mnemonic', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||||
|
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate private key from a mnemonic and passphrase', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const passphrase = '123'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||||
|
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const passphrase = '123'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||||
|
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||||
|
})
|
||||||
19
nip06.ts
Normal file
19
nip06.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||||
|
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||||
|
import { HDKey } from '@scure/bip32'
|
||||||
|
|
||||||
|
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
|
||||||
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
|
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||||
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
|
return bytesToHex(privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSeedWords(): string {
|
||||||
|
return generateMnemonic(wordlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWords(words: string): boolean {
|
||||||
|
return validateMnemonic(words, wordlist)
|
||||||
|
}
|
||||||
229
nip10.test.ts
Normal file
229
nip10.test.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { parse } from './nip10.ts'
|
||||||
|
|
||||||
|
describe('parse NIP10-referenced events', () => {
|
||||||
|
test('legacy + a lot of events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||||
|
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||||
|
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||||
|
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||||
|
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy + 3 events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy + 2 events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy + 1 event', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: undefined,
|
||||||
|
root: {
|
||||||
|
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recommended + 1 event', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||||
|
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||||
|
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||||
|
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||||
|
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
root: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
91
nip10.ts
Normal file
91
nip10.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Event } from './core.ts'
|
||||||
|
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type NIP10Result = {
|
||||||
|
/**
|
||||||
|
* Pointer to the root of the thread.
|
||||||
|
*/
|
||||||
|
root: EventPointer | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer to a "parent" event that parsed event replies to (responded to).
|
||||||
|
*/
|
||||||
|
reply: EventPointer | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointers to events which may or may not be in the reply chain.
|
||||||
|
*/
|
||||||
|
mentions: EventPointer[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of pubkeys that are involved in the thread in no particular order.
|
||||||
|
*/
|
||||||
|
profiles: ProfilePointer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||||
|
const result: NIP10Result = {
|
||||||
|
reply: undefined,
|
||||||
|
root: undefined,
|
||||||
|
mentions: [],
|
||||||
|
profiles: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eTags: string[][] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
|
eTags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
result.profiles.push({
|
||||||
|
pubkey: tag[1],
|
||||||
|
relays: tag[2] ? [tag[2]] : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||||
|
const eTag = eTags[eTagIndex]
|
||||||
|
|
||||||
|
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||||
|
|
||||||
|
const eventPointer: EventPointer = {
|
||||||
|
id: eTagEventId,
|
||||||
|
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirstETag = eTagIndex === 0
|
||||||
|
const isLastETag = eTagIndex === eTags.length - 1
|
||||||
|
|
||||||
|
if (eTagMarker === 'root') {
|
||||||
|
result.root = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eTagMarker === 'reply') {
|
||||||
|
result.reply = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eTagMarker === 'mention') {
|
||||||
|
result.mentions.push(eventPointer)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstETag) {
|
||||||
|
result.root = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLastETag) {
|
||||||
|
result.reply = eventPointer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.mentions.push(eventPointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
16
nip11.test.ts
Normal file
16
nip11.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||||
|
|
||||||
|
describe('requesting relay as for NIP11', () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
test('testing a relay', async () => {
|
||||||
|
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||||
|
expect(info.name).toEqual('nostr.land')
|
||||||
|
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
|
||||||
|
expect(info.fees).toBeTruthy()
|
||||||
|
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||||
|
expect(info.software).toEqual('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
286
nip11.ts
Normal file
286
nip11.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelayInformation(url: string) {
|
||||||
|
return (await (
|
||||||
|
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||||
|
headers: { Accept: 'application/nostr+json' },
|
||||||
|
})
|
||||||
|
).json()) as RelayInformation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Relay Information Document
|
||||||
|
|
||||||
|
* Relays may provide server metadata to clients to inform
|
||||||
|
* them of capabilities, administrative contacts, and
|
||||||
|
* various server attributes. This is made available as a
|
||||||
|
* JSON document over HTTP, on the same URI as the relay's
|
||||||
|
* websocket.
|
||||||
|
|
||||||
|
* Any field may be omitted, and clients MUST ignore any
|
||||||
|
* additional fields they do not understand. Relays MUST
|
||||||
|
* accept CORS requests by sending
|
||||||
|
* `Access-Control-Allow-Origin`,
|
||||||
|
* `Access-Control-Allow-Headers`, and
|
||||||
|
* `Access-Control-Allow-Methods` headers.
|
||||||
|
* @param name string identifying relay
|
||||||
|
* @param description string with detailed information
|
||||||
|
* @param pubkey administrative contact pubkey
|
||||||
|
* @param contact: administrative alternate contact
|
||||||
|
* @param supported_nips a list of NIP numbers supported by
|
||||||
|
* the relay
|
||||||
|
* @param software identifying relay software URL
|
||||||
|
* @param version string version identifier
|
||||||
|
*/
|
||||||
|
export interface BasicRelayInformation {
|
||||||
|
// string identifying relay
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
pubkey: string
|
||||||
|
contact: string
|
||||||
|
supported_nips: number[]
|
||||||
|
software: string
|
||||||
|
version: string
|
||||||
|
// limitation?: Limitations<A, P>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * ## Extra Fields
|
||||||
|
|
||||||
|
* * ### Server Limitations
|
||||||
|
|
||||||
|
* These are limitations imposed by the relay on clients.
|
||||||
|
* Your client should expect that requests which exceed
|
||||||
|
* these practical_ limitations are rejected or fail immediately.
|
||||||
|
* @param max_message_length this is the maximum number of
|
||||||
|
* bytes for incoming JSON that the relay will attempt to
|
||||||
|
* decode and act upon. When you send large subscriptions,
|
||||||
|
* you will be limited by this value. It also effectively
|
||||||
|
* limits the maximum size of any event. Value is calculated
|
||||||
|
* from `[` to `]` and is after UTF-8 serialization (so some
|
||||||
|
* unicode characters will cost 2-3 bytes). It is equal to
|
||||||
|
* the maximum size of the WebSocket message frame.
|
||||||
|
* @param max_subscription total number of subscriptions
|
||||||
|
* that may be active on a single websocket connection to
|
||||||
|
* this relay. It's possible that authenticated clients with
|
||||||
|
* a (paid) relationship to the relay may have higher limits.
|
||||||
|
* @param max_filters maximum number of filter values in
|
||||||
|
* each subscription. Must be one or higher.
|
||||||
|
* @param max_limit the relay server will clamp each
|
||||||
|
* filter's `limit` value to this number.
|
||||||
|
* This means the client won't be able to get more than this
|
||||||
|
* number of events from a single subscription filter. This
|
||||||
|
* clamping is typically done silently by the relay, but
|
||||||
|
* with this number, you can know that there are additional
|
||||||
|
* results if you narrowed your filter's time range or other
|
||||||
|
* parameters.
|
||||||
|
* @param max_subid_length maximum length of subscription id as a
|
||||||
|
* string.
|
||||||
|
* @param min_prefix for `authors` and `ids` filters which
|
||||||
|
* are to match against a hex prefix, you must provide at
|
||||||
|
* least this many hex digits in the prefix.
|
||||||
|
* @param max_event_tags in any event, this is the maximum
|
||||||
|
* number of elements in the `tags` list.
|
||||||
|
* @param max_content_length maximum number of characters in
|
||||||
|
* the `content` field of any event. This is a count of
|
||||||
|
* unicode characters. After serializing into JSON it may be
|
||||||
|
* larger (in bytes), and is still subject to the
|
||||||
|
* max_message_length`, if defined.
|
||||||
|
* @param min_pow_difficulty new events will require at
|
||||||
|
* least this difficulty of PoW, based on [NIP-13](13.md),
|
||||||
|
* or they will be rejected by this server.
|
||||||
|
* @param auth_required this relay requires [NIP-42](42.md)
|
||||||
|
* authentication to happen before a new connection may
|
||||||
|
* perform any other action. Even if set to False,
|
||||||
|
* authentication may be required for specific actions.
|
||||||
|
* @param payment_required this relay requires payment
|
||||||
|
* before a new connection may perform any action.
|
||||||
|
*/
|
||||||
|
export interface Limitations {
|
||||||
|
max_message_length: number
|
||||||
|
max_subscription: number
|
||||||
|
max_filters: number
|
||||||
|
max_limit: number
|
||||||
|
max_subid_length: number
|
||||||
|
min_prefix: number
|
||||||
|
max_event_tags: number
|
||||||
|
max_content_length: number
|
||||||
|
min_pow_difficulty: number
|
||||||
|
auth_required: boolean
|
||||||
|
payment_required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetentionDetails {
|
||||||
|
kinds: (number | number[])[]
|
||||||
|
time?: number | null
|
||||||
|
count?: number | null
|
||||||
|
}
|
||||||
|
type AnyRetentionDetails = RetentionDetails
|
||||||
|
/**
|
||||||
|
* ### Event Retention
|
||||||
|
|
||||||
|
* There may be a cost associated with storing data forever,
|
||||||
|
* so relays may wish to state retention times. The values
|
||||||
|
* stated here are defaults for unauthenticated users and
|
||||||
|
* visitors. Paid users would likely have other policies.
|
||||||
|
|
||||||
|
* Retention times are given in seconds, with `null`
|
||||||
|
* indicating infinity. If zero is provided, this means the
|
||||||
|
* event will not be stored at all, and preferably an error
|
||||||
|
* will be provided when those are received.
|
||||||
|
* ```json
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"retention": [
|
||||||
|
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
|
||||||
|
{ "kinds": [[40000, 49999]], "time": 100 },
|
||||||
|
{ "kinds": [[30000, 39999]], "count": 1000 },
|
||||||
|
{ "time": 3600, "count": 10000 }
|
||||||
|
]
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* @param retention is a list of specifications: each will
|
||||||
|
* apply to either all kinds, or a subset of kinds. Ranges
|
||||||
|
* may be specified for the kind field as a tuple of
|
||||||
|
* inclusive start and end values. Events of indicated kind
|
||||||
|
* (or all) are then limited to a `count` and/or time
|
||||||
|
* period.
|
||||||
|
|
||||||
|
* It is possible to effectively blacklist Nostr-based
|
||||||
|
* protocols that rely on a specific `kind` number, by
|
||||||
|
* giving a retention time of zero for those `kind` values.
|
||||||
|
* While that is unfortunate, it does allow clients to
|
||||||
|
* discover servers that will support their protocol quickly
|
||||||
|
* via a single HTTP fetch.
|
||||||
|
|
||||||
|
* There is no need to specify retention times for
|
||||||
|
* _ephemeral events_ as defined in [NIP-16](16.md) since
|
||||||
|
* they are not retained.
|
||||||
|
*/
|
||||||
|
export interface Retention {
|
||||||
|
retention: AnyRetentionDetails[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some relays may be governed by the arbitrary laws of a
|
||||||
|
* nation state. This may limit what content can be stored
|
||||||
|
* in cleartext on those relays. All clients are encouraged
|
||||||
|
* to use encryption to work around this limitation.
|
||||||
|
|
||||||
|
* It is not possible to describe the limitations of each
|
||||||
|
* country's laws and policies which themselves are
|
||||||
|
* typically vague and constantly shifting.
|
||||||
|
|
||||||
|
* Therefore, this field allows the relay operator to
|
||||||
|
* indicate which countries' laws might end up being
|
||||||
|
* enforced on them, and then indirectly on their users'
|
||||||
|
* content.
|
||||||
|
|
||||||
|
* Users should be able to avoid relays in countries they
|
||||||
|
* don't like, and/or select relays in more favourable
|
||||||
|
* zones. Exposing this flexibility is up to the client
|
||||||
|
* software.
|
||||||
|
|
||||||
|
* @param relay_countries a list of two-level ISO country
|
||||||
|
* codes (ISO 3166-1 alpha-2) whose laws and policies may
|
||||||
|
* affect this relay. `EU` may be used for European Union
|
||||||
|
* countries.
|
||||||
|
|
||||||
|
* Remember that a relay may be hosted in a country which is
|
||||||
|
* not the country of the legal entities who own the relay,
|
||||||
|
* so it's very likely a number of countries are involved.
|
||||||
|
*/
|
||||||
|
export interface ContentLimitations {
|
||||||
|
relay_countries: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ### Community Preferences
|
||||||
|
|
||||||
|
* For public text notes at least, a relay may try to foster
|
||||||
|
* a local community. This would encourage users to follow
|
||||||
|
* the global feed on that relay, in addition to their usual
|
||||||
|
* individual follows. To support this goal, relays MAY
|
||||||
|
* specify some of the following values.
|
||||||
|
|
||||||
|
* @param language_tags is an ordered list of [IETF
|
||||||
|
* language
|
||||||
|
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
|
||||||
|
* indicating the major languages spoken on the relay.
|
||||||
|
* @param tags is a list of limitations on the topics to be
|
||||||
|
* discussed. For example `sfw-only` indicates that only
|
||||||
|
* "Safe For Work" content is encouraged on this relay. This
|
||||||
|
* relies on assumptions of what the "work" "community"
|
||||||
|
* feels "safe" talking about. In time, a common set of tags
|
||||||
|
* may emerge that allow users to find relays that suit
|
||||||
|
* their needs, and client software will be able to parse
|
||||||
|
* these tags easily. The `bitcoin-only` tag indicates that
|
||||||
|
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
|
||||||
|
* be ridiculed without mercy.
|
||||||
|
* @param posting_policy is a link to a human-readable page
|
||||||
|
* which specifies the community policies for the relay. In
|
||||||
|
* cases where `sfw-only` is True, it's important to link to
|
||||||
|
* a page which gets into the specifics of your posting
|
||||||
|
* policy.
|
||||||
|
|
||||||
|
* The `description` field should be used to describe your
|
||||||
|
* community goals and values, in brief. The
|
||||||
|
* `posting_policy` is for additional detail and legal
|
||||||
|
* terms. Use the `tags` field to signify limitations on
|
||||||
|
* content, or topics to be discussed, which could be
|
||||||
|
* machine processed by appropriate client software.
|
||||||
|
*/
|
||||||
|
export interface CommunityPreferences {
|
||||||
|
language_tags: string[]
|
||||||
|
tags: string[]
|
||||||
|
posting_policy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Amount {
|
||||||
|
amount: number
|
||||||
|
unit: 'msat'
|
||||||
|
}
|
||||||
|
export interface PublicationAmount extends Amount {
|
||||||
|
kinds: number[]
|
||||||
|
}
|
||||||
|
export interface Subscription extends Amount {
|
||||||
|
period: number
|
||||||
|
}
|
||||||
|
export interface Fees {
|
||||||
|
admission: Amount[]
|
||||||
|
subscription: Subscription[]
|
||||||
|
publication: PublicationAmount[]
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Relays that require payments may want to expose their fee
|
||||||
|
* schedules.
|
||||||
|
*/
|
||||||
|
export interface PayToRelay {
|
||||||
|
payments_url: string
|
||||||
|
fees: Fees
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL pointing to an image to be used as an icon for the
|
||||||
|
* relay. Recommended to be squared in shape.
|
||||||
|
*/
|
||||||
|
export interface Icon {
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayInformation = BasicRelayInformation &
|
||||||
|
Partial<Retention> & {
|
||||||
|
limitation?: Partial<Limitations>
|
||||||
|
} & Partial<ContentLimitations> &
|
||||||
|
Partial<CommunityPreferences> &
|
||||||
|
Partial<PayToRelay> &
|
||||||
|
Partial<Icon>
|
||||||
25
nip13.test.ts
Normal file
25
nip13.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { getPow, minePow } from './nip13.ts'
|
||||||
|
|
||||||
|
test('identifies proof-of-work difficulty', async () => {
|
||||||
|
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||||
|
const difficulty = getPow(id)
|
||||||
|
expect(difficulty).toEqual(21)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mines POW for an event', async () => {
|
||||||
|
const difficulty = 10
|
||||||
|
|
||||||
|
const event = minePow(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 0,
|
||||||
|
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||||
|
},
|
||||||
|
difficulty,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
|
||||||
|
})
|
||||||
52
nip13.ts
Normal file
52
nip13.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||||
|
|
||||||
|
/** Get POW difficulty from a Nostr hex ID. */
|
||||||
|
export function getPow(hex: string): number {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < hex.length; i++) {
|
||||||
|
const nibble = parseInt(hex[i], 16)
|
||||||
|
if (nibble === 0) {
|
||||||
|
count += 4
|
||||||
|
} else {
|
||||||
|
count += Math.clz32(nibble) - 28
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mine an event with the desired POW. This function mutates the event.
|
||||||
|
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
|
||||||
|
*
|
||||||
|
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
|
||||||
|
*/
|
||||||
|
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const event = unsigned as Omit<Event, 'sig'>
|
||||||
|
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||||
|
|
||||||
|
event.tags.push(tag)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const now = Math.floor(new Date().getTime() / 1000)
|
||||||
|
|
||||||
|
if (now !== event.created_at) {
|
||||||
|
count = 0
|
||||||
|
event.created_at = now
|
||||||
|
}
|
||||||
|
|
||||||
|
tag[1] = (++count).toString()
|
||||||
|
|
||||||
|
event.id = getEventHash(event)
|
||||||
|
|
||||||
|
if (getPow(event.id) >= difficulty) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
102
nip18.test.ts
Normal file
102
nip18.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { Repost, ShortTextNote } from './kinds.ts'
|
||||||
|
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
const relayUrl = 'https://relay.example.com'
|
||||||
|
|
||||||
|
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const repostedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
],
|
||||||
|
content: 'Replied to a post',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('should create a signed event from a minimal template', () => {
|
||||||
|
const template = {
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Repost)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['e', repostedEvent.id, relayUrl],
|
||||||
|
['p', repostedEvent.pubkey],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||||
|
expect(event.created_at).toEqual(template.created_at)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
|
||||||
|
const repostedEventPointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||||
|
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||||
|
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||||
|
|
||||||
|
const repostedEventFromContent = getRepostedEvent(event)
|
||||||
|
|
||||||
|
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should create a signed event from a filled template', () => {
|
||||||
|
const template = {
|
||||||
|
tags: [['nonstandard', 'tag']],
|
||||||
|
content: '' as const,
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Repost)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['nonstandard', 'tag'],
|
||||||
|
['e', repostedEvent.id, relayUrl],
|
||||||
|
['p', repostedEvent.pubkey],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual('')
|
||||||
|
expect(event.created_at).toEqual(template.created_at)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
|
||||||
|
const repostedEventPointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||||
|
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||||
|
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||||
|
|
||||||
|
const repostedEventFromContent = getRepostedEvent(event)
|
||||||
|
|
||||||
|
expect(repostedEventFromContent).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRepostedEventPointer', () => {
|
||||||
|
test('should parse an event with only an `e` tag', () => {
|
||||||
|
const event = buildEvent({
|
||||||
|
kind: Repost,
|
||||||
|
tags: [['e', 'reposted event id', relayUrl]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const repostedEventPointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||||
|
expect(repostedEventPointer!.author).toBeUndefined()
|
||||||
|
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||||
|
})
|
||||||
|
})
|
||||||
97
nip18.ts
Normal file
97
nip18.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||||
|
import { Repost } from './kinds.ts'
|
||||||
|
import { EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type RepostEventTemplate = {
|
||||||
|
/**
|
||||||
|
* Pass only non-nip18 tags if you have to.
|
||||||
|
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
|
||||||
|
*/
|
||||||
|
tags?: string[][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass an empty string to NOT include the stringified JSON of the reposted event.
|
||||||
|
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
|
||||||
|
* @default Stringified JSON of the reposted event
|
||||||
|
*/
|
||||||
|
content?: ''
|
||||||
|
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishRepostEvent(
|
||||||
|
t: RepostEventTemplate,
|
||||||
|
reposted: Event,
|
||||||
|
relayUrl: string,
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
): Event {
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: Repost,
|
||||||
|
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||||
|
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||||
|
if (event.kind !== Repost) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastETag: undefined | string[]
|
||||||
|
let lastPTag: undefined | string[]
|
||||||
|
|
||||||
|
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length >= 2) {
|
||||||
|
if (tag[0] === 'e' && lastETag === undefined) {
|
||||||
|
lastETag = tag
|
||||||
|
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||||
|
lastPTag = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastETag === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lastETag[1],
|
||||||
|
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||||
|
author: lastPTag?.[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetRepostedEventOptions = {
|
||||||
|
skipVerification?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
|
||||||
|
const pointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
if (pointer === undefined || event.content === '') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let repostedEvent: undefined | Event
|
||||||
|
|
||||||
|
try {
|
||||||
|
repostedEvent = JSON.parse(event.content) as Event
|
||||||
|
} catch (error) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repostedEvent.id !== pointer.id) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipVerification && !verifyEvent(repostedEvent)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return repostedEvent
|
||||||
|
}
|
||||||
163
nip19.test.ts
Normal file
163
nip19.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import {
|
||||||
|
decode,
|
||||||
|
naddrEncode,
|
||||||
|
nprofileEncode,
|
||||||
|
npubEncode,
|
||||||
|
nrelayEncode,
|
||||||
|
nsecEncode,
|
||||||
|
neventEncode,
|
||||||
|
type AddressPointer,
|
||||||
|
type ProfilePointer,
|
||||||
|
EventPointer,
|
||||||
|
} from './nip19.ts'
|
||||||
|
|
||||||
|
test('encode and decode nsec', () => {
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let nsec = nsecEncode(sk)
|
||||||
|
expect(nsec).toMatch(/nsec1\w+/)
|
||||||
|
let { type, data } = decode(nsec)
|
||||||
|
expect(type).toEqual('nsec')
|
||||||
|
expect(data).toEqual(sk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode npub', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let npub = npubEncode(pk)
|
||||||
|
expect(npub).toMatch(/npub1\w+/)
|
||||||
|
let { type, data } = decode(npub)
|
||||||
|
expect(type).toEqual('npub')
|
||||||
|
expect(data).toEqual(pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nprofile', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||||
|
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||||
|
let { type, data } = decode(nprofile)
|
||||||
|
expect(type).toEqual('nprofile')
|
||||||
|
const pointer = data as ProfilePointer
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.relays).toContain(relays[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode nprofile without relays', () => {
|
||||||
|
expect(
|
||||||
|
decode(
|
||||||
|
nprofileEncode({
|
||||||
|
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||||
|
relays: [],
|
||||||
|
}),
|
||||||
|
).data,
|
||||||
|
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode naddr', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let naddr = naddrEncode({
|
||||||
|
pubkey: pk,
|
||||||
|
relays,
|
||||||
|
kind: 30023,
|
||||||
|
identifier: 'banana',
|
||||||
|
})
|
||||||
|
expect(naddr).toMatch(/naddr1\w+/)
|
||||||
|
let { type, data } = decode(naddr)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.relays).toContain(relays[1])
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('banana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nevent', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nevent = neventEncode({
|
||||||
|
id: pk,
|
||||||
|
relays,
|
||||||
|
kind: 30023,
|
||||||
|
})
|
||||||
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
|
let { type, data } = decode(nevent)
|
||||||
|
expect(type).toEqual('nevent')
|
||||||
|
const pointer = data as EventPointer
|
||||||
|
expect(pointer.id).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nevent with kind 0', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nevent = neventEncode({
|
||||||
|
id: pk,
|
||||||
|
relays,
|
||||||
|
kind: 0,
|
||||||
|
})
|
||||||
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
|
let { type, data } = decode(nevent)
|
||||||
|
expect(type).toEqual('nevent')
|
||||||
|
const pointer = data as EventPointer
|
||||||
|
expect(pointer.id).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode naddr with empty "d"', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let naddr = naddrEncode({
|
||||||
|
identifier: '',
|
||||||
|
pubkey: pk,
|
||||||
|
relays,
|
||||||
|
kind: 3,
|
||||||
|
})
|
||||||
|
expect(naddr).toMatch(/naddr\w+/)
|
||||||
|
let { type, data } = decode(naddr)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.identifier).toEqual('')
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(3)
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode naddr from habla.news', () => {
|
||||||
|
let { type, data } = decode(
|
||||||
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||||
|
)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('references')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||||
|
let { type, data } = decode(
|
||||||
|
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
|
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||||
|
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('banana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nrelay', () => {
|
||||||
|
let url = 'wss://relay.nostr.example'
|
||||||
|
let nrelay = nrelayEncode(url)
|
||||||
|
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||||
|
let { type, data } = decode(nrelay)
|
||||||
|
expect(type).toEqual('nrelay')
|
||||||
|
expect(data).toEqual(url)
|
||||||
|
})
|
||||||
242
nip19.ts
Normal file
242
nip19.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
const Bech32MaxSize = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bech32 regex.
|
||||||
|
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||||
|
*/
|
||||||
|
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||||
|
|
||||||
|
function integerToUint8Array(number: number) {
|
||||||
|
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
|
||||||
|
const uint8Array = new Uint8Array(4)
|
||||||
|
|
||||||
|
// Use bitwise operations to extract the bytes.
|
||||||
|
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
|
||||||
|
uint8Array[1] = (number >> 16) & 0xff
|
||||||
|
uint8Array[2] = (number >> 8) & 0xff
|
||||||
|
uint8Array[3] = number & 0xff // Least significant byte (LSB)
|
||||||
|
|
||||||
|
return uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfilePointer = {
|
||||||
|
pubkey: string // hex
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPointer = {
|
||||||
|
id: string // hex
|
||||||
|
relays?: string[]
|
||||||
|
author?: string
|
||||||
|
kind?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddressPointer = {
|
||||||
|
identifier: string
|
||||||
|
pubkey: string
|
||||||
|
kind: number
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Prefixes = {
|
||||||
|
nprofile: ProfilePointer
|
||||||
|
nrelay: string
|
||||||
|
nevent: EventPointer
|
||||||
|
naddr: AddressPointer
|
||||||
|
nsec: Uint8Array
|
||||||
|
npub: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||||
|
type: Prefix
|
||||||
|
data: Prefixes[Prefix]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodeResult = {
|
||||||
|
[P in keyof Prefixes]: DecodeValue<P>
|
||||||
|
}[keyof Prefixes]
|
||||||
|
|
||||||
|
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||||
|
export function decode(nip19: string): DecodeResult
|
||||||
|
export function decode(nip19: string): DecodeResult {
|
||||||
|
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||||
|
let data = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
|
switch (prefix) {
|
||||||
|
case 'nprofile': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
|
||||||
|
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'nprofile',
|
||||||
|
data: {
|
||||||
|
pubkey: bytesToHex(tlv[0][0]),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
||||||
|
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||||
|
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||||
|
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'nevent',
|
||||||
|
data: {
|
||||||
|
id: bytesToHex(tlv[0][0]),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||||
|
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'naddr': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
|
||||||
|
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
|
||||||
|
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||||
|
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
|
||||||
|
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'naddr',
|
||||||
|
data: {
|
||||||
|
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||||
|
pubkey: bytesToHex(tlv[2][0]),
|
||||||
|
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nrelay': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'nrelay',
|
||||||
|
data: utf8Decoder.decode(tlv[0][0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nsec':
|
||||||
|
return { type: prefix, data }
|
||||||
|
|
||||||
|
case 'npub':
|
||||||
|
case 'note':
|
||||||
|
return { type: prefix, data: bytesToHex(data) }
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown prefix ${prefix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLV = { [t: number]: Uint8Array[] }
|
||||||
|
|
||||||
|
function parseTLV(data: Uint8Array): TLV {
|
||||||
|
let result: TLV = {}
|
||||||
|
let rest = data
|
||||||
|
while (rest.length > 0) {
|
||||||
|
let t = rest[0]
|
||||||
|
let l = rest[1]
|
||||||
|
let v = rest.slice(2, 2 + l)
|
||||||
|
rest = rest.slice(2 + l)
|
||||||
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
|
result[t] = result[t] || []
|
||||||
|
result[t].push(v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
||||||
|
return encodeBytes('nsec', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function npubEncode(hex: string): `npub1${string}` {
|
||||||
|
return encodeBytes('npub', hexToBytes(hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteEncode(hex: string): `note1${string}` {
|
||||||
|
return encodeBytes('note', hexToBytes(hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||||
|
let words = bech32.toWords(data)
|
||||||
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||||
|
return encodeBech32(prefix, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [hexToBytes(profile.pubkey)],
|
||||||
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
})
|
||||||
|
return encodeBech32('nprofile', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||||
|
let kindArray
|
||||||
|
if (event.kind !== undefined) {
|
||||||
|
kindArray = integerToUint8Array(event.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [hexToBytes(event.id)],
|
||||||
|
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
2: event.author ? [hexToBytes(event.author)] : [],
|
||||||
|
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return encodeBech32('nevent', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||||
|
let kind = new ArrayBuffer(4)
|
||||||
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [utf8Encoder.encode(addr.identifier)],
|
||||||
|
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
2: [hexToBytes(addr.pubkey)],
|
||||||
|
3: [new Uint8Array(kind)],
|
||||||
|
})
|
||||||
|
return encodeBech32('naddr', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [utf8Encoder.encode(url)],
|
||||||
|
})
|
||||||
|
return encodeBech32('nrelay', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
|
Object.entries(tlv)
|
||||||
|
.reverse()
|
||||||
|
.forEach(([t, vs]) => {
|
||||||
|
vs.forEach(v => {
|
||||||
|
let entry = new Uint8Array(v.length + 2)
|
||||||
|
entry.set([parseInt(t)], 0)
|
||||||
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return concatBytes(...entries)
|
||||||
|
}
|
||||||
24
nip21.test.ts
Normal file
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
30
nip21.ts
Normal file
30
nip21.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||||
|
|
||||||
|
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||||
|
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||||
|
|
||||||
|
/** Test whether the value is a Nostr URI. */
|
||||||
|
export function test(value: unknown): value is `nostr:${string}` {
|
||||||
|
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed Nostr URI data. */
|
||||||
|
export interface NostrURI {
|
||||||
|
/** Full URI including the `nostr:` protocol. */
|
||||||
|
uri: `nostr:${string}`
|
||||||
|
/** The bech32-encoded data (eg `npub1...`). */
|
||||||
|
value: string
|
||||||
|
/** Decoded bech32 string, according to NIP-19. */
|
||||||
|
decoded: DecodeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse and decode a Nostr URI. */
|
||||||
|
export function parse(uri: string): NostrURI {
|
||||||
|
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||||
|
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||||
|
return {
|
||||||
|
uri: match[0] as `nostr:${string}`,
|
||||||
|
value: match[1],
|
||||||
|
decoded: decode(match[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
78
nip25.test.ts
Normal file
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],
|
||||||
|
}
|
||||||
|
}
|
||||||
68
nip27.test.ts
Normal file
68
nip27.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchAll, replaceAll } from './nip27.ts'
|
||||||
|
|
||||||
|
test('matchAll', () => {
|
||||||
|
const result = matchAll(
|
||||||
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect([...result]).toEqual([
|
||||||
|
{
|
||||||
|
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||||
|
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||||
|
decoded: {
|
||||||
|
type: 'npub',
|
||||||
|
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||||
|
},
|
||||||
|
start: 6,
|
||||||
|
end: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
decoded: {
|
||||||
|
type: 'note',
|
||||||
|
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||||
|
},
|
||||||
|
start: 78,
|
||||||
|
end: 147,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matchAll with an invalid nip19', () => {
|
||||||
|
const result = matchAll(
|
||||||
|
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect([...result]).toEqual([
|
||||||
|
{
|
||||||
|
decoded: {
|
||||||
|
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||||
|
type: 'note',
|
||||||
|
},
|
||||||
|
end: 193,
|
||||||
|
start: 124,
|
||||||
|
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replaceAll', () => {
|
||||||
|
const content =
|
||||||
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
|
|
||||||
|
const result = replaceAll(content, ({ decoded, value }) => {
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub':
|
||||||
|
return '@alex'
|
||||||
|
case 'note':
|
||||||
|
return '!1234'
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||||
|
})
|
||||||
63
nip27.ts
Normal file
63
nip27.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { decode } from './nip19.ts'
|
||||||
|
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||||
|
|
||||||
|
/** Regex to find NIP-21 URIs inside event content. */
|
||||||
|
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||||
|
|
||||||
|
/** Match result for a Nostr URI in event content. */
|
||||||
|
export interface NostrURIMatch extends NostrURI {
|
||||||
|
/** Index where the URI begins in the event content. */
|
||||||
|
start: number
|
||||||
|
/** Index where the URI ends in the event content. */
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find and decode all NIP-21 URIs. */
|
||||||
|
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||||
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
|
const [uri, value] = match
|
||||||
|
|
||||||
|
yield {
|
||||||
|
uri: uri as `nostr:${string}`,
|
||||||
|
value,
|
||||||
|
decoded: decode(value),
|
||||||
|
start: match.index!,
|
||||||
|
end: match.index! + uri.length,
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all occurrences of Nostr URIs in the text.
|
||||||
|
*
|
||||||
|
* WARNING: using this on an HTML string is potentially unsafe!
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||||
|
* switch(decoded.type) {
|
||||||
|
* case 'npub':
|
||||||
|
* return renderMention(decoded)
|
||||||
|
* case 'note':
|
||||||
|
* return renderNote(decoded)
|
||||||
|
* default:
|
||||||
|
* return value
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||||
|
return content.replaceAll(regex(), (uri, value: string) => {
|
||||||
|
return replacer({
|
||||||
|
uri: uri as `nostr:${string}`,
|
||||||
|
value,
|
||||||
|
decoded: decode(value),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
130
nip28.test.ts
Normal file
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
152
nip28.ts
Normal file
152
nip28.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Event, finalizeEvent } from './pure.ts'
|
||||||
|
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||||
|
|
||||||
|
export interface ChannelMetadata {
|
||||||
|
name: string
|
||||||
|
about: string
|
||||||
|
picture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelCreateEventTemplate {
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMetadataEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMessageEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
reply_to_channel_message_event_id?: string
|
||||||
|
relay_url: string
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelHideMessageEventTemplate {
|
||||||
|
channel_message_event_id: string
|
||||||
|
content: string | { reason: string }
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMuteUserEventTemplate {
|
||||||
|
content: string | { reason: string }
|
||||||
|
created_at: number
|
||||||
|
pubkey_to_mute: string
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelCreation,
|
||||||
|
tags: [...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMetadata,
|
||||||
|
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
|
||||||
|
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
|
||||||
|
|
||||||
|
if (t.reply_to_channel_message_event_id) {
|
||||||
|
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMessage,
|
||||||
|
tags: [...tags, ...(t.tags ?? [])],
|
||||||
|
content: t.content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "e" tag should be the kind 42 event to hide */
|
||||||
|
export const channelHideMessageEvent = (
|
||||||
|
t: ChannelHideMessageEventTemplate,
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelHideMessage,
|
||||||
|
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMuteUser,
|
||||||
|
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
60
nip29.ts
Normal file
60
nip29.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Event } from './pure'
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
picture?: string
|
||||||
|
about?: string
|
||||||
|
relay?: string
|
||||||
|
public?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroup(event: Event): Group {
|
||||||
|
const chan: Partial<Group> = {}
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
switch (tag[0]) {
|
||||||
|
case 'd':
|
||||||
|
chan.id = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
chan.name = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'about':
|
||||||
|
chan.about = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'picture':
|
||||||
|
chan.picture = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
chan.open = true
|
||||||
|
break
|
||||||
|
case 'public':
|
||||||
|
chan.public = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chan as Group
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Member = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMembers(event: Event): Member[] {
|
||||||
|
const members = []
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length < 2) continue
|
||||||
|
if (tag[0] !== 'p') continue
|
||||||
|
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
|
||||||
|
const member: Member = { pubkey: tag[1], permissions: [] }
|
||||||
|
if (tag.length > 2) member.label = tag[2]
|
||||||
|
if (tag.length > 3) member.permissions = tag.slice(3)
|
||||||
|
members.push(member)
|
||||||
|
}
|
||||||
|
return members
|
||||||
|
}
|
||||||
33
nip30.test.ts
Normal file
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 = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||||
|
|
||||||
|
/** Represents a Nostr custom emoji. */
|
||||||
|
export interface CustomEmoji {
|
||||||
|
/** The matched emoji name with colons. */
|
||||||
|
shortcode: `:${string}:`
|
||||||
|
/** The matched emoji name without colons. */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match result for a custom emoji in text content. */
|
||||||
|
export interface CustomEmojiMatch extends CustomEmoji {
|
||||||
|
/** Index where the emoji begins in the text content. */
|
||||||
|
start: number
|
||||||
|
/** Index where the emoji ends in the text content. */
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find all custom emoji shortcodes. */
|
||||||
|
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
|
||||||
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
|
const [shortcode, name] = match
|
||||||
|
|
||||||
|
yield {
|
||||||
|
shortcode: shortcode as `:${string}:`,
|
||||||
|
name,
|
||||||
|
start: match.index!,
|
||||||
|
end: match.index! + shortcode.length,
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all emoji shortcodes in the content. */
|
||||||
|
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
|
||||||
|
return content.replaceAll(regex(), (shortcode, name) => {
|
||||||
|
return replacer({
|
||||||
|
shortcode: shortcode as `:${string}:`,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
15
nip39.test.ts
Normal file
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)
|
||||||
|
})
|
||||||
18
nip39.ts
Normal file
18
nip39.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||||
|
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
44
nip40.test.ts
Normal file
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 }
|
||||||
14
nip42.test.ts
Normal file
14
nip42.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
|
||||||
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
|
import { Relay } from './relay.ts'
|
||||||
|
|
||||||
|
test('auth flow', async () => {
|
||||||
|
const relay = await Relay.connect('wss://nostr.wine')
|
||||||
|
|
||||||
|
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||||
|
expect(auth.tags).toHaveLength(2)
|
||||||
|
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||||
|
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||||
|
expect(auth.kind).toEqual(22242)
|
||||||
|
})
|
||||||
17
nip42.ts
Normal file
17
nip42.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { EventTemplate } from './core.ts'
|
||||||
|
import { ClientAuth } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates an EventTemplate for an AUTH event to be signed.
|
||||||
|
*/
|
||||||
|
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
|
||||||
|
return {
|
||||||
|
kind: ClientAuth,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['relay', relayURL],
|
||||||
|
['challenge', challenge],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
44
nip44.test.ts
Normal file
44
nip44.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { v2 } from './nip44.js'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||||
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
|
const v2vec = vec.v2
|
||||||
|
|
||||||
|
test('get_conversation_key', () => {
|
||||||
|
for (const v of v2vec.valid.get_conversation_key) {
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encrypt_decrypt', () => {
|
||||||
|
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||||
|
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||||
|
expect(ciphertext).toEqual(v.payload)
|
||||||
|
const decrypted = v2.decrypt(ciphertext, key)
|
||||||
|
expect(decrypted).toEqual(v.plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calc_padded_len', () => {
|
||||||
|
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||||
|
const actual = v2.utils.calcPaddedLen(len)
|
||||||
|
expect(actual).toEqual(shouldBePaddedTo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt', async () => {
|
||||||
|
for (const v of v2vec.invalid.decrypt) {
|
||||||
|
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get_conversation_key', async () => {
|
||||||
|
for (const v of v2vec.invalid.get_conversation_key) {
|
||||||
|
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
|
||||||
|
}
|
||||||
|
})
|
||||||
131
nip44.ts
Normal file
131
nip44.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { chacha20 } from '@noble/ciphers/chacha'
|
||||||
|
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
|
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||||
|
import { hmac } from '@noble/hashes/hmac'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const u = {
|
||||||
|
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||||
|
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||||
|
|
||||||
|
utf8Encode: utf8ToBytes,
|
||||||
|
utf8Decode(bytes: Uint8Array) {
|
||||||
|
return decoder.decode(bytes)
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||||
|
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||||
|
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||||
|
ensureBytes(conversationKey, 32)
|
||||||
|
ensureBytes(nonce, 32)
|
||||||
|
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||||
|
return {
|
||||||
|
chacha_key: keys.subarray(0, 32),
|
||||||
|
chacha_nonce: keys.subarray(32, 44),
|
||||||
|
hmac_key: keys.subarray(44, 76),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
calcPaddedLen(len: number): number {
|
||||||
|
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||||
|
if (len <= 32) return 32
|
||||||
|
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||||
|
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||||
|
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
writeU16BE(num: number) {
|
||||||
|
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||||
|
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||||
|
const arr = new Uint8Array(2)
|
||||||
|
new DataView(arr.buffer).setUint16(0, num, false)
|
||||||
|
return arr
|
||||||
|
},
|
||||||
|
|
||||||
|
pad(plaintext: string): Uint8Array {
|
||||||
|
const unpadded = u.utf8Encode(plaintext)
|
||||||
|
const unpaddedLen = unpadded.length
|
||||||
|
const prefix = u.writeU16BE(unpaddedLen)
|
||||||
|
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||||
|
return concatBytes(prefix, unpadded, suffix)
|
||||||
|
},
|
||||||
|
|
||||||
|
unpad(padded: Uint8Array): string {
|
||||||
|
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||||
|
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||||
|
if (
|
||||||
|
unpaddedLen < u.minPlaintextSize ||
|
||||||
|
unpaddedLen > u.maxPlaintextSize ||
|
||||||
|
unpadded.length !== unpaddedLen ||
|
||||||
|
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||||
|
)
|
||||||
|
throw new Error('invalid padding')
|
||||||
|
return u.utf8Decode(unpadded)
|
||||||
|
},
|
||||||
|
|
||||||
|
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||||
|
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||||
|
const combined = concatBytes(aad, message)
|
||||||
|
return hmac(sha256, key, combined)
|
||||||
|
},
|
||||||
|
|
||||||
|
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||||
|
// plaintext: 1b to 0xffff
|
||||||
|
// padded plaintext: 32b to 0xffff
|
||||||
|
// ciphertext: 32b+2 to 0xffff+2
|
||||||
|
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||||
|
// compressed payload (base64): 132b to 87472b
|
||||||
|
decodePayload(payload: string) {
|
||||||
|
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||||
|
const plen = payload.length
|
||||||
|
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||||
|
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||||
|
let data: Uint8Array
|
||||||
|
try {
|
||||||
|
data = base64.decode(payload)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('invalid base64: ' + (error as any).message)
|
||||||
|
}
|
||||||
|
const dlen = data.length
|
||||||
|
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||||
|
const vers = data[0]
|
||||||
|
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||||
|
return {
|
||||||
|
nonce: data.subarray(1, 33),
|
||||||
|
ciphertext: data.subarray(33, -32),
|
||||||
|
mac: data.subarray(-32),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const padded = u.pad(plaintext)
|
||||||
|
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||||
|
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||||
|
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||||
|
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||||
|
return u.unpad(padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const v2 = {
|
||||||
|
utils: u,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { v2 }
|
||||||
605
nip44.vectors.json
Normal file
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
nip47.test.ts
Normal file
65
nip47.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||||
|
import { decrypt } from './nip04.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
globalThis.crypto = crypto
|
||||||
|
|
||||||
|
describe('parseConnectionString', () => {
|
||||||
|
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||||
|
|
||||||
|
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||||
|
expect(relay).toBe('wss://relay.damus.io')
|
||||||
|
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no pubkey in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no relay in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no secret in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeNwcRequestEvent', () => {
|
||||||
|
test('returns a valid NWC request event', async () => {
|
||||||
|
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||||
|
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||||
|
const invoice =
|
||||||
|
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||||
|
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
|
||||||
|
expect(result.kind).toBe(NWCWalletRequest)
|
||||||
|
expect(await decrypt(secret, pubkey, result.content)).toEqual(
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(result.tags).toEqual([['p', pubkey]])
|
||||||
|
expect(result.id).toEqual(expect.any(String))
|
||||||
|
expect(result.sig).toEqual(expect.any(String))
|
||||||
|
})
|
||||||
|
})
|
||||||
34
nip47.ts
Normal file
34
nip47.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { finalizeEvent } from './pure.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
import { encrypt } from './nip04.ts'
|
||||||
|
|
||||||
|
export function parseConnectionString(connectionString: string) {
|
||||||
|
const { pathname, searchParams } = new URL(connectionString)
|
||||||
|
const pubkey = pathname
|
||||||
|
const relay = searchParams.get('relay')
|
||||||
|
const secret = searchParams.get('secret')
|
||||||
|
|
||||||
|
if (!pubkey || !relay || !secret) {
|
||||||
|
throw new Error('invalid connection string')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pubkey, relay, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||||
|
const content = {
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||||
|
const eventTemplate = {
|
||||||
|
kind: NWCWalletRequest,
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: encryptedContent,
|
||||||
|
tags: [['p', pubkey]],
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, secretKey)
|
||||||
|
}
|
||||||
319
nip57.test.ts
Normal file
319
nip57.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { describe, test, expect, mock } from 'bun:test'
|
||||||
|
import { finalizeEvent } from './pure.ts'
|
||||||
|
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
describe('getZapEndpoint', () => {
|
||||||
|
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null if fetch fails', async () => {
|
||||||
|
const fetchImplementation = mock(() => Promise.reject(new Error()))
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null if the response does not allow Nostr payments', async () => {
|
||||||
|
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||||
|
const fetchImplementation = mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
json: () => ({
|
||||||
|
allowsNostr: true,
|
||||||
|
nostrPubkey: 'pubkey',
|
||||||
|
callback: 'callback',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBe('callback')
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeZapRequest', () => {
|
||||||
|
test('throws an error if amount is not given', () => {
|
||||||
|
expect(() =>
|
||||||
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
|
profile: 'profile',
|
||||||
|
event: null,
|
||||||
|
relays: [],
|
||||||
|
comment: '',
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if profile is not given', () => {
|
||||||
|
expect(() =>
|
||||||
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
|
event: null,
|
||||||
|
amount: 100,
|
||||||
|
relays: [],
|
||||||
|
comment: '',
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a valid Zap request', () => {
|
||||||
|
const result = makeZapRequest({
|
||||||
|
profile: 'profile',
|
||||||
|
event: 'event',
|
||||||
|
amount: 100,
|
||||||
|
relays: ['relay1', 'relay2'],
|
||||||
|
comment: 'comment',
|
||||||
|
})
|
||||||
|
expect(result.kind).toBe(9734)
|
||||||
|
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||||
|
expect(result.content).toBe('comment')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['p', 'profile'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
['e', 'event'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateZapRequest', () => {
|
||||||
|
test('returns an error message for invalid JSON', () => {
|
||||||
|
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||||
|
const zapRequest = {
|
||||||
|
kind: 1234,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', 'profile'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = {
|
||||||
|
pubkey: publicKey,
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', 'invalid hex'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['e', 'invalid hex'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a valid Zap request', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeZapReceipt', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt with a preimage', () => {
|
||||||
|
const zapRequest = JSON.stringify(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', target],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const preimage = 'preimage'
|
||||||
|
const bolt11 = 'bolt11'
|
||||||
|
const paidAt = new Date()
|
||||||
|
|
||||||
|
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||||
|
|
||||||
|
expect(result.kind).toBe(9735)
|
||||||
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
|
expect(result.content).toBe('')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['bolt11', bolt11],
|
||||||
|
['description', zapRequest],
|
||||||
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
|
['preimage', preimage],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt without a preimage', () => {
|
||||||
|
const zapRequest = JSON.stringify(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', target],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const bolt11 = 'bolt11'
|
||||||
|
const paidAt = new Date()
|
||||||
|
|
||||||
|
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||||
|
|
||||||
|
expect(result.kind).toBe(9735)
|
||||||
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
|
expect(result.content).toBe('')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['bolt11', bolt11],
|
||||||
|
['description', zapRequest],
|
||||||
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||||
|
})
|
||||||
|
})
|
||||||
130
nip57.ts
Normal file
130
nip57.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||||
|
import { utf8Decoder } from './utils.ts'
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||||
|
try {
|
||||||
|
let lnurl: string = ''
|
||||||
|
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||||
|
if (lud06) {
|
||||||
|
let { words } = bech32.decode(lud06, 1000)
|
||||||
|
let data = bech32.fromWords(words)
|
||||||
|
lnurl = utf8Decoder.decode(data)
|
||||||
|
} else if (lud16) {
|
||||||
|
let [name, domain] = lud16.split('@')
|
||||||
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await _fetch(lnurl)
|
||||||
|
let body = await res.json()
|
||||||
|
|
||||||
|
if (body.allowsNostr && body.nostrPubkey) {
|
||||||
|
return body.callback
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/*-*/
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeZapRequest({
|
||||||
|
profile,
|
||||||
|
event,
|
||||||
|
amount,
|
||||||
|
relays,
|
||||||
|
comment = '',
|
||||||
|
}: {
|
||||||
|
profile: string
|
||||||
|
event: string | null
|
||||||
|
amount: number
|
||||||
|
comment: string
|
||||||
|
relays: string[]
|
||||||
|
}): EventTemplate {
|
||||||
|
if (!amount) throw new Error('amount not given')
|
||||||
|
if (!profile) throw new Error('profile not given')
|
||||||
|
|
||||||
|
let zr: EventTemplate = {
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: comment,
|
||||||
|
tags: [
|
||||||
|
['p', profile],
|
||||||
|
['amount', amount.toString()],
|
||||||
|
['relays', ...relays],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
zr.tags.push(['e', event])
|
||||||
|
}
|
||||||
|
|
||||||
|
return zr
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateZapRequest(zapRequestString: string): string | null {
|
||||||
|
let zapRequest: Event
|
||||||
|
|
||||||
|
try {
|
||||||
|
zapRequest = JSON.parse(zapRequestString)
|
||||||
|
} catch (err) {
|
||||||
|
return 'Invalid zap request JSON.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||||
|
|
||||||
|
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
|
||||||
|
|
||||||
|
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||||
|
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||||
|
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||||
|
|
||||||
|
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||||
|
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||||
|
|
||||||
|
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||||
|
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeZapReceipt({
|
||||||
|
zapRequest,
|
||||||
|
preimage,
|
||||||
|
bolt11,
|
||||||
|
paidAt,
|
||||||
|
}: {
|
||||||
|
zapRequest: string
|
||||||
|
preimage?: string
|
||||||
|
bolt11: string
|
||||||
|
paidAt: Date
|
||||||
|
}): EventTemplate {
|
||||||
|
let zr: Event = JSON.parse(zapRequest)
|
||||||
|
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||||
|
|
||||||
|
let zap: EventTemplate = {
|
||||||
|
kind: 9735,
|
||||||
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preimage) {
|
||||||
|
zap.tags.push(['preimage', preimage])
|
||||||
|
}
|
||||||
|
|
||||||
|
return zap
|
||||||
|
}
|
||||||
350
nip98.test.ts
Normal file
350
nip98.test.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
hashPayload,
|
||||||
|
unpackEventFromToken,
|
||||||
|
validateEvent,
|
||||||
|
validateEventKind,
|
||||||
|
validateEventMethodTag,
|
||||||
|
validateEventPayloadTag,
|
||||||
|
validateEventTimestamp,
|
||||||
|
validateEventUrlTag,
|
||||||
|
validateToken,
|
||||||
|
} from './nip98.ts'
|
||||||
|
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
describe('getToken', () => {
|
||||||
|
test('returns without authorization scheme for GET', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'get'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token without authorization scheme for POST', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token WITH authorization scheme for POST', async () => {
|
||||||
|
const authorizationScheme = 'Nostr '
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token with a valid payload tag when payload is present', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
['payload', payloadHash],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateToken', () => {
|
||||||
|
test('returns true for valid token without authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
|
||||||
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
|
expect(isTokenValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid token with authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid token', async () => {
|
||||||
|
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for missing token', async () => {
|
||||||
|
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.kind = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.created_at = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventTimestamp', () => {
|
||||||
|
test('returns true for valid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventKind', () => {
|
||||||
|
test('returns true for valid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventUrlTag', () => {
|
||||||
|
test('returns true for valid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventMethodTag', () => {
|
||||||
|
test('returns true for valid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventPayloadTag', () => {
|
||||||
|
test('returns true for valid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for missing payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPayload', () => {
|
||||||
|
test('returns hash for valid payload', async () => {
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns hash for empty payload', async () => {
|
||||||
|
const payload = {}
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
})
|
||||||
206
nip98.ts
Normal file
206
nip98.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sign = window.nostr.signEvent
|
||||||
|
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||||
|
*/
|
||||||
|
export async function getToken(
|
||||||
|
loginUrl: string,
|
||||||
|
httpMethod: string,
|
||||||
|
sign: (e: EventTemplate) => Promise<Event> | Event,
|
||||||
|
includeAuthorizationScheme: boolean = false,
|
||||||
|
payload?: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
const event: EventTemplate = {
|
||||||
|
kind: HTTPAuth,
|
||||||
|
tags: [
|
||||||
|
['u', loginUrl],
|
||||||
|
['method', httpMethod],
|
||||||
|
],
|
||||||
|
created_at: Math.round(new Date().getTime() / 1000),
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
event.tags.push(['payload', hashPayload(payload)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await sign(event)
|
||||||
|
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||||
|
|
||||||
|
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||||
|
*/
|
||||||
|
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||||
|
const event = await unpackEventFromToken(token).catch(error => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
const valid = await validateEvent(event, url, method).catch(error => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks an event from a token.
|
||||||
|
*
|
||||||
|
* @param token - The token to unpack.
|
||||||
|
* @returns A promise that resolves to the unpacked event.
|
||||||
|
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||||
|
*/
|
||||||
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Missing token')
|
||||||
|
}
|
||||||
|
|
||||||
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
|
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||||
|
throw new Error('Invalid token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(eventB64) as Event
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the timestamp of an event.
|
||||||
|
* @param event - The event object to validate.
|
||||||
|
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||||
|
*/
|
||||||
|
export function validateEventTimestamp(event: Event): boolean {
|
||||||
|
if (!event.created_at) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the kind of an event.
|
||||||
|
* @param event The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event kind is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventKind(event: Event): boolean {
|
||||||
|
return event.kind === HTTPAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given URL matches the URL tag of the event.
|
||||||
|
* @param event - The event object.
|
||||||
|
* @param url - The URL to validate.
|
||||||
|
* @returns A boolean indicating whether the URL is valid or not.
|
||||||
|
*/
|
||||||
|
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||||
|
|
||||||
|
if (!urlTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlTag.length > 0 && urlTag[1] === url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given event has a method tag that matches the specified method.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @param method - The method to match against the method tag.
|
||||||
|
* @returns A boolean indicating whether the event has a matching method tag.
|
||||||
|
*/
|
||||||
|
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||||
|
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||||
|
|
||||||
|
if (!methodTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the hash of a payload.
|
||||||
|
* @param payload - The payload to be hashed.
|
||||||
|
* @returns The hash value as a string.
|
||||||
|
*/
|
||||||
|
export function hashPayload(payload: any): string {
|
||||||
|
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||||
|
return bytesToHex(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the event payload tag against the provided payload.
|
||||||
|
* @param event The event object.
|
||||||
|
* @param payload The payload to validate.
|
||||||
|
* @returns A boolean indicating whether the payload tag is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||||
|
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||||
|
|
||||||
|
if (!payloadTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Nostr event for the NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @param event - The Nostr event to validate.
|
||||||
|
* @param url - The URL associated with the event.
|
||||||
|
* @param method - The HTTP method associated with the event.
|
||||||
|
* @param body - The request body associated with the event (optional).
|
||||||
|
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||||
|
* @throws An error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||||
|
if (!verifyEvent(event)) {
|
||||||
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventKind(event)) {
|
||||||
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventTimestamp(event)) {
|
||||||
|
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventUrlTag(event, url)) {
|
||||||
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventMethodTag(event, method)) {
|
||||||
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||||
|
if (!validateEventPayloadTag(event, body)) {
|
||||||
|
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Event } from './core'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
|
||||||
|
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('should return true for a valid classified listing event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "d" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "title" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
// Missing 'title' tag
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "summary" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
// Missing 'summary' tag
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
// Missing 'published_at' tag
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "location" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
// Missing 'location' tag
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
// Missing 'price' tag
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', 'not-a-valid-timestamp'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid price', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', 'not-a-valid-price', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid currency', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'not-a-valid-currency'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['a', 'extra1'],
|
||||||
|
['a', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const event2: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['e', 'extra1'],
|
||||||
|
['e', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
expect(validateEvent(event2)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseEvent', () => {
|
||||||
|
test('should parse a valid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedListing = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
e: ['value1', 'value2'],
|
||||||
|
a: ['value1', 'value2'],
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseEvent(event)).toEqual(expectedListing)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw an error for an invalid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseEvent(event)).toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateEventTemplate', () => {
|
||||||
|
test('should generate the correct event template for a classified listing', () => {
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
extra1: 'value1',
|
||||||
|
extra2: 'value2',
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEventTemplate = {
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['extra1', 'value1'],
|
||||||
|
['extra2', 'value2'],
|
||||||
|
],
|
||||||
|
created_at: expect.any(Number),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the details of a price.
|
||||||
|
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||||
|
* @example { amount: '100', currency: 'EUR' }
|
||||||
|
*/
|
||||||
|
export type PriceDetails = {
|
||||||
|
/**
|
||||||
|
* The amount of the price.
|
||||||
|
*/
|
||||||
|
amount: string
|
||||||
|
/**
|
||||||
|
* The currency of the price in 3-letter ISO 4217 format.
|
||||||
|
* @example 'USD'
|
||||||
|
*/
|
||||||
|
currency: string
|
||||||
|
/**
|
||||||
|
* The optional frequency of payment.
|
||||||
|
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||||
|
*/
|
||||||
|
frequency?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a classified listing object.
|
||||||
|
*/
|
||||||
|
export type ClassifiedListingObject = {
|
||||||
|
/**
|
||||||
|
* Whether the listing is a draft or not.
|
||||||
|
*/
|
||||||
|
isDraft: boolean
|
||||||
|
/**
|
||||||
|
* A title of the listing.
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* A short summary or tagline.
|
||||||
|
*/
|
||||||
|
summary: string
|
||||||
|
/**
|
||||||
|
* A description in Markdown format.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
/**
|
||||||
|
* Timestamp in unix seconds of when the listing was published.
|
||||||
|
*/
|
||||||
|
publishedAt: string
|
||||||
|
/**
|
||||||
|
* Location of the listing.
|
||||||
|
* @example 'NYC'
|
||||||
|
*/
|
||||||
|
location: string
|
||||||
|
/**
|
||||||
|
* Price details.
|
||||||
|
*/
|
||||||
|
price: PriceDetails
|
||||||
|
/**
|
||||||
|
* Images of the listing with optional dimensions.
|
||||||
|
*/
|
||||||
|
images: Array<{
|
||||||
|
url: string
|
||||||
|
dimensions?: string
|
||||||
|
}>
|
||||||
|
/**
|
||||||
|
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||||
|
*/
|
||||||
|
hashtags: string[]
|
||||||
|
/**
|
||||||
|
* Other standard tags.
|
||||||
|
* @example "g", a geohash for more precise location
|
||||||
|
*/
|
||||||
|
additionalTags: Record<string, string | string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an event to ensure it is a valid classified listing event.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateEvent(event: Event): boolean {
|
||||||
|
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||||
|
const requiredTagCount = requiredTags.length
|
||||||
|
const tagCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (event.tags.length < requiredTagCount) return false
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag.length < 2) return false
|
||||||
|
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'published_at') {
|
||||||
|
const timestamp = parseInt(tagValues[0])
|
||||||
|
if (isNaN(timestamp)) return false
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
if (tagValues.length < 2) return false
|
||||||
|
|
||||||
|
const price = parseInt(tagValues[0])
|
||||||
|
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||||
|
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredTags.includes(tagName)) {
|
||||||
|
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an event and returns a classified listing object.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The classified listing object.
|
||||||
|
* @throws Error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||||
|
if (!validateEvent(event)) {
|
||||||
|
throw new Error('Invalid event')
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
isDraft: event.kind === DraftClassifiedListing,
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: event.content,
|
||||||
|
publishedAt: '',
|
||||||
|
location: '',
|
||||||
|
price: {
|
||||||
|
amount: '',
|
||||||
|
currency: '',
|
||||||
|
},
|
||||||
|
images: [],
|
||||||
|
hashtags: [],
|
||||||
|
additionalTags: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'title') {
|
||||||
|
listing.title = tagValues[0]
|
||||||
|
} else if (tagName == 'summary') {
|
||||||
|
listing.summary = tagValues[0]
|
||||||
|
} else if (tagName == 'published_at') {
|
||||||
|
listing.publishedAt = tagValues[0]
|
||||||
|
} else if (tagName == 'location') {
|
||||||
|
listing.location = tagValues[0]
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
listing.price.amount = tagValues[0]
|
||||||
|
listing.price.currency = tagValues[1]
|
||||||
|
|
||||||
|
if (tagValues.length == 3) {
|
||||||
|
listing.price.frequency = tagValues[2]
|
||||||
|
}
|
||||||
|
} else if (tagName == 'image') {
|
||||||
|
listing.images.push({
|
||||||
|
url: tagValues[0],
|
||||||
|
dimensions: tagValues?.[1] ?? undefined,
|
||||||
|
})
|
||||||
|
} else if (tagName == 't') {
|
||||||
|
listing.hashtags.push(tagValues[0])
|
||||||
|
} else if (tagName == 'e' || tagName == 'a') {
|
||||||
|
listing.additionalTags[tagName] = [...tagValues]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template based on a classified listing object.
|
||||||
|
*
|
||||||
|
* @param listing - The classified listing object.
|
||||||
|
* @returns The event template.
|
||||||
|
*/
|
||||||
|
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||||
|
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||||
|
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||||
|
['title', listing.title],
|
||||||
|
['published_at', listing.publishedAt],
|
||||||
|
['summary', listing.summary],
|
||||||
|
['location', listing.location],
|
||||||
|
priceTag,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.images.length; i++) {
|
||||||
|
const image = listing.images[i]
|
||||||
|
const imageTag = ['image', image.url]
|
||||||
|
if (image.dimensions) imageTag.push(image.dimensions)
|
||||||
|
|
||||||
|
tags.push(imageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||||
|
const t = listing.hashtags[i]
|
||||||
|
|
||||||
|
tags.push(['t', t])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const val = value[i]
|
||||||
|
|
||||||
|
tags.push([key, val])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tags.push([key, value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||||
|
content: listing.content,
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
228
package.json
228
package.json
@@ -1,36 +1,224 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "0.23.0",
|
"version": "2.1.4",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/fiatjaf/nostr-tools.git"
|
"url": "https://github.com/nbd-wtf/nostr-tools.git"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"sideEffects": false,
|
||||||
|
"module": "./lib/esm/index.js",
|
||||||
|
"main": "./lib/cjs/index.js",
|
||||||
|
"types": "./lib/types/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./lib/esm/index.js",
|
||||||
|
"require": "./lib/cjs/index.js",
|
||||||
|
"types": "./lib/types/index.d.ts"
|
||||||
|
},
|
||||||
|
"./pure": {
|
||||||
|
"import": "./lib/esm/pure.js",
|
||||||
|
"require": "./lib/cjs/pure.js",
|
||||||
|
"types": "./lib/types/pure.d.ts"
|
||||||
|
},
|
||||||
|
"./wasm": {
|
||||||
|
"import": "./lib/esm/wasm.js",
|
||||||
|
"require": "./lib/cjs/wasm.js",
|
||||||
|
"types": "./lib/types/wasm.d.ts"
|
||||||
|
},
|
||||||
|
"./kinds": {
|
||||||
|
"import": "./lib/esm/kinds.js",
|
||||||
|
"require": "./lib/cjs/kinds.js",
|
||||||
|
"types": "./lib/types/kinds.d.ts"
|
||||||
|
},
|
||||||
|
"./filter": {
|
||||||
|
"import": "./lib/esm/filter.js",
|
||||||
|
"require": "./lib/cjs/filter.js",
|
||||||
|
"types": "./lib/types/filter.d.ts"
|
||||||
|
},
|
||||||
|
"./abstract-relay": {
|
||||||
|
"import": "./lib/esm/abstract-relay.js",
|
||||||
|
"require": "./lib/cjs/abstract-relay.js",
|
||||||
|
"types": "./lib/types/abstract-relay.d.ts"
|
||||||
|
},
|
||||||
|
"./relay": {
|
||||||
|
"import": "./lib/esm/relay.js",
|
||||||
|
"require": "./lib/cjs/relay.js",
|
||||||
|
"types": "./lib/types/relay.d.ts"
|
||||||
|
},
|
||||||
|
"./abstract-pool": {
|
||||||
|
"import": "./lib/esm/abstract-pool.js",
|
||||||
|
"require": "./lib/cjs/abstract-pool.js",
|
||||||
|
"types": "./lib/types/abstract-pool.d.ts"
|
||||||
|
},
|
||||||
|
"./pool": {
|
||||||
|
"import": "./lib/esm/pool.js",
|
||||||
|
"require": "./lib/cjs/pool.js",
|
||||||
|
"types": "./lib/types/pool.d.ts"
|
||||||
|
},
|
||||||
|
"./references": {
|
||||||
|
"import": "./lib/esm/references.js",
|
||||||
|
"require": "./lib/cjs/references.js",
|
||||||
|
"types": "./lib/types/references.d.ts"
|
||||||
|
},
|
||||||
|
"./nip04": {
|
||||||
|
"import": "./lib/esm/nip04.js",
|
||||||
|
"require": "./lib/cjs/nip04.js",
|
||||||
|
"types": "./lib/types/nip04.d.ts"
|
||||||
|
},
|
||||||
|
"./nip44": {
|
||||||
|
"import": "./lib/esm/nip44.js",
|
||||||
|
"require": "./lib/cjs/nip44.js",
|
||||||
|
"types": "./lib/types/nip44.d.ts"
|
||||||
|
},
|
||||||
|
"./nip05": {
|
||||||
|
"import": "./lib/esm/nip05.js",
|
||||||
|
"require": "./lib/cjs/nip05.js",
|
||||||
|
"types": "./lib/types/nip05.d.ts"
|
||||||
|
},
|
||||||
|
"./nip06": {
|
||||||
|
"import": "./lib/esm/nip06.js",
|
||||||
|
"require": "./lib/cjs/nip06.js",
|
||||||
|
"types": "./lib/types/nip06.d.ts"
|
||||||
|
},
|
||||||
|
"./nip10": {
|
||||||
|
"import": "./lib/esm/nip10.js",
|
||||||
|
"require": "./lib/cjs/nip10.js",
|
||||||
|
"types": "./lib/types/nip10.d.ts"
|
||||||
|
},
|
||||||
|
"./nip11": {
|
||||||
|
"import": "./lib/esm/nip11.js",
|
||||||
|
"require": "./lib/cjs/nip11.js",
|
||||||
|
"types": "./lib/types/nip11.d.ts"
|
||||||
|
},
|
||||||
|
"./nip13": {
|
||||||
|
"import": "./lib/esm/nip13.js",
|
||||||
|
"require": "./lib/cjs/nip13.js",
|
||||||
|
"types": "./lib/types/nip13.d.ts"
|
||||||
|
},
|
||||||
|
"./nip18": {
|
||||||
|
"import": "./lib/esm/nip18.js",
|
||||||
|
"require": "./lib/cjs/nip18.js",
|
||||||
|
"types": "./lib/types/nip18.d.ts"
|
||||||
|
},
|
||||||
|
"./nip19": {
|
||||||
|
"import": "./lib/esm/nip19.js",
|
||||||
|
"require": "./lib/cjs/nip19.js",
|
||||||
|
"types": "./lib/types/nip19.d.ts"
|
||||||
|
},
|
||||||
|
"./nip21": {
|
||||||
|
"import": "./lib/esm/nip21.js",
|
||||||
|
"require": "./lib/cjs/nip21.js",
|
||||||
|
"types": "./lib/types/nip21.d.ts"
|
||||||
|
},
|
||||||
|
"./nip25": {
|
||||||
|
"import": "./lib/esm/nip25.js",
|
||||||
|
"require": "./lib/cjs/nip25.js",
|
||||||
|
"types": "./lib/types/nip25.d.ts"
|
||||||
|
},
|
||||||
|
"./nip27": {
|
||||||
|
"import": "./lib/esm/nip27.js",
|
||||||
|
"require": "./lib/cjs/nip27.js",
|
||||||
|
"types": "./lib/types/nip27.d.ts"
|
||||||
|
},
|
||||||
|
"./nip28": {
|
||||||
|
"import": "./lib/esm/nip28.js",
|
||||||
|
"require": "./lib/cjs/nip28.js",
|
||||||
|
"types": "./lib/types/nip28.d.ts"
|
||||||
|
},
|
||||||
|
"./nip29": {
|
||||||
|
"import": "./lib/esm/nip29.js",
|
||||||
|
"require": "./lib/cjs/nip29.js",
|
||||||
|
"types": "./lib/types/nip29.d.ts"
|
||||||
|
},
|
||||||
|
"./nip30": {
|
||||||
|
"import": "./lib/esm/nip30.js",
|
||||||
|
"require": "./lib/cjs/nip30.js",
|
||||||
|
"types": "./lib/types/nip30.d.ts"
|
||||||
|
},
|
||||||
|
"./nip39": {
|
||||||
|
"import": "./lib/esm/nip39.js",
|
||||||
|
"require": "./lib/cjs/nip39.js",
|
||||||
|
"types": "./lib/types/nip39.d.ts"
|
||||||
|
},
|
||||||
|
"./nip42": {
|
||||||
|
"import": "./lib/esm/nip42.js",
|
||||||
|
"require": "./lib/cjs/nip42.js",
|
||||||
|
"types": "./lib/types/nip42.d.ts"
|
||||||
|
},
|
||||||
|
"./nip57": {
|
||||||
|
"import": "./lib/esm/nip57.js",
|
||||||
|
"require": "./lib/cjs/nip57.js",
|
||||||
|
"types": "./lib/types/nip57.d.ts"
|
||||||
|
},
|
||||||
|
"./nip98": {
|
||||||
|
"import": "./lib/esm/nip98.js",
|
||||||
|
"require": "./lib/cjs/nip98.js",
|
||||||
|
"types": "./lib/types/nip98.d.ts"
|
||||||
|
},
|
||||||
|
"./fakejson": {
|
||||||
|
"import": "./lib/esm/fakejson.js",
|
||||||
|
"require": "./lib/cjs/fakejson.js",
|
||||||
|
"types": "./lib/types/fakejson.d.ts"
|
||||||
|
},
|
||||||
|
"./utils": {
|
||||||
|
"import": "./lib/esm/utils.js",
|
||||||
|
"require": "./lib/cjs/utils.js",
|
||||||
|
"types": "./lib/types/utils.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^0.5.7",
|
"@noble/ciphers": "0.2.0",
|
||||||
"@noble/secp256k1": "^1.5.2",
|
"@noble/curves": "1.2.0",
|
||||||
"browserify-cipher": ">=1",
|
"@noble/hashes": "1.3.1",
|
||||||
"buffer": ">=5",
|
"@scure/base": "1.1.1",
|
||||||
"create-hash": "^1.2.0",
|
"@scure/bip32": "1.3.1",
|
||||||
"cross-fetch": "^3.1.4",
|
"@scure/bip39": "1.2.1"
|
||||||
"micro-bip32": "^0.1.0",
|
},
|
||||||
"micro-bip39": "^0.1.3",
|
"optionalDependencies": {
|
||||||
"websocket-polyfill": "^0.0.3"
|
"nostr-wasm": "v0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"decentralization",
|
"decentralization",
|
||||||
"twitter",
|
|
||||||
"p2p",
|
|
||||||
"mastodon",
|
|
||||||
"ssb",
|
|
||||||
"social",
|
"social",
|
||||||
"unstoppable",
|
|
||||||
"censorship",
|
|
||||||
"censorship-resistance",
|
"censorship-resistance",
|
||||||
"client"
|
"client",
|
||||||
|
"nostr"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.5.0",
|
"@types/node": "^18.13.0",
|
||||||
"eslint-plugin-babel": "^5.3.1"
|
"@types/node-fetch": "^2.6.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||||
|
"@typescript-eslint/parser": "^6.5.0",
|
||||||
|
"bun-types": "^1.0.18",
|
||||||
|
"esbuild": "0.16.9",
|
||||||
|
"esbuild-plugin-alias": "^0.2.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
|
"esm-loader-typescript": "^1.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"mitata": "^0.1.6",
|
||||||
|
"mock-socket": "^9.3.1",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"tsd": "^0.22.0",
|
||||||
|
"typescript": "^5.0.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepublish": "just build && just emit-types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
pool.js
204
pool.js
@@ -1,204 +0,0 @@
|
|||||||
import {getEventHash, verifySignature, signEvent} from './event'
|
|
||||||
import {relayConnect, normalizeRelayURL} from './relay'
|
|
||||||
|
|
||||||
export function relayPool() {
|
|
||||||
var globalPrivateKey
|
|
||||||
var globalSigningFunction
|
|
||||||
|
|
||||||
const poolPolicy = {
|
|
||||||
// setting this to a number will cause events to be published to a random
|
|
||||||
// set of relays only, instead of publishing to all relays all the time
|
|
||||||
randomChoice: null,
|
|
||||||
|
|
||||||
// setting this to true will cause .publish() calls to wait until the event has
|
|
||||||
// been published -- or at least attempted to be published -- to all relays
|
|
||||||
wait: false
|
|
||||||
}
|
|
||||||
const relays = {}
|
|
||||||
const noticeCallbacks = []
|
|
||||||
|
|
||||||
function propagateNotice(notice, relayURL) {
|
|
||||||
for (let i = 0; i < noticeCallbacks.length; i++) {
|
|
||||||
let {relay} = relays[relayURL]
|
|
||||||
noticeCallbacks[i](notice, relay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSubscriptions = {}
|
|
||||||
|
|
||||||
const sub = (
|
|
||||||
{cb, filter, beforeSend},
|
|
||||||
id = Math.random().toString().slice(2)
|
|
||||||
) => {
|
|
||||||
const subControllers = Object.fromEntries(
|
|
||||||
Object.values(relays)
|
|
||||||
.filter(({policy}) => policy.read)
|
|
||||||
.map(({relay}) => [
|
|
||||||
relay.url,
|
|
||||||
relay.sub({filter, cb: event => cb(event, relay.url), beforeSend}, id)
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeCallback = cb
|
|
||||||
const activeFilters = filter
|
|
||||||
const activeBeforeSend = beforeSend
|
|
||||||
|
|
||||||
const unsub = () => {
|
|
||||||
Object.values(subControllers).forEach(sub => sub.unsub())
|
|
||||||
delete activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const sub = ({
|
|
||||||
cb = activeCallback,
|
|
||||||
filter = activeFilters,
|
|
||||||
beforeSend = activeBeforeSend
|
|
||||||
}) => {
|
|
||||||
Object.entries(subControllers).map(([relayURL, sub]) => [
|
|
||||||
relayURL,
|
|
||||||
sub.sub({cb, filter, beforeSend}, id)
|
|
||||||
])
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const addRelay = relay => {
|
|
||||||
subControllers[relay.url] = relay.sub({cb, filter}, id)
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const removeRelay = relayURL => {
|
|
||||||
if (relayURL in subControllers) {
|
|
||||||
subControllers[relayURL].unsub()
|
|
||||||
if (Object.keys(subControllers).length === 0) unsub()
|
|
||||||
}
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSubscriptions[id] = {
|
|
||||||
sub,
|
|
||||||
unsub,
|
|
||||||
addRelay,
|
|
||||||
removeRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub,
|
|
||||||
relays,
|
|
||||||
setPrivateKey(privateKey) {
|
|
||||||
globalPrivateKey = privateKey
|
|
||||||
},
|
|
||||||
registerSigningFunction(fn) {
|
|
||||||
globalSigningFunction = fn
|
|
||||||
},
|
|
||||||
setPolicy(key, value) {
|
|
||||||
poolPolicy[key] = value
|
|
||||||
},
|
|
||||||
addRelay(url, policy = {read: true, write: true}) {
|
|
||||||
let relayURL = normalizeRelayURL(url)
|
|
||||||
if (relayURL in relays) return
|
|
||||||
|
|
||||||
let relay = relayConnect(url, notice => {
|
|
||||||
propagateNotice(notice, relayURL)
|
|
||||||
})
|
|
||||||
relays[relayURL] = {relay, policy}
|
|
||||||
|
|
||||||
if (policy.read) {
|
|
||||||
Object.values(activeSubscriptions).forEach(subscription =>
|
|
||||||
subscription.addRelay(relay)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return relay
|
|
||||||
},
|
|
||||||
removeRelay(url) {
|
|
||||||
let relayURL = normalizeRelayURL(url)
|
|
||||||
let data = relays[relayURL]
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
let {relay} = data
|
|
||||||
Object.values(activeSubscriptions).forEach(subscription =>
|
|
||||||
subscription.removeRelay(relay)
|
|
||||||
)
|
|
||||||
relay.close()
|
|
||||||
delete relays[relayURL]
|
|
||||||
},
|
|
||||||
onNotice(cb) {
|
|
||||||
noticeCallbacks.push(cb)
|
|
||||||
},
|
|
||||||
offNotice(cb) {
|
|
||||||
let index = noticeCallbacks.indexOf(cb)
|
|
||||||
if (index !== -1) noticeCallbacks.splice(index, 1)
|
|
||||||
},
|
|
||||||
async publish(event, statusCallback) {
|
|
||||||
event.id = getEventHash(event)
|
|
||||||
|
|
||||||
if (!event.sig) {
|
|
||||||
event.tags = event.tags || []
|
|
||||||
|
|
||||||
if (globalPrivateKey) {
|
|
||||||
event.sig = await signEvent(event, globalPrivateKey)
|
|
||||||
} else if (globalSigningFunction) {
|
|
||||||
event.sig = await globalSigningFunction(event)
|
|
||||||
if (!event.sig) {
|
|
||||||
// abort here
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// check
|
|
||||||
if (!(await verifySignature(event)))
|
|
||||||
throw new Error(
|
|
||||||
'signature provided by custom signing function is invalid.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let writeable = Object.values(relays)
|
|
||||||
.filter(({policy}) => policy.write)
|
|
||||||
.sort(() => Math.random() - 0.5) // random
|
|
||||||
|
|
||||||
let maxTargets = poolPolicy.randomChoice
|
|
||||||
? poolPolicy.randomChoice
|
|
||||||
: writeable.length
|
|
||||||
|
|
||||||
let successes = 0
|
|
||||||
|
|
||||||
if (poolPolicy.wait) {
|
|
||||||
for (let i = 0; i < writeable.length; i++) {
|
|
||||||
let {relay} = writeable[i]
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
await relay.publish(event, status => {
|
|
||||||
if (statusCallback) statusCallback(status, relay.url)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
if (statusCallback) statusCallback(-1, relay.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
successes++
|
|
||||||
if (successes >= maxTargets) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
/***/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
writeable.forEach(async ({relay}) => {
|
|
||||||
let callback = statusCallback
|
|
||||||
? status => statusCallback(status, relay.url)
|
|
||||||
: null
|
|
||||||
relay.publish(event, callback)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
123
pool.test.ts
Normal file
123
pool.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { test, expect, afterAll } from 'bun:test'
|
||||||
|
|
||||||
|
import { finalizeEvent, type Event } from './pure.ts'
|
||||||
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
|
let pool = new SimplePool()
|
||||||
|
|
||||||
|
let mockRelays = [newMockRelay(), newMockRelay(), newMockRelay(), newMockRelay()]
|
||||||
|
let relays = mockRelays.map(mr => mr.url)
|
||||||
|
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||||
|
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
pool.close(relays)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removing duplicates when subscribing', async () => {
|
||||||
|
let priv = generateSecretKey()
|
||||||
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
|
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||||
|
onevent(event: Event) {
|
||||||
|
// this should be called only once even though we're listening
|
||||||
|
// to multiple relays because the events will be caught and
|
||||||
|
// deduplicated efficiently (without even being parsed)
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let received: Event[] = []
|
||||||
|
|
||||||
|
let event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: 'test',
|
||||||
|
kind: 22345,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
priv,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relays, event))
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
expect(received[0]).toEqual(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('same with double subs', async () => {
|
||||||
|
let priv = generateSecretKey()
|
||||||
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
|
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||||
|
onevent(event) {
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||||
|
onevent(event) {
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let received: Event[] = []
|
||||||
|
|
||||||
|
let event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: 'test2',
|
||||||
|
kind: 22346,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
priv,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relays, event))
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
expect(received).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('query a bunch of events and cancel on eose', async () => {
|
||||||
|
let events = new Set<string>()
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
pool.subscribeManyEose(
|
||||||
|
[...relays, ...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||||
|
[{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
events.add(event.id)
|
||||||
|
},
|
||||||
|
onclose: resolve as any,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect(events.size).toBeGreaterThan(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('querySync()', async () => {
|
||||||
|
let events = await pool.querySync(
|
||||||
|
[...relays.slice(0, 2), ...relays.slice(0, 2), 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||||
|
{
|
||||||
|
authors: authors.slice(0, 2),
|
||||||
|
kinds: [1],
|
||||||
|
limit: 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// the actual received number will be greater than 2, but there will be no duplicates
|
||||||
|
expect(events.length).toBeGreaterThan(2)
|
||||||
|
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||||
|
expect(events).toHaveLength(uniqueEventCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get()', async () => {
|
||||||
|
let event = await pool.get(relays, {
|
||||||
|
ids: [ids[0]],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(event).not.toBeNull()
|
||||||
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
|
})
|
||||||
10
pool.ts
Normal file
10
pool.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||||
|
|
||||||
|
export class SimplePool extends AbstractSimplePool {
|
||||||
|
constructor() {
|
||||||
|
super({ verifyEvent })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './abstract-pool.ts'
|
||||||
59
pure.ts
Normal file
59
pure.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
class JS implements Nostr {
|
||||||
|
generateSecretKey(): Uint8Array {
|
||||||
|
return schnorr.utils.randomPrivateKey()
|
||||||
|
}
|
||||||
|
getPublicKey(secretKey: Uint8Array): string {
|
||||||
|
return bytesToHex(schnorr.getPublicKey(secretKey))
|
||||||
|
}
|
||||||
|
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||||
|
const event = t as VerifiedEvent
|
||||||
|
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
||||||
|
event.id = getEventHash(event)
|
||||||
|
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
|
||||||
|
event[verifiedSymbol] = true
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent {
|
||||||
|
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||||
|
|
||||||
|
const hash = getEventHash(event)
|
||||||
|
if (hash !== event.id) {
|
||||||
|
event[verifiedSymbol] = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const valid = schnorr.verify(event.sig, hash, event.pubkey)
|
||||||
|
event[verifiedSymbol] = valid
|
||||||
|
return valid
|
||||||
|
} catch (err) {
|
||||||
|
event[verifiedSymbol] = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeEvent(evt: UnsignedEvent): string {
|
||||||
|
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||||
|
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventHash(event: UnsignedEvent): string {
|
||||||
|
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||||
|
return bytesToHex(eventHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = new JS()
|
||||||
|
|
||||||
|
export const generateSecretKey = i.generateSecretKey
|
||||||
|
export const getPublicKey = i.getPublicKey
|
||||||
|
export const finalizeEvent = i.finalizeEvent
|
||||||
|
export const verifyEvent = i.verifyEvent
|
||||||
|
export * from './core.ts'
|
||||||
47
references.test.ts
Normal file
47
references.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { parseReferences } from './references.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
test('parse mentions', () => {
|
||||||
|
let evt = buildEvent({
|
||||||
|
tags: [
|
||||||
|
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
|
||||||
|
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
|
||||||
|
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
|
||||||
|
],
|
||||||
|
content:
|
||||||
|
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parseReferences(evt)).toEqual([
|
||||||
|
{
|
||||||
|
text: '#[0]',
|
||||||
|
profile: {
|
||||||
|
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||||
|
relays: ['wss://nostr.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '#[2]',
|
||||||
|
event: {
|
||||||
|
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||||
|
profile: {
|
||||||
|
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||||
|
event: {
|
||||||
|
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||||
|
relays: [],
|
||||||
|
author: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
104
references.ts
Normal file
104
references.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
type Reference = {
|
||||||
|
text: string
|
||||||
|
profile?: ProfilePointer
|
||||||
|
event?: EventPointer
|
||||||
|
address?: AddressPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||||
|
|
||||||
|
export function parseReferences(evt: Event): Reference[] {
|
||||||
|
let references: Reference[] = []
|
||||||
|
for (let ref of evt.content.matchAll(mentionRegex)) {
|
||||||
|
if (ref[2]) {
|
||||||
|
// it's a NIP-27 mention
|
||||||
|
try {
|
||||||
|
let { type, data } = decode(ref[1])
|
||||||
|
switch (type) {
|
||||||
|
case 'npub': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: { pubkey: data as string, relays: [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: data as ProfilePointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: { id: data as string, relays: [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: data as EventPointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
address: data as AddressPointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
} else if (ref[3]) {
|
||||||
|
// it's a NIP-10 mention
|
||||||
|
let idx = parseInt(ref[3], 10)
|
||||||
|
let tag = evt.tags[idx]
|
||||||
|
if (!tag) continue
|
||||||
|
|
||||||
|
switch (tag[0]) {
|
||||||
|
case 'p': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'e': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
try {
|
||||||
|
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
address: {
|
||||||
|
identifier,
|
||||||
|
pubkey,
|
||||||
|
kind: parseInt(kind, 10),
|
||||||
|
relays: tag[2] ? [tag[2]] : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return references
|
||||||
|
}
|
||||||
192
relay.js
192
relay.js
@@ -1,192 +0,0 @@
|
|||||||
/* global WebSocket */
|
|
||||||
|
|
||||||
import 'websocket-polyfill'
|
|
||||||
|
|
||||||
import {verifySignature, validateEvent} from './event'
|
|
||||||
import {matchFilters} from './filter'
|
|
||||||
|
|
||||||
export function normalizeRelayURL(url) {
|
|
||||||
let [host, ...qs] = url.trim().split('?')
|
|
||||||
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
|
|
||||||
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
|
|
||||||
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
|
|
||||||
return [host, ...qs].join('?')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
|
|
||||||
url = normalizeRelayURL(url)
|
|
||||||
|
|
||||||
var ws, resolveOpen, untilOpen, wasClosed
|
|
||||||
var openSubs = {}
|
|
||||||
let attemptNumber = 1
|
|
||||||
let nextAttemptSeconds = 1
|
|
||||||
|
|
||||||
function resetOpenState() {
|
|
||||||
untilOpen = new Promise(resolve => {
|
|
||||||
resolveOpen = resolve
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var channels = {}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log('connected to', url)
|
|
||||||
resolveOpen()
|
|
||||||
|
|
||||||
// restablish old subscriptions
|
|
||||||
if (wasClosed) {
|
|
||||||
wasClosed = false
|
|
||||||
for (let channel in openSubs) {
|
|
||||||
let filters = openSubs[channel]
|
|
||||||
let cb = channels[channel]
|
|
||||||
sub({cb, filter: filters}, channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onerror = err => {
|
|
||||||
console.log('error connecting to relay', url)
|
|
||||||
onError(err)
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
resetOpenState()
|
|
||||||
attemptNumber++
|
|
||||||
nextAttemptSeconds += attemptNumber ** 3
|
|
||||||
if (nextAttemptSeconds > 14400) {
|
|
||||||
nextAttemptSeconds = 14400 // 4 hours
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
|
|
||||||
)
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
connect()
|
|
||||||
} catch (err) {}
|
|
||||||
}, nextAttemptSeconds * 1000)
|
|
||||||
|
|
||||||
wasClosed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = async e => {
|
|
||||||
var data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(e.data)
|
|
||||||
} catch (err) {
|
|
||||||
data = e.data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length > 1) {
|
|
||||||
if (data[0] === 'NOTICE') {
|
|
||||||
if (data.length < 2) return
|
|
||||||
|
|
||||||
console.log('message from relay ' + url + ': ' + data[1])
|
|
||||||
onNotice(data[1])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data[0] === 'EVENT') {
|
|
||||||
if (data.length < 3) return
|
|
||||||
|
|
||||||
let channel = data[1]
|
|
||||||
let event = data[2]
|
|
||||||
|
|
||||||
if (
|
|
||||||
validateEvent(event) &&
|
|
||||||
verifySignature(event) &&
|
|
||||||
channels[channel] &&
|
|
||||||
matchFilters(openSubs[channel], event)
|
|
||||||
) {
|
|
||||||
channels[channel](event)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetOpenState()
|
|
||||||
|
|
||||||
try {
|
|
||||||
connect()
|
|
||||||
} catch (err) {}
|
|
||||||
|
|
||||||
async function trySend(params) {
|
|
||||||
let msg = JSON.stringify(params)
|
|
||||||
|
|
||||||
await untilOpen
|
|
||||||
ws.send(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sub = (
|
|
||||||
{cb, filter, beforeSend},
|
|
||||||
channel = Math.random().toString().slice(2)
|
|
||||||
) => {
|
|
||||||
var filters = []
|
|
||||||
if (Array.isArray(filter)) {
|
|
||||||
filters = filter
|
|
||||||
} else {
|
|
||||||
filters.push(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (beforeSend) {
|
|
||||||
const beforeSendResult = beforeSend({filter, relay: url, channel})
|
|
||||||
filters = beforeSendResult.filter
|
|
||||||
}
|
|
||||||
|
|
||||||
trySend(['REQ', channel, ...filters])
|
|
||||||
channels[channel] = cb
|
|
||||||
openSubs[channel] = filters
|
|
||||||
|
|
||||||
const activeCallback = cb
|
|
||||||
const activeFilters = filters
|
|
||||||
const activeBeforeSend = beforeSend
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: ({
|
|
||||||
cb = activeCallback,
|
|
||||||
filter = activeFilters,
|
|
||||||
beforeSend = activeBeforeSend
|
|
||||||
}) => sub({cb, filter, beforeSend}, channel),
|
|
||||||
unsub: () => {
|
|
||||||
delete openSubs[channel]
|
|
||||||
delete channels[channel]
|
|
||||||
trySend(['CLOSE', channel])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
sub,
|
|
||||||
async publish(event, statusCallback) {
|
|
||||||
try {
|
|
||||||
await trySend(['EVENT', event])
|
|
||||||
if (statusCallback) {
|
|
||||||
statusCallback(0)
|
|
||||||
let {unsub} = sub(
|
|
||||||
{
|
|
||||||
cb: () => {
|
|
||||||
statusCallback(1)
|
|
||||||
unsub()
|
|
||||||
clearTimeout(willUnsub)
|
|
||||||
},
|
|
||||||
filter: {ids: [event.id]}
|
|
||||||
},
|
|
||||||
`monitor-${event.id.slice(0, 5)}`
|
|
||||||
)
|
|
||||||
let willUnsub = setTimeout(unsub, 5000)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (statusCallback) statusCallback(-1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
ws.close()
|
|
||||||
},
|
|
||||||
get status() {
|
|
||||||
return ws.readyState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
90
relay.test.ts
Normal file
90
relay.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { Relay } from './relay.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
|
test('connectivity', async () => {
|
||||||
|
const { url } = newMockRelay()
|
||||||
|
const relay = new Relay(url)
|
||||||
|
await relay.connect()
|
||||||
|
expect(relay.connected).toBeTrue()
|
||||||
|
relay.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectivity, with Relay.connect()', async () => {
|
||||||
|
const { url } = newMockRelay()
|
||||||
|
const relay = await Relay.connect(url)
|
||||||
|
expect(relay.connected).toBeTrue()
|
||||||
|
relay.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('querying', async done => {
|
||||||
|
const { url, authors } = newMockRelay()
|
||||||
|
const kind = 0
|
||||||
|
|
||||||
|
const relay = new Relay(url)
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
authors: authors,
|
||||||
|
kinds: [kind],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
expect(authors).toContain(event.pubkey)
|
||||||
|
expect(event).toHaveProperty('kind', kind)
|
||||||
|
|
||||||
|
relay.close()
|
||||||
|
done()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listening and publishing and closing', async done => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const pk = getPublicKey(sk)
|
||||||
|
const kind = 23571
|
||||||
|
|
||||||
|
const { url } = newMockRelay()
|
||||||
|
const relay = new Relay(url)
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
let sub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [kind],
|
||||||
|
authors: [pk],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
|
expect(event).toHaveProperty('kind', kind)
|
||||||
|
expect(event).toHaveProperty('content', 'content')
|
||||||
|
sub.close()
|
||||||
|
},
|
||||||
|
oneose() {},
|
||||||
|
onclose() {
|
||||||
|
relay.close()
|
||||||
|
done()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
relay.publish(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
content: 'content',
|
||||||
|
created_at: 0,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
23
relay.ts
Normal file
23
relay.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use Relay.connect() instead.
|
||||||
|
*/
|
||||||
|
export function relayConnect(url: string): Promise<Relay> {
|
||||||
|
return Relay.connect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Relay extends AbstractRelay {
|
||||||
|
constructor(url: string) {
|
||||||
|
super(url, { verifyEvent })
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string) {
|
||||||
|
const relay = new Relay(url)
|
||||||
|
await relay.connect()
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './abstract-relay.ts'
|
||||||
95
test-helpers.ts
Normal file
95
test-helpers.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Server } from 'mock-socket'
|
||||||
|
|
||||||
|
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
|
|
||||||
|
export function buildEvent(params: Partial<Event>): Event {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: '',
|
||||||
|
created_at: 0,
|
||||||
|
content: '',
|
||||||
|
tags: [],
|
||||||
|
sig: '',
|
||||||
|
...params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let serial = 0
|
||||||
|
|
||||||
|
// the mock relay will always return some events before eose and then be ok with everything
|
||||||
|
export function newMockRelay(): { url: string; authors: string[]; ids: string[] } {
|
||||||
|
serial++
|
||||||
|
const url = `wss://mock.relay.url/${serial}`
|
||||||
|
const relay = new Server(url)
|
||||||
|
const secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||||
|
const preloadedEvents = secretKeys.map(sk =>
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
relay.on('connection', (conn: any) => {
|
||||||
|
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||||
|
|
||||||
|
conn.on('message', (message: string) => {
|
||||||
|
const data = JSON.parse(message)
|
||||||
|
switch (data[0]) {
|
||||||
|
case 'REQ': {
|
||||||
|
let subId = data[1]
|
||||||
|
let filters = data.slice(2)
|
||||||
|
subs[subId] = { conn, filters }
|
||||||
|
|
||||||
|
preloadedEvents.forEach(event => {
|
||||||
|
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
})
|
||||||
|
|
||||||
|
filters.forEach((filter: Filter) => {
|
||||||
|
const kinds = filter.kinds?.length ? filter.kinds : [1]
|
||||||
|
kinds.forEach(kind => {
|
||||||
|
secretKeys.forEach(sk => {
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
conn.send(JSON.stringify(['EOSE', subId]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CLOSE': {
|
||||||
|
let subId = data[1]
|
||||||
|
delete subs[subId]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'EVENT': {
|
||||||
|
let event = data[1]
|
||||||
|
conn.send(JSON.stringify(['OK', event.id, 'true']))
|
||||||
|
for (let subId in subs) {
|
||||||
|
const { filters, conn: listener } = subs[subId]
|
||||||
|
if (matchFilters(filters, event)) {
|
||||||
|
listener.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url, authors: secretKeys.map(getPublicKey), ids: preloadedEvents.map(evt => evt.id) }
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"outDir": "lib/types",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
265
utils.test.ts
Normal file
265
utils.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
describe('inserting into a desc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0: Event[] = []
|
||||||
|
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
buildEvent({ created_at: 1 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 15,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 5,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0: Event[] = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10, id: 'abc' }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inserting into a asc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0: Event[] = []
|
||||||
|
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
buildEvent({ created_at: 40 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 25,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 40 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 50,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30, id: 'abc' }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enqueue a message into MessageQueue', () => {
|
||||||
|
test('enqueue into an empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
expect(queue.first!.value).toBe('node1')
|
||||||
|
})
|
||||||
|
test('enqueue into a non-empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
queue.enqueue('node2')
|
||||||
|
expect(queue.first!.value).toBe('node1')
|
||||||
|
expect(queue.last!.value).toBe('node2')
|
||||||
|
})
|
||||||
|
test('dequeue from an empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe(null)
|
||||||
|
})
|
||||||
|
test('dequeue from a non-empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
queue.enqueue('node2')
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe('node1')
|
||||||
|
const item2 = queue.dequeue()
|
||||||
|
expect(item2).toBe('node3')
|
||||||
|
})
|
||||||
|
test('dequeue more than in queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe('node1')
|
||||||
|
const item2 = queue.dequeue()
|
||||||
|
expect(item2).toBe('node3')
|
||||||
|
const item3 = queue.dequeue()
|
||||||
|
expect(item3).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('binary search', () => {
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
|
||||||
|
})
|
||||||
116
utils.ts
Normal file
116
utils.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
export const utf8Decoder = new TextDecoder('utf-8')
|
||||||
|
export const utf8Encoder = new TextEncoder()
|
||||||
|
|
||||||
|
export function normalizeURL(url: string): string {
|
||||||
|
let p = new URL(url)
|
||||||
|
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||||
|
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||||
|
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||||
|
p.searchParams.sort()
|
||||||
|
p.hash = ''
|
||||||
|
return p.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||||
|
const [idx, found] = binarySearch(sortedArray, b => {
|
||||||
|
if (event.id === b.id) return 0
|
||||||
|
if (event.created_at === b.created_at) return -1
|
||||||
|
return b.created_at - event.created_at
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
sortedArray.splice(idx, 0, event)
|
||||||
|
}
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||||
|
const [idx, found] = binarySearch(sortedArray, b => {
|
||||||
|
if (event.id === b.id) return 0
|
||||||
|
if (event.created_at === b.created_at) return -1
|
||||||
|
return event.created_at - b.created_at
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
sortedArray.splice(idx, 0, event)
|
||||||
|
}
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
|
||||||
|
let start = 0
|
||||||
|
let end = arr.length - 1
|
||||||
|
|
||||||
|
while (start <= end) {
|
||||||
|
const mid = Math.floor((start + end) / 2)
|
||||||
|
const cmp = compare(arr[mid])
|
||||||
|
|
||||||
|
if (cmp === 0) {
|
||||||
|
return [mid, true]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmp < 0) {
|
||||||
|
end = mid - 1
|
||||||
|
} else {
|
||||||
|
start = mid + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start, false]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueueNode<V> {
|
||||||
|
public value: V
|
||||||
|
public next: QueueNode<V> | null = null
|
||||||
|
public prev: QueueNode<V> | null = null
|
||||||
|
|
||||||
|
constructor(message: V) {
|
||||||
|
this.value = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Queue<V> {
|
||||||
|
public first: QueueNode<V> | null
|
||||||
|
public last: QueueNode<V> | null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.first = null
|
||||||
|
this.last = null
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(value: V): boolean {
|
||||||
|
const newNode = new QueueNode(value)
|
||||||
|
if (!this.last) {
|
||||||
|
// list is empty
|
||||||
|
this.first = newNode
|
||||||
|
this.last = newNode
|
||||||
|
} else if (this.last === this.first) {
|
||||||
|
// list has a single element
|
||||||
|
this.last = newNode
|
||||||
|
this.last.prev = this.first
|
||||||
|
this.first.next = newNode
|
||||||
|
} else {
|
||||||
|
// list has elements, add as last
|
||||||
|
newNode.prev = this.last
|
||||||
|
this.last.next = newNode
|
||||||
|
this.last = newNode
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dequeue(): V | null {
|
||||||
|
if (!this.first) return null
|
||||||
|
|
||||||
|
if (this.first === this.last) {
|
||||||
|
const target = this.first
|
||||||
|
this.first = null
|
||||||
|
this.last = null
|
||||||
|
return target.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.first
|
||||||
|
this.first = target.next
|
||||||
|
|
||||||
|
return target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
38
wasm.ts
Normal file
38
wasm.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||||
|
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||||
|
|
||||||
|
let nw: NostrWasm
|
||||||
|
|
||||||
|
export function setNostrWasm(x: NostrWasm) {
|
||||||
|
nw = x
|
||||||
|
}
|
||||||
|
|
||||||
|
class Wasm implements Nostr {
|
||||||
|
generateSecretKey(): Uint8Array {
|
||||||
|
return nw.generateSecretKey()
|
||||||
|
}
|
||||||
|
getPublicKey(secretKey: Uint8Array): string {
|
||||||
|
return bytesToHex(nw.getPublicKey(secretKey))
|
||||||
|
}
|
||||||
|
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||||
|
nw.finalizeEvent(t as any, secretKey)
|
||||||
|
return t as VerifiedEvent
|
||||||
|
}
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent {
|
||||||
|
try {
|
||||||
|
nw.verifyEvent(event)
|
||||||
|
event[verifiedSymbol] = true
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = new Wasm()
|
||||||
|
export const generateSecretKey = i.generateSecretKey
|
||||||
|
export const getPublicKey = i.getPublicKey
|
||||||
|
export const finalizeEvent = i.finalizeEvent
|
||||||
|
export const verifyEvent = i.verifyEvent
|
||||||
|
export * from './core.ts'
|
||||||
Reference in New Issue
Block a user