mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
364 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
60ce13e17d | ||
|
|
727bcb05a8 | ||
|
|
c236e41f80 | ||
|
|
f04bc0cee1 | ||
|
|
e63479ee7f | ||
|
|
c47f091d9b | ||
|
|
4c785279bc | ||
|
|
6786641b1d | ||
|
|
0396db5ed6 | ||
|
|
0c8e7a74f5 | ||
|
|
c66a2acda1 | ||
|
|
6f07c756e5 | ||
|
|
f6bcda8d8d | ||
|
|
4b666e421b | ||
|
|
454366f6a2 | ||
|
|
3d6f9a41e0 | ||
|
|
e3631ba806 | ||
|
|
89f11e214d | ||
|
|
bb09e25512 | ||
|
|
1b5c314436 | ||
|
|
2230f32d11 | ||
|
|
b271d6c06b | ||
|
|
76624a0f23 | ||
|
|
1f1a6380f0 | ||
|
|
a46568d55c | ||
|
|
ff4e63ecdf | ||
|
|
01dd5b7a3c | ||
|
|
16536340e5 | ||
|
|
1037eee335 | ||
|
|
5ce1b4c9f7 | ||
|
|
7bc9083bc5 | ||
|
|
ce214ebbab | ||
|
|
800beb37f1 | ||
|
|
6d4916e6f7 | ||
|
|
60fc0d7940 | ||
|
|
faa308049f |
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,4 +1,10 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"ecmaFeatures": {
|
||||
@@ -13,14 +19,11 @@
|
||||
"node": true
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
"babel"
|
||||
],
|
||||
|
||||
"globals": {
|
||||
"document": false,
|
||||
"navigator": false,
|
||||
"window": false,
|
||||
"crypto": false,
|
||||
"location": false,
|
||||
"URL": false,
|
||||
"URLSearchParams": false,
|
||||
@@ -43,8 +46,7 @@
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"generator-star-spacing": [2, { "before": true, "after": true }],
|
||||
"handle-callback-err": [2, "^(err|error)$" ],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||
@@ -99,7 +101,6 @@
|
||||
"no-octal-escape": 2,
|
||||
"no-path-concat": 0,
|
||||
"no-proto": 2,
|
||||
"no-redeclare": 2,
|
||||
"no-regex-spaces": 2,
|
||||
"no-return-assign": 0,
|
||||
"no-self-assign": 2,
|
||||
@@ -116,7 +117,7 @@
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||
"no-unreachable": 2,
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
@@ -138,5 +139,13 @@
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": [0]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts"],
|
||||
"env": { "jest/globals": true },
|
||||
"plugins": ["jest"],
|
||||
"extends": ["plugin:jest/recommended"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
23
.github/workflows/npm-publish.yml
vendored
Normal file
23
.github/workflows/npm-publish.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: publish npm package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just build
|
||||
- run: just test
|
||||
- run: just emit-types
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
greater-version-only: true
|
||||
28
.github/workflows/test.yml
vendored
Normal file
28
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: test every commit
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just test
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just lint
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules
|
||||
dist
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
.envrc
|
||||
lib
|
||||
test.html
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
semi: false
|
||||
arrowParens: avoid
|
||||
bracketSpacing: true
|
||||
insertPragma: false
|
||||
printWidth: 80
|
||||
printWidth: 120
|
||||
proseWrap: preserve
|
||||
semi: false
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
jsxBracketSameLine: false
|
||||
bracketSpacing: false
|
||||
|
||||
24
LICENSE
Normal file
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>
|
||||
358
README.md
358
README.md
@@ -2,69 +2,305 @@
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
## Usage
|
||||
Only depends on _@scure_ and _@noble_ packages.
|
||||
|
||||
```js
|
||||
import {relayPool} from 'nostr-tools'
|
||||
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||
|
||||
const pool = relayPool()
|
||||
## Installation
|
||||
|
||||
pool.setPrivateKey('<hex>') // optional
|
||||
|
||||
pool.addRelay('ws://some.relay.com', {read: true, write: true})
|
||||
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
|
||||
|
||||
// example callback function for a subscription
|
||||
function onEvent(event, relay) => {
|
||||
console.log(`got an event from ${relay.url} which is already validated.`, event)
|
||||
}
|
||||
|
||||
// subscribing to a single user
|
||||
// author is the user's public key
|
||||
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
|
||||
|
||||
// or bulk follow
|
||||
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
|
||||
|
||||
// reuse a subscription channel
|
||||
const mySubscription = pool.sub({cb: ..., filter: ....})
|
||||
mySubscription.sub({filter: ....})
|
||||
mySubscription.sub({cb: ...})
|
||||
mySubscription.unsub()
|
||||
|
||||
// get specific event
|
||||
const specificChannel = pool.sub({
|
||||
cb: (event, relay) => {
|
||||
console.log('got specific event from relay', event, relay)
|
||||
specificChannel.unsub()
|
||||
},
|
||||
filter: {id: '<hex>'}
|
||||
})
|
||||
|
||||
// or get a specific event plus all the events that reference it in the 'e' tag
|
||||
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
|
||||
|
||||
// get all events
|
||||
pool.sub({cb: (event, relay) => {...}, filter: {}})
|
||||
|
||||
// get recent events
|
||||
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
|
||||
|
||||
// publishing events(inside an async function):
|
||||
const ev = await pool.publish(eventObject, (status, url) => {
|
||||
if (status === 0) {
|
||||
console.log(`publish request sent to ${url}`)
|
||||
}
|
||||
if (status === 1) {
|
||||
console.log(`event published by ${url}`, ev)
|
||||
}
|
||||
})
|
||||
// it will be signed automatically with the key supplied above
|
||||
// or pass an already signed event to bypass this
|
||||
|
||||
// subscribing to a new relay
|
||||
pool.addRelay('<url>')
|
||||
// will automatically subscribe to the all the events called with .sub above
|
||||
```bash
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
```
|
||||
|
||||
For other utils please read the source (for now).
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey() // `sk` is a hex string
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
```
|
||||
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import { validateEvent, verifySignature, getSignature, getEventHash, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let event = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
pubkey: getPublicKey(privateKey),
|
||||
}
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
|
||||
let ok = validateEvent(event)
|
||||
let veryOk = verifySignature(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
|
||||
```js
|
||||
import { relayInit, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const relay = relayInit('wss://relay.example.com')
|
||||
relay.on('connect', () => {
|
||||
console.log(`connected to ${relay.url}`)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
console.log(`failed to connect to ${relay.url}`)
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
|
||||
// let's query for an event that exists
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
sub.on('event', event => {
|
||||
console.log('we got the event we wanted:', event)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
})
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
console.log('got event:', event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
kind: 1,
|
||||
pubkey: pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello world',
|
||||
}
|
||||
|
||||
// this calculates the event id and signs the event in a single step
|
||||
const signedEvent = finishEvent(event, sk)
|
||||
await relay.publish(signedEvent)
|
||||
|
||||
let events = await relay.list([{ kinds: [0, 1] }])
|
||||
let event = await relay.get({
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
relay.close()
|
||||
```
|
||||
|
||||
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
||||
|
||||
```js
|
||||
import 'websocket-polyfill'
|
||||
```
|
||||
|
||||
### Interacting with multiple relays
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
|
||||
let sub = pool.sub(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
sub.on('event', event => {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
})
|
||||
|
||||
let pubs = pool.publish(relays, newEvent)
|
||||
await Promise.all(pubs)
|
||||
|
||||
let events = await pool.list(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
// relaysForEvent will be an array of URLs from relays a given event was seen on
|
||||
|
||||
pool.close()
|
||||
```
|
||||
|
||||
### 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, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
let { type, data } = nip19.decode(nsec)
|
||||
assert(type === 'nsec')
|
||||
assert(data === sk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
let { type, data } = nip19.decode(npub)
|
||||
assert(type === 'npub')
|
||||
assert(data === pk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||
let { type, data } = nip19.decode(nprofile)
|
||||
assert(type === 'nprofile')
|
||||
assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
### Encrypting and decrypting direct messages
|
||||
|
||||
```js
|
||||
import { nip04, getPublicKey, generatePrivateKey } from 'nostr-tools'
|
||||
|
||||
// sender
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
|
||||
// receiver
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
// on the sender side
|
||||
let message = 'hello'
|
||||
let ciphertext = await nip04.encrypt(sk1, pk2, message)
|
||||
|
||||
let event = {
|
||||
kind: 4,
|
||||
pubkey: pk1,
|
||||
tags: [['p', pk2]],
|
||||
content: ciphertext,
|
||||
...otherProperties,
|
||||
}
|
||||
|
||||
sendEvent(event)
|
||||
|
||||
// on the receiver side
|
||||
sub.on('event', async event => {
|
||||
let sender = event.pubkey
|
||||
pk1 === sender
|
||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||
})
|
||||
```
|
||||
|
||||
### Performing and checking for delegation
|
||||
|
||||
```js
|
||||
import { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
|
||||
|
||||
// delegator
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
|
||||
// delegatee
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
// generate delegation
|
||||
let delegation = nip26.createDelegation(sk1, {
|
||||
pubkey: pk2,
|
||||
kind: 1,
|
||||
since: Math.round(Date.now() / 1000),
|
||||
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */,
|
||||
})
|
||||
|
||||
// the delegatee uses the delegation when building an event
|
||||
let event = {
|
||||
pubkey: pk2,
|
||||
kind: 1,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'hello from a delegated key',
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||
}
|
||||
|
||||
// finally any receiver of this event can check for the presence of a valid delegation tag
|
||||
let delegator = nip26.getDelegator(event)
|
||||
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
|
||||
```
|
||||
|
||||
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
|
||||
|
||||
### Using from the browser (if you don't want to use a bundler)
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
<script>
|
||||
window.NostrTools.generatePrivateKey('...') // and so on
|
||||
</script>
|
||||
```
|
||||
|
||||
## Plumbing
|
||||
|
||||
1. Install [`just`](https://just.systems/)
|
||||
2. `just -l`
|
||||
|
||||
## 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.
|
||||
|
||||
47
build.js
Executable file
47
build.js
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const esbuild = require('esbuild')
|
||||
|
||||
let common = {
|
||||
entryPoints: ['index.ts'],
|
||||
bundle: true,
|
||||
sourcemap: 'external',
|
||||
}
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outfile: 'lib/esm/nostr.mjs',
|
||||
format: 'esm',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({ type: 'module' })
|
||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('esm build success.')
|
||||
})
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outfile: 'lib/nostr.cjs.js',
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outfile: 'lib/nostr.bundle.js',
|
||||
format: 'iife',
|
||||
globalName: 'NostrTools',
|
||||
define: {
|
||||
window: 'self',
|
||||
global: 'self',
|
||||
process: '{"env": {}}',
|
||||
},
|
||||
})
|
||||
.then(() => console.log('standalone build success.'))
|
||||
43
event.js
43
event.js
@@ -1,43 +0,0 @@
|
||||
import {Buffer} from 'buffer'
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
|
||||
import {sha256} from './utils'
|
||||
|
||||
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 async function getEventHash(event) {
|
||||
let eventHash = await sha256(Buffer.from(serializeEvent(event)))
|
||||
return Buffer.from(eventHash).toString('hex')
|
||||
}
|
||||
|
||||
export async function verifySignature(event) {
|
||||
return await secp256k1.schnorr.verify(
|
||||
event.sig,
|
||||
await getEventHash(event),
|
||||
event.pubkey
|
||||
)
|
||||
}
|
||||
|
||||
export async function signEvent(event, key) {
|
||||
let eventHash = await getEventHash(event)
|
||||
return await secp256k1.schnorr.sign(eventHash, key)
|
||||
}
|
||||
360
event.test.ts
Normal file
360
event.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
getBlankEvent,
|
||||
finishEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
getSignature,
|
||||
Kind,
|
||||
verifiedSymbol,
|
||||
} from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
|
||||
describe('Event', () => {
|
||||
describe('getBlankEvent', () => {
|
||||
it('should return a blank event object', () => {
|
||||
expect(getBlankEvent()).toEqual({
|
||||
kind: 255,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a blank event object with defined kind', () => {
|
||||
expect(getBlankEvent(Kind.Text)).toEqual({
|
||||
kind: 1,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishEvent', () => {
|
||||
it('should create a signed event from a template', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
it('should serialize a valid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for an invalid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
it('should return the correct event hash', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid properties', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid tags', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true for a valid event signature', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for an invalid event signature', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifySignature({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an invalid event id', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSignature', () => {
|
||||
it('should produce the correct signature for an event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, privateKey)
|
||||
|
||||
// verify the signature
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
id: getEventHash(unsignedEvent),
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should not sign an event with different private key', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
143
event.ts
Normal file
143
event.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
/** Designates a verified event signature. */
|
||||
export const verifiedSymbol = Symbol('verified')
|
||||
|
||||
/** @deprecated Use numbers instead. */
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum Kind {
|
||||
Metadata = 0,
|
||||
Text = 1,
|
||||
RecommendRelay = 2,
|
||||
Contacts = 3,
|
||||
EncryptedDirectMessage = 4,
|
||||
EventDeletion = 5,
|
||||
Repost = 6,
|
||||
Reaction = 7,
|
||||
BadgeAward = 8,
|
||||
ChannelCreation = 40,
|
||||
ChannelMetadata = 41,
|
||||
ChannelMessage = 42,
|
||||
ChannelHideMessage = 43,
|
||||
ChannelMuteUser = 44,
|
||||
Blank = 255,
|
||||
Report = 1984,
|
||||
ZapRequest = 9734,
|
||||
Zap = 9735,
|
||||
RelayList = 10002,
|
||||
ClientAuth = 22242,
|
||||
HttpAuth = 27235,
|
||||
ProfileBadge = 30008,
|
||||
BadgeDefinition = 30009,
|
||||
Article = 30023,
|
||||
FileMetadata = 1063,
|
||||
}
|
||||
|
||||
export interface Event<K extends number = number> {
|
||||
kind: K
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
id: string
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type EventTemplate<K extends number = number> = Pick<Event<K>, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||
export type UnsignedEvent<K extends number = number> = Pick<
|
||||
Event<K>,
|
||||
'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'
|
||||
>
|
||||
|
||||
/** An event whose signature has been verified. */
|
||||
export interface VerifiedEvent<K extends number = number> extends Event<K> {
|
||||
[verifiedSymbol]: true
|
||||
}
|
||||
|
||||
export function getBlankEvent(): EventTemplate<Kind.Blank>
|
||||
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
|
||||
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
|
||||
return {
|
||||
kind,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
|
||||
const event = t as VerifiedEvent<K>
|
||||
event.pubkey = getPublicKey(privateKey)
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
event[verifiedSymbol] = true
|
||||
return event
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||
|
||||
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent<number>): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
if (typeof event.pubkey !== 'string') return false
|
||||
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Verify the event's signature. This function mutates the event with a `verified` symbol, making it idempotent. */
|
||||
export function verifySignature<K extends number>(event: Event<K>): event is VerifiedEvent<K> {
|
||||
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||
|
||||
const hash = getEventHash(event)
|
||||
if (hash !== event.id) {
|
||||
return (event[verifiedSymbol] = false)
|
||||
}
|
||||
|
||||
try {
|
||||
return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
|
||||
} catch (err) {
|
||||
return (event[verifiedSymbol] = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `getSignature` instead. */
|
||||
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||
console.warn(
|
||||
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
|
||||
)
|
||||
return getSignature(event, key)
|
||||
}
|
||||
|
||||
/** Calculate the signature for an event. */
|
||||
export function getSignature(event: UnsignedEvent<number>, key: string): string {
|
||||
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||
}
|
||||
43
fakejson.test.ts
Normal file
43
fakejson.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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')
|
||||
}
|
||||
27
filter.js
27
filter.js
@@ -1,27 +0,0 @@
|
||||
export function matchFilter(filter, event) {
|
||||
if (filter.id && event.id !== filter.id) return false
|
||||
if (filter.kind && event.kind !== filter.kind) return false
|
||||
if (filter.author && event.pubkey !== filter.author) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
||||
return false
|
||||
if (
|
||||
filter['#e'] &&
|
||||
!event.tags.find(([t, v]) => t === 'e' && v === filter['#e'])
|
||||
)
|
||||
return false
|
||||
if (
|
||||
filter['#p'] &&
|
||||
!event.tags.find(([t, v]) => t === 'p' && v === filter['#p'])
|
||||
)
|
||||
return false
|
||||
if (filter.since && event.created_at <= filter.since) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(filters, event) {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
243
filter.test.ts
Normal file
243
filter.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
it('should return true when all filter conditions are met', () => {
|
||||
const filter = {
|
||||
ids: ['123', '456'],
|
||||
kinds: [1, 2, 3],
|
||||
authors: ['abc'],
|
||||
since: 100,
|
||||
until: 200,
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({ id: '789' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({ id: '001' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
const filter = { kinds: [1, 2, 3] }
|
||||
|
||||
const event = buildEvent({ kind: 4 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
const filter = { authors: ['abc', 'def'] }
|
||||
|
||||
const event = buildEvent({ pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = { '#tag1': ['foo'] }
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar'],
|
||||
],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 50 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 150 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFilters', () => {
|
||||
it('should return true when at least one filter matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when at least one prefix matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['1'], kinds: [1], authors: ['a'] },
|
||||
{ ids: ['4'], kinds: [2], authors: ['d'] },
|
||||
{ ids: ['9'], kinds: [3], authors: ['g'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when event matches one or more filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when no filters match the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when event matches none of the filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
const event = buildEvent({
|
||||
id: '456',
|
||||
kind: 2,
|
||||
pubkey: 'def',
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeFilters', () => {
|
||||
it('should merge filters', () => {
|
||||
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||
ids: ['a', 'b', 'c'],
|
||||
limit: 3,
|
||||
authors: ['x'],
|
||||
})
|
||||
|
||||
expect(
|
||||
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
72
filter.ts
Normal file
72
filter.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Event } from './event.ts'
|
||||
|
||||
export type Filter<K extends number = number> = {
|
||||
ids?: string[]
|
||||
kinds?: K[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[] | undefined
|
||||
}
|
||||
|
||||
export function matchFilter(filter: Filter<number>, event: Event<number>): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (let f in filter) {
|
||||
if (f[0] === '#') {
|
||||
let tagName = f.slice(1)
|
||||
let values = filter[`#${tagName}`]
|
||||
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.since && event.created_at < filter.since) return false
|
||||
if (filter.until && event.created_at > filter.until) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(filters: Filter<number>[], event: Event<number>): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
|
||||
let result: Filter<number> = {}
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
let filter = filters[i]
|
||||
Object.entries(filter).forEach(([property, values]) => {
|
||||
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||
// @ts-ignore
|
||||
result[property] = result[property] || []
|
||||
// @ts-ignore
|
||||
for (let v = 0; v < values.length; v++) {
|
||||
// @ts-ignore
|
||||
let value = values[v]
|
||||
// @ts-ignore
|
||||
if (!result[property].includes(value)) result[property].push(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
26
index.js
26
index.js
@@ -1,26 +0,0 @@
|
||||
import {relayConnect} from './relay'
|
||||
import {relayPool} from './pool'
|
||||
import {
|
||||
getBlankEvent,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
serializeEvent,
|
||||
getEventHash
|
||||
} from './event'
|
||||
import {matchFilter, matchFilters} from './filter'
|
||||
import {makeRandom32, sha256, getPublicKey} from './utils'
|
||||
|
||||
export {
|
||||
relayConnect,
|
||||
relayPool,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
makeRandom32,
|
||||
sha256,
|
||||
getPublicKey,
|
||||
getBlankEvent,
|
||||
matchFilter,
|
||||
matchFilters
|
||||
}
|
||||
27
index.ts
Normal file
27
index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export * from './keys.ts'
|
||||
export * from './relay.ts'
|
||||
export * from './event.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04.ts'
|
||||
export * as nip05 from './nip05.ts'
|
||||
export * as nip06 from './nip06.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
export * as nip25 from './nip25.ts'
|
||||
export * as nip26 from './nip26.ts'
|
||||
export * as nip27 from './nip27.ts'
|
||||
export * as nip28 from './nip28.ts'
|
||||
export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as fj from './fakejson.ts'
|
||||
export * as utils from './utils.ts'
|
||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
}
|
||||
28
justfile
Normal file
28
justfile
Normal file
@@ -0,0 +1,28 @@
|
||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
|
||||
install-dependencies:
|
||||
yarn --ignore-engines
|
||||
|
||||
build:
|
||||
rm -rf lib
|
||||
node build.js
|
||||
|
||||
test:
|
||||
jest
|
||||
|
||||
test-only file:
|
||||
jest {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
|
||||
publish: build emit-types
|
||||
npm publish
|
||||
|
||||
format:
|
||||
eslint --ext .ts --fix .
|
||||
prettier --write .
|
||||
|
||||
lint:
|
||||
eslint --ext .ts .
|
||||
prettier --check .
|
||||
18
keys.test.ts
Normal file
18
keys.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
10
keys.ts
Normal file
10
keys.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return bytesToHex(schnorr.utils.randomPrivateKey())
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return bytesToHex(schnorr.getPublicKey(privateKey))
|
||||
}
|
||||
20
kinds.test.ts
Normal file
20
kinds.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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')
|
||||
})
|
||||
40
kinds.ts
Normal file
40
kinds.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
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. */
|
||||
function isReplaceableKind(kind: number) {
|
||||
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
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. */
|
||||
function isParameterizedReplaceableKind(kind: number) {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** Classification of the event kind. */
|
||||
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||
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 {
|
||||
classifyKind,
|
||||
isEphemeralKind,
|
||||
isParameterizedReplaceableKind,
|
||||
isRegularKind,
|
||||
isReplaceableKind,
|
||||
type KindClassification,
|
||||
}
|
||||
39
nip04.js
39
nip04.js
@@ -1,39 +0,0 @@
|
||||
import aes from 'browserify-cipher'
|
||||
import {Buffer} from 'buffer'
|
||||
import randomBytes from 'randombytes'
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
|
||||
export function encrypt(privkey, pubkey, text) {
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getOnlyXFromFullSharedSecret(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
var cipher = aes.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(normalizedKey, 'hex'),
|
||||
iv
|
||||
)
|
||||
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
|
||||
encryptedMessage += cipher.final('base64')
|
||||
|
||||
return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
|
||||
}
|
||||
|
||||
export function decrypt(privkey, pubkey, ciphertext, iv) {
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getOnlyXFromFullSharedSecret(key)
|
||||
|
||||
var decipher = aes.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(normalizedKey, 'hex'),
|
||||
Buffer.from(iv, 'base64')
|
||||
)
|
||||
let decryptedMessage = decipher.update(ciphertext, 'base64')
|
||||
decryptedMessage += decipher.final('utf8')
|
||||
|
||||
return decryptedMessage
|
||||
}
|
||||
|
||||
function getOnlyXFromFullSharedSecret(fullSharedSecretCoordinates) {
|
||||
return fullSharedSecretCoordinates.substr(2, 64)
|
||||
}
|
||||
17
nip04.test.ts
Normal file
17
nip04.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { encrypt, decrypt } from './nip04.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
expect(await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))).toEqual('hello')
|
||||
})
|
||||
44
nip04.ts
Normal file
44
nip04.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
// @ts-ignore
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(privkey: string, pubkey: string, text: string): Promise<string> {
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
let plaintext = utf8Encoder.encode(text)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(privkey: string, pubkey: string, data: string): Promise<string> {
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
return key.slice(1, 33)
|
||||
}
|
||||
52
nip05.js
52
nip05.js
@@ -1,52 +0,0 @@
|
||||
import {Buffer} from 'buffer'
|
||||
import dnsPacket from 'dns-packet'
|
||||
|
||||
const dohProviders = [
|
||||
'cloudflare-dns.com',
|
||||
'fi.doh.dns.snopyta.org',
|
||||
'basic.bravedns.com',
|
||||
'hydra.plan9-ns1.com',
|
||||
'doh.pl.ahadns.net',
|
||||
'dns.flatuslifir.is',
|
||||
'doh.dns.sb',
|
||||
'doh.li'
|
||||
]
|
||||
|
||||
let counter = 0
|
||||
|
||||
export async function keyFromDomain(domain) {
|
||||
let host = dohProviders[counter % dohProviders.length]
|
||||
|
||||
let buf = dnsPacket.encode({
|
||||
type: 'query',
|
||||
id: Math.floor(Math.random() * 65534),
|
||||
flags: dnsPacket.RECURSION_DESIRED,
|
||||
questions: [
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_nostrkey.${domain}`
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
let fetching = fetch(`https://${host}/dns-query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/dns-message',
|
||||
'Content-Length': Buffer.byteLength(buf)
|
||||
},
|
||||
body: buf
|
||||
})
|
||||
|
||||
counter++
|
||||
|
||||
try {
|
||||
let response = Buffer.from(await (await fetching).arrayBuffer())
|
||||
let {answers} = dnsPacket.decode(response)
|
||||
if (answers.length === 0) return null
|
||||
return Buffer.from(answers[0].data[0]).toString()
|
||||
} catch (err) {
|
||||
console.log(`error querying DNS for ${domain} on ${host}`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
26
nip05.test.ts
Normal file
26
nip05.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(p3!.relays).toEqual([
|
||||
'wss://relay.nostr.bg',
|
||||
'wss://nos.lol',
|
||||
'wss://nostr-verified.wellorder.net',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://eden.nostr.land',
|
||||
'wss://nostr.milou.lol',
|
||||
])
|
||||
})
|
||||
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
|
||||
}
|
||||
17
nip06.js
17
nip06.js
@@ -1,17 +0,0 @@
|
||||
import createHmac from 'create-hmac'
|
||||
import randomBytes from 'randombytes'
|
||||
import * as bip39 from 'bip39'
|
||||
|
||||
export function privateKeyFromSeed(seed) {
|
||||
let hmac = createHmac('sha512', Buffer.from('Nostr seed', 'utf8'))
|
||||
hmac.update(seed)
|
||||
return hmac.digest().slice(0, 32).toString('hex')
|
||||
}
|
||||
|
||||
export function seedFromWords(mnemonic) {
|
||||
return bip39.mnemonicToSeedSync(mnemonic)
|
||||
}
|
||||
|
||||
export function generateSeedWords() {
|
||||
return bip39.entropyToMnemonic(randomBytes(16).toString('hex'))
|
||||
}
|
||||
14
nip06.test.ts
Normal file
14
nip06.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
})
|
||||
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): string {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return bytesToHex(privateKey)
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
return generateMnemonic(wordlist)
|
||||
}
|
||||
|
||||
export function validateWords(words: string): boolean {
|
||||
return validateMnemonic(words, wordlist)
|
||||
}
|
||||
232
nip10.test.ts
Normal file
232
nip10.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { parse } from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.todo('recommended + a lot of events')
|
||||
test.todo('recommended + 3 events')
|
||||
test.todo('recommended + 2 events')
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
91
nip10.ts
Normal file
91
nip10.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Event } from './event.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
/**
|
||||
* Pointer to the root of the thread.
|
||||
*/
|
||||
root: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointer to a "parent" event that parsed event replies to (responded to).
|
||||
*/
|
||||
reply: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointers to events which may or may not be in the reply chain.
|
||||
*/
|
||||
mentions: EventPointer[]
|
||||
|
||||
/**
|
||||
* List of pubkeys that are involved in the thread in no particular order.
|
||||
*/
|
||||
profiles: ProfilePointer[]
|
||||
}
|
||||
|
||||
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
const result: NIP10Result = {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
eTags.push(tag)
|
||||
}
|
||||
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
result.profiles.push({
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||
const eTag = eTags[eTagIndex]
|
||||
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
const isLastETag = eTagIndex === eTags.length - 1
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFirstETag) {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLastETag) {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
7
nip13.test.ts
Normal file
7
nip13.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getPow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
})
|
||||
16
nip13.ts
Normal file
16
nip13.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** 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
|
||||
}
|
||||
101
nip18.test.ts
Normal file
101
nip18.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
const relayUrl = 'https://relay.example.com'
|
||||
|
||||
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const repostedEvent = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '' as const,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual('')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
it('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
kind: Kind.Repost,
|
||||
tags: [['e', 'reposted event id', relayUrl]],
|
||||
})
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||
expect(repostedEventPointer!.author).toEqual(undefined)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
99
nip18.ts
Normal file
99
nip18.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Event, finishEvent, Kind, verifySignature } from './event.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip18 tags if you have to.
|
||||
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* Pass an empty string to NOT include the stringified JSON of the reposted event.
|
||||
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
|
||||
* @default Stringified JSON of the reposted event
|
||||
*/
|
||||
content?: ''
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishRepostEvent(
|
||||
t: RepostEventTemplate,
|
||||
reposted: Event<number>,
|
||||
relayUrl: string,
|
||||
privateKey: string,
|
||||
): Event<Kind.Repost> {
|
||||
return finishEvent(
|
||||
{
|
||||
kind: Kind.Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Repost) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||
author: lastPTag?.[1],
|
||||
}
|
||||
}
|
||||
|
||||
export type GetRepostedEventOptions = {
|
||||
skipVerification?: boolean
|
||||
}
|
||||
|
||||
export function getRepostedEvent(
|
||||
event: Event<number>,
|
||||
{ skipVerification }: GetRepostedEventOptions = {},
|
||||
): undefined | Event<number> {
|
||||
const pointer = getRepostedEventPointer(event)
|
||||
|
||||
if (pointer === undefined || event.content === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let repostedEvent: undefined | Event<number>
|
||||
|
||||
try {
|
||||
repostedEvent = JSON.parse(event.content) as Event<number>
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (repostedEvent.id !== pointer.id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!skipVerification && !verifySignature(repostedEvent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return repostedEvent
|
||||
}
|
||||
107
nip19.test.ts
Normal file
107
nip19.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let { type, data } = decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let { type, data } = decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
})
|
||||
|
||||
test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
decode(
|
||||
nprofileEncode({
|
||||
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: [],
|
||||
}),
|
||||
).data,
|
||||
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana',
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nrelay', () => {
|
||||
let url = 'wss://relay.nostr.example'
|
||||
let nrelay = nrelayEncode(url)
|
||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||
let { type, data } = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
})
|
||||
217
nip19.ts
Normal file
217
nip19.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||
*/
|
||||
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
|
||||
export type ProfilePointer = {
|
||||
pubkey: string // hex
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export type EventPointer = {
|
||||
id: string // hex
|
||||
relays?: string[]
|
||||
author?: string
|
||||
}
|
||||
|
||||
export type AddressPointer = {
|
||||
identifier: string
|
||||
pubkey: string
|
||||
kind: number
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: string
|
||||
npub: string
|
||||
note: string
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
case 'nprofile': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
|
||||
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||
|
||||
return {
|
||||
type: 'nprofile',
|
||||
data: {
|
||||
pubkey: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'nevent': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
||||
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
|
||||
return {
|
||||
type: 'nevent',
|
||||
data: {
|
||||
id: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
|
||||
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
|
||||
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
|
||||
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||
|
||||
return {
|
||||
type: 'naddr',
|
||||
data: {
|
||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||
pubkey: bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'nrelay': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return { type: prefix, data: bytesToHex(data) }
|
||||
|
||||
default:
|
||||
throw new Error(`unknown prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
type TLV = { [t: number]: Uint8Array[] }
|
||||
|
||||
function parseTLV(data: Uint8Array): TLV {
|
||||
let result: TLV = {}
|
||||
let rest = data
|
||||
while (rest.length > 0) {
|
||||
let t = rest[0]
|
||||
let l = rest[1]
|
||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
||||
let v = rest.slice(2, 2 + l)
|
||||
rest = rest.slice(2 + l)
|
||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||
result[t] = result[t] || []
|
||||
result[t].push(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function nsecEncode(hex: string): `nsec1${string}` {
|
||||
return encodeBytes('nsec', hex)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
return encodeBytes('npub', hex)
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
return encodeBytes('note', hex)
|
||||
}
|
||||
|
||||
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||
}
|
||||
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
|
||||
let data = hexToBytes(hex)
|
||||
return encodeBech32(prefix, data)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
})
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(event.id)],
|
||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: event.author ? [hexToBytes(event.author)] : [],
|
||||
})
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
let kind = new ArrayBuffer(4)
|
||||
new DataView(kind).setUint32(0, addr.kind, false)
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(addr.identifier)],
|
||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: [hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)],
|
||||
})
|
||||
return encodeBech32('naddr', data)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)],
|
||||
})
|
||||
return encodeBech32('nrelay', data)
|
||||
}
|
||||
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
Object.entries(tlv).forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
})
|
||||
})
|
||||
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
23
nip21.test.ts
Normal file
23
nip21.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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]),
|
||||
}
|
||||
}
|
||||
77
nip25.test.ts
Normal file
77
nip25.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
|
||||
|
||||
describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const reactedEvent = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('+')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '👍',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('👍')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
})
|
||||
65
nip25.ts
Normal file
65
nip25.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Event, finishEvent, Kind } from './event.ts'
|
||||
|
||||
import type { EventPointer } from './nip19.ts'
|
||||
|
||||
export type ReactionEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* @default '+'
|
||||
*/
|
||||
content?: string
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishReactionEvent(
|
||||
t: ReactionEventTemplate,
|
||||
reacted: Event<number>,
|
||||
privateKey: string,
|
||||
): Event<Kind.Reaction> {
|
||||
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
|
||||
|
||||
return finishEvent(
|
||||
{
|
||||
...t,
|
||||
kind: Kind.Reaction,
|
||||
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||
content: t.content ?? '+',
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Reaction) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined || lastPTag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
|
||||
author: lastPTag[1],
|
||||
}
|
||||
}
|
||||
101
nip26.test.ts
Normal file
101
nip26.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
import { getDelegator, createDelegation } from './nip26.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
test('parse good delegation from NIP', async () => {
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
|
||||
})
|
||||
|
||||
test('parse bad delegations', async () => {
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1740995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
test('create and verify delegation', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
let delegation = createDelegation(sk1, { pubkey: pk2, kind: 1 })
|
||||
expect(delegation).toHaveProperty('from', pk1)
|
||||
expect(delegation).toHaveProperty('to', pk2)
|
||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
||||
|
||||
let event = buildEvent({
|
||||
kind: 1,
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||
pubkey: pk2,
|
||||
})
|
||||
expect(getDelegator(event)).toEqual(pk1)
|
||||
})
|
||||
71
nip26.ts
Normal file
71
nip26.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
export type Parameters = {
|
||||
pubkey: string // the key to whom the delegation will be given
|
||||
kind?: number
|
||||
until?: number // delegation will only be valid until this date
|
||||
since?: number // delegation will be valid from this date on
|
||||
}
|
||||
|
||||
export type Delegation = {
|
||||
from: string // the pubkey who signed the delegation
|
||||
to: string // the pubkey that is allowed to use the delegation
|
||||
cond: string // the string of conditions as they should be included in the event tag
|
||||
sig: string
|
||||
}
|
||||
|
||||
export function createDelegation(privateKey: string, parameters: Parameters): Delegation {
|
||||
let conditions = []
|
||||
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
|
||||
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
|
||||
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
|
||||
let cond = conditions.join('&')
|
||||
|
||||
if (cond === '') throw new Error('refusing to create a delegation without any conditions')
|
||||
|
||||
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`))
|
||||
|
||||
let sig = bytesToHex(schnorr.sign(sighash, privateKey))
|
||||
|
||||
return {
|
||||
from: getPublicKey(privateKey),
|
||||
to: parameters.pubkey,
|
||||
cond,
|
||||
sig,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDelegator(event: Event<number>): string | null {
|
||||
// find delegation tag
|
||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
||||
if (!tag) return null
|
||||
|
||||
let pubkey = tag[1]
|
||||
let cond = tag[2]
|
||||
let sig = tag[3]
|
||||
|
||||
// check conditions
|
||||
let conditions = cond.split('&')
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
let [key, operator, value] = conditions[i].split(/\b/)
|
||||
|
||||
// the supported conditions are just 'kind' and 'created_at' for now
|
||||
if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) continue
|
||||
else if (key === 'created_at' && operator === '<' && event.created_at < parseInt(value)) continue
|
||||
else if (key === 'created_at' && operator === '>' && event.created_at > parseInt(value)) continue
|
||||
else return null // invalid condition
|
||||
}
|
||||
|
||||
// check signature
|
||||
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`))
|
||||
if (!schnorr.verify(sig, sighash, pubkey)) return null
|
||||
|
||||
return pubkey
|
||||
}
|
||||
67
nip27.test.ts
Normal file
67
nip27.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
})
|
||||
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),
|
||||
})
|
||||
})
|
||||
}
|
||||
118
nip28.test.ts
Normal file
118
nip28.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import {
|
||||
channelCreateEvent,
|
||||
channelMetadataEvent,
|
||||
channelMessageEvent,
|
||||
channelHideMessageEvent,
|
||||
channelMuteUserEvent,
|
||||
ChannelMetadata,
|
||||
ChannelMessageEventTemplate,
|
||||
} from './nip28.ts'
|
||||
|
||||
const privateKey = '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',
|
||||
}
|
||||
|
||||
it('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)
|
||||
})
|
||||
|
||||
it('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')
|
||||
})
|
||||
|
||||
it('channelMessageEvent should create a signed message event with given template', () => {
|
||||
const template = {
|
||||
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')
|
||||
})
|
||||
|
||||
it('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).toContainEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
|
||||
expect(event.tags).toContainEqual(['e', template.reply_to_channel_message_event_id, 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')
|
||||
})
|
||||
|
||||
it('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')
|
||||
})
|
||||
|
||||
it('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')
|
||||
})
|
||||
})
|
||||
160
nip28.ts
Normal file
160
nip28.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Event, finishEvent, Kind } from './event.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: string,
|
||||
): Event<Kind.ChannelCreation> | 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 finishEvent(
|
||||
{
|
||||
kind: Kind.ChannelCreation,
|
||||
tags: [...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMetadataEvent = (
|
||||
t: ChannelMetadataEventTemplate,
|
||||
privateKey: string,
|
||||
): Event<Kind.ChannelMetadata> | 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 finishEvent(
|
||||
{
|
||||
kind: 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: string): Event<Kind.ChannelMessage> => {
|
||||
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 finishEvent(
|
||||
{
|
||||
kind: 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: string,
|
||||
): Event<Kind.ChannelHideMessage> | 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 finishEvent(
|
||||
{
|
||||
kind: 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: string,
|
||||
): Event<Kind.ChannelMuteUser> | 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 finishEvent(
|
||||
{
|
||||
kind: Kind.ChannelMuteUser,
|
||||
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
14
nip39.test.ts
Normal file
14
nip39.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||
|
||||
test('validate github claim', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let result = await validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421',
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
26
nip42.test.ts
Normal file
26
nip42.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import { finishEvent } from './event.ts'
|
||||
import { generatePrivateKey } from './keys.ts'
|
||||
import { authenticate } from './nip42.ts'
|
||||
import { relayInit } from './relay.ts'
|
||||
|
||||
test('auth flow', () => {
|
||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
||||
relay.connect()
|
||||
const sk = generatePrivateKey()
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
relay.on('auth', async challenge => {
|
||||
await expect(
|
||||
authenticate({
|
||||
challenge,
|
||||
relay,
|
||||
sign: e => finishEvent(e, sk),
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
relay.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
32
nip42.ts
Normal file
32
nip42.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Kind, type EventTemplate, type Event } from './event.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
/**
|
||||
* Authenticate via NIP-42 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* relay.on('auth', challenge =>
|
||||
* authenticate({ relay, sign, challenge })
|
||||
* )
|
||||
*/
|
||||
export const authenticate = async ({
|
||||
challenge,
|
||||
relay,
|
||||
sign,
|
||||
}: {
|
||||
challenge: string
|
||||
relay: Relay
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
|
||||
}): Promise<void> => {
|
||||
const e: EventTemplate = {
|
||||
kind: Kind.ClientAuth,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relay.url],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
return relay.auth(await sign(e))
|
||||
}
|
||||
21
nip44.test.ts
Normal file
21
nip44.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
import { encrypt, decrypt, getSharedSecret } from './nip44.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let pk2 = getPublicKey(sk2)
|
||||
let sharedKey1 = getSharedSecret(sk1, pk2)
|
||||
let sharedKey2 = getSharedSecret(sk2, pk1)
|
||||
|
||||
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
|
||||
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
|
||||
})
|
||||
40
nip44.ts
Normal file
40
nip44.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { base64 } from '@scure/base'
|
||||
import { randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { xchacha20 } from '@noble/ciphers/chacha'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
|
||||
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
|
||||
|
||||
export function encrypt(key: Uint8Array, text: string, v = 1) {
|
||||
if (v !== 1) {
|
||||
throw new Error('NIP44: unknown encryption version')
|
||||
}
|
||||
|
||||
const nonce = randomBytes(24)
|
||||
const plaintext = utf8Encoder.encode(text)
|
||||
const ciphertext = xchacha20(key, nonce, plaintext)
|
||||
|
||||
const payload = new Uint8Array(25 + ciphertext.length)
|
||||
payload.set([v], 0)
|
||||
payload.set(nonce, 1)
|
||||
payload.set(ciphertext, 25)
|
||||
|
||||
return base64.encode(payload)
|
||||
}
|
||||
|
||||
export function decrypt(key: Uint8Array, payload: string) {
|
||||
let data = base64.decode(payload)
|
||||
if (data[0] !== 1) {
|
||||
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
|
||||
}
|
||||
|
||||
const nonce = data.slice(1, 25)
|
||||
const ciphertext = data.slice(25)
|
||||
const plaintext = xchacha20(key, nonce, ciphertext)
|
||||
|
||||
return utf8Decoder.decode(plaintext)
|
||||
}
|
||||
312
nip57.test.ts
Normal file
312
nip57.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { finishEvent } from './event.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns null if the response does not allow Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
nostrPubkey: 'pubkey',
|
||||
callback: 'callback',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBe('callback')
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapRequest', () => {
|
||||
test('throws an error if amount is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('throws an error if profile is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('returns a valid Zap request', () => {
|
||||
const result = makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
relays: ['relay1', 'relay2'],
|
||||
comment: 'comment',
|
||||
})
|
||||
expect(result.kind).toBe(9734)
|
||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||
expect(result.content).toBe('comment')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
]),
|
||||
)
|
||||
expect(result.tags).toContainEqual(['e', 'event'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||
const zapRequest = {
|
||||
kind: 1234,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||
})
|
||||
|
||||
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = {
|
||||
pubkey: publicKey,
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['e', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapReceipt', () => {
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const preimage = 'preimage'
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
||||
expect(result.tags).toContainEqual(['p', publicKey])
|
||||
expect(result.tags).toContainEqual(['preimage', preimage])
|
||||
})
|
||||
|
||||
test('returns a valid Zap receipt without a preimage', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
||||
expect(result.tags).toContainEqual(['p', publicKey])
|
||||
expect(result.tags).not.toContain('preimage')
|
||||
})
|
||||
})
|
||||
130
nip57.ts
Normal file
130
nip57.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function getZapEndpoint(metadata: Event<Kind.Metadata>): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
let { words } = bech32.decode(lud06, 1000)
|
||||
let data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
let res = await _fetch(lnurl)
|
||||
let body = await res.json()
|
||||
|
||||
if (body.allowsNostr && body.nostrPubkey) {
|
||||
return body.callback
|
||||
}
|
||||
} catch (err) {
|
||||
/*-*/
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapRequest({
|
||||
profile,
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
}): EventTemplate<Kind.ZapRequest> {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
|
||||
let zr: EventTemplate<Kind.ZapRequest> = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
zr.tags.push(['e', event])
|
||||
}
|
||||
|
||||
return zr
|
||||
}
|
||||
|
||||
export function validateZapRequest(zapRequestString: string): string | null {
|
||||
let zapRequest: Event
|
||||
|
||||
try {
|
||||
zapRequest = JSON.parse(zapRequestString)
|
||||
} catch (err) {
|
||||
return 'Invalid zap request JSON.'
|
||||
}
|
||||
|
||||
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||
|
||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
||||
|
||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||
|
||||
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||
|
||||
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapReceipt({
|
||||
zapRequest,
|
||||
preimage,
|
||||
bolt11,
|
||||
paidAt,
|
||||
}: {
|
||||
zapRequest: string
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate<Kind.Zap> {
|
||||
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||
|
||||
let zap: EventTemplate<Kind.Zap> = {
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
||||
}
|
||||
|
||||
if (preimage) {
|
||||
zap.tags.push(['preimage', preimage])
|
||||
}
|
||||
|
||||
return zap
|
||||
}
|
||||
130
nip98.test.ts
Normal file
130
nip98.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, Kind, finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
|
||||
const sk = generatePrivateKey()
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken missing loginUrl throws an error', async () => {
|
||||
const result = getToken('', 'get', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('getToken missing httpMethod throws an error', async () => {
|
||||
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
100
nip98.ts
Normal file
100
nip98.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { base64 } from '@scure/base'
|
||||
import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
|
||||
import { utf8Decoder, utf8Encoder } from './utils'
|
||||
|
||||
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: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
|
||||
includeAuthorizationScheme: boolean = false,
|
||||
): Promise<string> {
|
||||
if (!loginUrl || !httpMethod) throw new Error('Missing loginUrl or httpMethod')
|
||||
|
||||
const event = getBlankEvent(Kind.HttpAuth)
|
||||
|
||||
event.tags = [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod],
|
||||
]
|
||||
event.created_at = Math.round(new Date().getTime() / 1000)
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
|
||||
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||
*/
|
||||
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||
throw new Error('Invalid token')
|
||||
}
|
||||
|
||||
const event = JSON.parse(eventB64) as Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
}
|
||||
if (!verifySignature(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== Kind.HttpAuth) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
76
package.json
76
package.json
@@ -1,31 +1,71 @@
|
||||
{
|
||||
"name": "nostr-tools",
|
||||
"version": "0.11.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/fiatjaf/nostr-tools.git"
|
||||
"url": "https://github.com/nbd-wtf/nostr-tools.git"
|
||||
},
|
||||
"files": [
|
||||
"./lib/**/*"
|
||||
],
|
||||
"types": "./lib/index.d.ts",
|
||||
"main": "lib/nostr.cjs.js",
|
||||
"module": "lib/esm/nostr.mjs",
|
||||
"exports": {
|
||||
"import": "./lib/esm/nostr.mjs",
|
||||
"require": "./lib/nostr.cjs.js",
|
||||
"types": "./lib/index.d.ts"
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/secp256k1": "^1.3.0",
|
||||
"bip39": "^3.0.4",
|
||||
"browserify-cipher": ">=1",
|
||||
"buffer": ">=5",
|
||||
"create-hmac": ">=1",
|
||||
"dns-packet": "^5.2.4",
|
||||
"randombytes": ">=2",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
"@noble/ciphers": "^0.2.0",
|
||||
"@noble/curves": "1.1.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
"twitter",
|
||||
"p2p",
|
||||
"mastodon",
|
||||
"ssb",
|
||||
"social",
|
||||
"unstoppable",
|
||||
"censorship",
|
||||
"censorship-resistance",
|
||||
"client"
|
||||
]
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.5.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
126
pool.js
126
pool.js
@@ -1,126 +0,0 @@
|
||||
import {getEventHash, signEvent} from './event'
|
||||
import {relayConnect, normalizeRelayURL} from './relay'
|
||||
|
||||
export function relayPool(globalPrivateKey) {
|
||||
const relays = {}
|
||||
const globalSub = []
|
||||
const noticeCallbacks = []
|
||||
|
||||
function propagateNotice(notice, relayURL) {
|
||||
for (let i = 0; i < noticeCallbacks.length; i++) {
|
||||
let {relay} = relays[relayURL]
|
||||
noticeCallbacks[i](notice, relay)
|
||||
}
|
||||
}
|
||||
|
||||
const activeSubscriptions = {}
|
||||
|
||||
const sub = ({cb, filter}, id = Math.random().toString().slice(2)) => {
|
||||
const subControllers = Object.fromEntries(
|
||||
Object.values(relays)
|
||||
.filter(({policy}) => policy.read)
|
||||
.map(({relay}) => [
|
||||
relay.url,
|
||||
relay.sub({filter, cb: event => cb(event, relay.url)}, id)
|
||||
])
|
||||
)
|
||||
|
||||
const activeCallback = cb
|
||||
const activeFilters = filter
|
||||
|
||||
activeSubscriptions[id] = {
|
||||
sub: ({cb = activeCallback, filter = activeFilters}) => {
|
||||
Object.entries(subControllers).map(([relayURL, sub]) => [
|
||||
relayURL,
|
||||
sub.sub({cb, filter}, id)
|
||||
])
|
||||
return activeSubscriptions[id]
|
||||
},
|
||||
addRelay: relay => {
|
||||
subControllers[relay.url] = relay.sub({cb, filter}, id)
|
||||
return activeSubscriptions[id]
|
||||
},
|
||||
removeRelay: relayURL => {
|
||||
if (relayURL in subControllers) {
|
||||
subControllers[relayURL].unsub()
|
||||
if (Object.keys(subControllers).length === 0) unsub()
|
||||
}
|
||||
return activeSubscriptions[id]
|
||||
},
|
||||
unsub: () => {
|
||||
Object.values(subControllers).forEach(sub => sub.unsub())
|
||||
delete activeSubscriptions[id]
|
||||
}
|
||||
}
|
||||
|
||||
return activeSubscriptions[id]
|
||||
}
|
||||
|
||||
return {
|
||||
sub,
|
||||
relays,
|
||||
setPrivateKey(privateKey) {
|
||||
globalPrivateKey = privateKey
|
||||
},
|
||||
async addRelay(url, policy = {read: true, write: true}) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
if (relayURL in relays) return
|
||||
|
||||
let relay = await relayConnect(url, notice => {
|
||||
propagateNotice(notice, relayURL)
|
||||
})
|
||||
relays[relayURL] = {relay, policy}
|
||||
|
||||
Object.values(activeSubscriptions).forEach(subscription =>
|
||||
subscription.addRelay(relay)
|
||||
)
|
||||
|
||||
return relay
|
||||
},
|
||||
removeRelay(url) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
let {relay} = relays[relayURL]
|
||||
if (!relay) return
|
||||
Object.values(activeSubscriptions).forEach(subscription =>
|
||||
subscription.removeRelay(relay)
|
||||
)
|
||||
relay.close()
|
||||
delete relays[relayURL]
|
||||
},
|
||||
onNotice(cb) {
|
||||
noticeCallbacks.push(cb)
|
||||
},
|
||||
offNotice(cb) {
|
||||
let index = noticeCallbacks.indexOf(cb)
|
||||
if (index !== -1) noticeCallbacks.splice(index, 1)
|
||||
},
|
||||
async publish(event, statusCallback = (status, relayURL) => {}) {
|
||||
if (!event.sig) {
|
||||
event.tags = event.tags || []
|
||||
|
||||
if (globalPrivateKey) {
|
||||
event.id = await getEventHash(event)
|
||||
event.sig = await signEvent(event, globalPrivateKey)
|
||||
} else {
|
||||
throw new Error(
|
||||
"can't publish unsigned event. either sign this event beforehand or pass a private key while initializing this relay pool so it can be signed automatically."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(relays)
|
||||
.filter(({policy}) => policy.write)
|
||||
.map(async ({relay}) => {
|
||||
try {
|
||||
await relay.publish(event, status =>
|
||||
statusCallback(status, relay.url)
|
||||
)
|
||||
} catch (err) {
|
||||
statusCallback(-1, relay.url)
|
||||
}
|
||||
})
|
||||
|
||||
return event
|
||||
}
|
||||
}
|
||||
}
|
||||
130
pool.test.ts
Normal file
130
pool.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import { finishEvent, type Event } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
let relays = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.bg/',
|
||||
'wss://nostr.fmt.wiz.biz/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/',
|
||||
]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub = pool.sub(relays, [{ authors: [pub] }])
|
||||
let received: Event[] = []
|
||||
|
||||
sub.on('event', event => {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('same with double querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub1 = pool.sub(relays, [{ authors: [pub] }])
|
||||
let sub2 = pool.sub(relays, [{ authors: [pub] }])
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
sub1.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
sub2.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await pool.list(
|
||||
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||
[
|
||||
{
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toEqual(
|
||||
events
|
||||
.map(evt => evt.id)
|
||||
// @ts-ignore ???
|
||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []).length,
|
||||
)
|
||||
|
||||
let relaysForAllEvents = events.map(event => pool.seenOn(event.id)).reduce((acc, n) => acc.concat(n), [])
|
||||
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
|
||||
})
|
||||
|
||||
test('seenOnEnabled: false', async () => {
|
||||
const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
|
||||
|
||||
const event = await poolWithoutSeenOn.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
|
||||
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
|
||||
|
||||
expect(relaysForEvent).toHaveLength(0)
|
||||
})
|
||||
249
pool.ts
Normal file
249
pool.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { relayInit, eventsGenerator, type Relay, type Sub, type SubscriptionOptions } from './relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
|
||||
type BatchedRequest = {
|
||||
filters: Filter<any>[]
|
||||
relays: string[]
|
||||
resolve: (events: Event<any>[]) => void
|
||||
events: Event<any>[]
|
||||
}
|
||||
|
||||
export class SimplePool {
|
||||
private _conn: { [url: string]: Relay }
|
||||
private _seenOn: { [id: string]: Set<string> } = {} // a map of all events we've seen in each relay
|
||||
private batchedByKey: { [batchKey: string]: BatchedRequest[] } = {}
|
||||
|
||||
private eoseSubTimeout: number
|
||||
private getTimeout: number
|
||||
private seenOnEnabled: boolean = true
|
||||
private batchInterval: number = 100
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
eoseSubTimeout?: number
|
||||
getTimeout?: number
|
||||
seenOnEnabled?: boolean
|
||||
batchInterval?: number
|
||||
} = {},
|
||||
) {
|
||||
this._conn = {}
|
||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||
this.getTimeout = options.getTimeout || 3400
|
||||
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||
this.batchInterval = options.batchInterval || 100
|
||||
}
|
||||
|
||||
close(relays: string[]): void {
|
||||
relays.forEach(url => {
|
||||
let relay = this._conn[normalizeURL(url)]
|
||||
if (relay) relay.close()
|
||||
})
|
||||
}
|
||||
|
||||
async ensureRelay(url: string): Promise<Relay> {
|
||||
const nm = normalizeURL(url)
|
||||
|
||||
if (!this._conn[nm]) {
|
||||
this._conn[nm] = relayInit(nm, {
|
||||
getTimeout: this.getTimeout * 0.9,
|
||||
listTimeout: this.getTimeout * 0.9,
|
||||
})
|
||||
}
|
||||
|
||||
const relay = this._conn[nm]
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
||||
let _knownIds: Set<string> = new Set()
|
||||
let modifiedOpts = { ...(opts || {}) }
|
||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||
return true
|
||||
}
|
||||
if (this.seenOnEnabled) {
|
||||
let set = this._seenOn[id] || new Set()
|
||||
set.add(url)
|
||||
this._seenOn[id] = set
|
||||
}
|
||||
return _knownIds.has(id)
|
||||
}
|
||||
|
||||
let subs: Sub[] = []
|
||||
let eventListeners: Set<any> = new Set()
|
||||
let eoseListeners: Set<() => void> = new Set()
|
||||
let eosesMissing = relays.length
|
||||
|
||||
let eoseSent = false
|
||||
let eoseTimeout = setTimeout(
|
||||
() => {
|
||||
eoseSent = true
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
},
|
||||
opts?.eoseSubTimeout || this.eoseSubTimeout,
|
||||
)
|
||||
|
||||
relays
|
||||
.filter((r, i, a) => a.indexOf(r) === i)
|
||||
.forEach(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
} catch (err) {
|
||||
handleEose()
|
||||
return
|
||||
}
|
||||
if (!r) return
|
||||
let s = r.sub(filters, modifiedOpts)
|
||||
s.on('event', event => {
|
||||
_knownIds.add(event.id as string)
|
||||
for (let cb of eventListeners.values()) cb(event)
|
||||
})
|
||||
s.on('eose', () => {
|
||||
if (eoseSent) return
|
||||
handleEose()
|
||||
})
|
||||
subs.push(s)
|
||||
|
||||
function handleEose() {
|
||||
eosesMissing--
|
||||
if (eosesMissing === 0) {
|
||||
clearTimeout(eoseTimeout)
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let greaterSub: Sub<K> = {
|
||||
sub(filters, opts) {
|
||||
subs.forEach(sub => sub.sub(filters, opts))
|
||||
return greaterSub as any
|
||||
},
|
||||
unsub() {
|
||||
subs.forEach(sub => sub.unsub())
|
||||
},
|
||||
on(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.add(cb)
|
||||
} else if (type === 'eose') {
|
||||
eoseListeners.add(cb as () => void | Promise<void>)
|
||||
}
|
||||
},
|
||||
off(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.delete(cb)
|
||||
} else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(greaterSub)
|
||||
},
|
||||
}
|
||||
|
||||
return greaterSub
|
||||
}
|
||||
|
||||
get<K extends number = number>(
|
||||
relays: string[],
|
||||
filter: Filter<K>,
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K> | null> {
|
||||
return new Promise(resolve => {
|
||||
let sub = this.sub(relays, [filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
sub.unsub()
|
||||
resolve(null)
|
||||
}, this.getTimeout)
|
||||
sub.on('event', event => {
|
||||
resolve(event)
|
||||
clearTimeout(timeout)
|
||||
sub.unsub()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
list<K extends number = number>(
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
let events: Event<K>[] = []
|
||||
let sub = this.sub(relays, filters, opts)
|
||||
|
||||
sub.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
// we can rely on an eose being emitted here because pool.sub() will fake one
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
resolve(events)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
batchedList<K extends number = number>(
|
||||
batchKey: string,
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
if (!this.batchedByKey[batchKey]) {
|
||||
this.batchedByKey[batchKey] = [
|
||||
{
|
||||
filters,
|
||||
relays,
|
||||
resolve,
|
||||
events: [],
|
||||
},
|
||||
]
|
||||
|
||||
setTimeout(() => {
|
||||
Object.keys(this.batchedByKey).forEach(async batchKey => {
|
||||
const batchedRequests = this.batchedByKey[batchKey]
|
||||
|
||||
const filters = [] as Filter[]
|
||||
const relays = [] as string[]
|
||||
batchedRequests.forEach(br => {
|
||||
filters.push(...br.filters)
|
||||
relays.push(...br.relays)
|
||||
})
|
||||
|
||||
const sub = this.sub(relays, filters)
|
||||
sub.on('event', event => {
|
||||
batchedRequests.forEach(br => matchFilters(br.filters, event) && br.events.push(event))
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
batchedRequests.forEach(br => br.resolve(br.events))
|
||||
})
|
||||
|
||||
delete this.batchedByKey[batchKey]
|
||||
})
|
||||
}, this.batchInterval)
|
||||
} else {
|
||||
this.batchedByKey[batchKey].push({
|
||||
filters,
|
||||
relays,
|
||||
resolve,
|
||||
events: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event<number>): Promise<void>[] {
|
||||
return relays.map(async relay => {
|
||||
let r = await this.ensureRelay(relay)
|
||||
return r.publish(event)
|
||||
})
|
||||
}
|
||||
|
||||
seenOn(id: string): string[] {
|
||||
return Array.from(this._seenOn[id]?.values?.() || [])
|
||||
}
|
||||
}
|
||||
46
references.test.ts
Normal file
46
references.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 './event.ts'
|
||||
|
||||
type Reference = {
|
||||
text: string
|
||||
profile?: ProfilePointer
|
||||
event?: EventPointer
|
||||
address?: AddressPointer
|
||||
}
|
||||
|
||||
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
|
||||
export function parseReferences(evt: Event): Reference[] {
|
||||
let references: Reference[] = []
|
||||
for (let ref of evt.content.matchAll(mentionRegex)) {
|
||||
if (ref[2]) {
|
||||
// it's a NIP-27 mention
|
||||
try {
|
||||
let { type, data } = decode(ref[1])
|
||||
switch (type) {
|
||||
case 'npub': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: { pubkey: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nprofile': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: data as ProfilePointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'note': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: { id: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nevent': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: data as EventPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'naddr': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: data as AddressPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
} else if (ref[3]) {
|
||||
// it's a NIP-10 mention
|
||||
let idx = parseInt(ref[3], 10)
|
||||
let tag = evt.tags[idx]
|
||||
if (!tag) continue
|
||||
|
||||
switch (tag[0]) {
|
||||
case 'p': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
try {
|
||||
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: {
|
||||
identifier,
|
||||
pubkey,
|
||||
kind: parseInt(kind, 10),
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
172
relay.js
172
relay.js
@@ -1,172 +0,0 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import {verifySignature} from './event'
|
||||
import {matchFilters} from './filter'
|
||||
|
||||
export function normalizeRelayURL(url) {
|
||||
let [host, ...qs] = url.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) {
|
||||
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 = () => {
|
||||
console.log('error connecting to relay', url)
|
||||
}
|
||||
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 (
|
||||
(await verifySignature(event)) &&
|
||||
channels[channel] &&
|
||||
matchFilters(openSubs[channel], event)
|
||||
) {
|
||||
channels[channel](event)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetOpenState()
|
||||
|
||||
try {
|
||||
connect()
|
||||
} catch (err) {}
|
||||
|
||||
async function trySend(params) {
|
||||
let msg = JSON.stringify(params)
|
||||
|
||||
await untilOpen
|
||||
ws.send(msg)
|
||||
}
|
||||
|
||||
const sub = ({cb, filter}, channel = Math.random().toString().slice(2)) => {
|
||||
var filters = []
|
||||
if (Array.isArray(filter)) {
|
||||
filters = filter
|
||||
} else {
|
||||
filters.push(filter)
|
||||
}
|
||||
|
||||
trySend(['REQ', channel, ...filters])
|
||||
channels[channel] = cb
|
||||
openSubs[channel] = filters
|
||||
|
||||
const activeCallback = cb
|
||||
const activeFilters = filters
|
||||
|
||||
return {
|
||||
sub: ({cb = activeCallback, filter = activeFilters}) =>
|
||||
sub({cb, filter}, channel),
|
||||
unsub: () => {
|
||||
delete openSubs[channel]
|
||||
delete channels[channel]
|
||||
trySend(['CLOSE', channel])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
async publish(event, statusCallback = status => {}) {
|
||||
try {
|
||||
await trySend(['EVENT', event])
|
||||
statusCallback(0)
|
||||
let {unsub} = relay.sub(
|
||||
{
|
||||
cb: () => {
|
||||
statusCallback(1)
|
||||
},
|
||||
filter: {id: event.id}
|
||||
},
|
||||
`monitor-${event.id.slice(0, 5)}`
|
||||
)
|
||||
setTimeout(unsub, 5000)
|
||||
} catch (err) {
|
||||
statusCallback(-1)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
ws.close()
|
||||
},
|
||||
get status() {
|
||||
return ws.readyState
|
||||
}
|
||||
}
|
||||
}
|
||||
140
relay.test.ts
Normal file
140
relay.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import { finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { relayInit } from './relay.ts'
|
||||
|
||||
let relay = relayInit('wss://relay.damus.io/')
|
||||
|
||||
beforeAll(() => {
|
||||
relay.connect()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity', () => {
|
||||
return expect(
|
||||
new Promise(resolve => {
|
||||
relay.on('connect', () => {
|
||||
resolve(true)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let [t1, t2] = await Promise.all([
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
])
|
||||
|
||||
expect(t1).toEqual(true)
|
||||
expect(t2).toEqual(true)
|
||||
}, 10000)
|
||||
|
||||
test('async iterator', async () => {
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
|
||||
for await (const event of sub.events) {
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await relay.get({
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await relay.list([
|
||||
{
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(events.length).toEqual(2)
|
||||
})
|
||||
|
||||
test('listening (twice) and publishing', async () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [27572],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
kind: 27572,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
return expect(
|
||||
Promise.all([
|
||||
new Promise(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
]),
|
||||
).resolves.toEqual([true, true])
|
||||
})
|
||||
398
relay.ts
Normal file
398
relay.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifySignature, validateEvent, type Event } from './event.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { MessageQueue } from './utils.ts'
|
||||
|
||||
type RelayEvent = {
|
||||
connect: () => void | Promise<void>
|
||||
disconnect: () => void | Promise<void>
|
||||
error: () => void | Promise<void>
|
||||
notice: (msg: string) => void | Promise<void>
|
||||
auth: (challenge: string) => void | Promise<void>
|
||||
}
|
||||
export type CountPayload = {
|
||||
count: number
|
||||
}
|
||||
export type SubEvent<K extends number> = {
|
||||
event: (event: Event<K>) => void | Promise<void>
|
||||
count: (payload: CountPayload) => void | Promise<void>
|
||||
eose: () => void | Promise<void>
|
||||
}
|
||||
export type Relay = {
|
||||
url: string
|
||||
status: number
|
||||
connect: () => Promise<void>
|
||||
close: () => void
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
|
||||
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
|
||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
||||
count: (filters: Filter[], opts?: SubscriptionOptions) => Promise<CountPayload | null>
|
||||
publish: (event: Event<number>) => Promise<void>
|
||||
auth: (event: Event<number>) => Promise<void>
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
|
||||
}
|
||||
export type Sub<K extends number = number> = {
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
||||
unsub: () => void
|
||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
events: AsyncGenerator<Event<K>, void, unknown>
|
||||
}
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
id?: string
|
||||
verb?: 'REQ' | 'COUNT'
|
||||
skipVerification?: boolean
|
||||
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
|
||||
eoseSubTimeout?: number
|
||||
}
|
||||
|
||||
const newListeners = (): { [TK in keyof RelayEvent]: RelayEvent[TK][] } => ({
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: [],
|
||||
auth: [],
|
||||
})
|
||||
|
||||
export function relayInit(
|
||||
url: string,
|
||||
options: {
|
||||
getTimeout?: number
|
||||
listTimeout?: number
|
||||
countTimeout?: number
|
||||
} = {},
|
||||
): Relay {
|
||||
let { listTimeout = 3000, getTimeout = 3000, countTimeout = 3000 } = options
|
||||
|
||||
var ws: WebSocket
|
||||
var openSubs: { [id: string]: { filters: Filter[] } & SubscriptionOptions } = {}
|
||||
var listeners = newListeners()
|
||||
var subListeners: {
|
||||
[subid: string]: { [TK in keyof SubEvent<any>]: SubEvent<any>[TK][] }
|
||||
} = {}
|
||||
var pubListeners: {
|
||||
[eventid: string]: {
|
||||
resolve: (_: unknown) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
} = {}
|
||||
|
||||
var connectionPromise: Promise<void> | undefined
|
||||
async function connectRelay(): Promise<void> {
|
||||
if (connectionPromise) return connectionPromise
|
||||
connectionPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
listeners.connect.forEach(cb => cb())
|
||||
resolve()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
connectionPromise = undefined
|
||||
listeners.error.forEach(cb => cb())
|
||||
reject()
|
||||
}
|
||||
ws.onclose = async () => {
|
||||
connectionPromise = undefined
|
||||
listeners.disconnect.forEach(cb => cb())
|
||||
}
|
||||
|
||||
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||
let handleNextInterval: any
|
||||
|
||||
ws.onmessage = e => {
|
||||
incomingMessageQueue.enqueue(e.data)
|
||||
if (!handleNextInterval) {
|
||||
handleNextInterval = setInterval(handleNext, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (incomingMessageQueue.size === 0) {
|
||||
clearInterval(handleNextInterval)
|
||||
handleNextInterval = null
|
||||
return
|
||||
}
|
||||
|
||||
var json = incomingMessageQueue.dequeue()
|
||||
if (!json) return
|
||||
|
||||
let subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
let so = openSubs[subid]
|
||||
if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, 'id'), url)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json)
|
||||
|
||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
let id = data[1]
|
||||
let event = data[2]
|
||||
if (
|
||||
validateEvent(event) &&
|
||||
openSubs[id] &&
|
||||
(openSubs[id].skipVerification || verifySignature(event)) &&
|
||||
matchFilters(openSubs[id].filters, event)
|
||||
) {
|
||||
openSubs[id]
|
||||
;(subListeners[id]?.event || []).forEach(cb => cb(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT':
|
||||
let id = data[1]
|
||||
let payload = data[2]
|
||||
if (openSubs[id]) {
|
||||
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
|
||||
}
|
||||
return
|
||||
case 'EOSE': {
|
||||
let id = data[1]
|
||||
if (id in subListeners) {
|
||||
subListeners[id].eose.forEach(cb => cb())
|
||||
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'OK': {
|
||||
let id: string = data[1]
|
||||
let ok: boolean = data[2]
|
||||
let reason: string = data[3] || ''
|
||||
if (id in pubListeners) {
|
||||
let { resolve, reject } = pubListeners[id]
|
||||
if (ok) resolve(null)
|
||||
else reject(new Error(reason))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
let notice = data[1]
|
||||
listeners.notice.forEach(cb => cb(notice))
|
||||
return
|
||||
case 'AUTH': {
|
||||
let challenge = data[1]
|
||||
listeners.auth?.forEach(cb => cb(challenge))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return connectionPromise
|
||||
}
|
||||
|
||||
function connected() {
|
||||
return ws?.readyState === 1
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (connected()) return // ws already open
|
||||
await connectRelay()
|
||||
}
|
||||
|
||||
async function trySend(params: [string, ...any]) {
|
||||
let msg = JSON.stringify(params)
|
||||
if (!connected()) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
if (!connected()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(msg)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const sub = <K extends number = number>(
|
||||
filters: Filter<K>[],
|
||||
{
|
||||
verb = 'REQ',
|
||||
skipVerification = false,
|
||||
alreadyHaveEvent = null,
|
||||
id = Math.random().toString().slice(2),
|
||||
}: SubscriptionOptions = {},
|
||||
): Sub<K> => {
|
||||
let subid = id
|
||||
|
||||
openSubs[subid] = {
|
||||
id: subid,
|
||||
filters,
|
||||
skipVerification,
|
||||
alreadyHaveEvent,
|
||||
}
|
||||
trySend([verb, subid, ...filters])
|
||||
|
||||
let subscription: Sub<K> = {
|
||||
sub: (newFilters, newOpts = {}) =>
|
||||
sub(newFilters || filters, {
|
||||
skipVerification: newOpts.skipVerification || skipVerification,
|
||||
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
|
||||
id: subid,
|
||||
}),
|
||||
unsub: () => {
|
||||
delete openSubs[subid]
|
||||
delete subListeners[subid]
|
||||
trySend(['CLOSE', subid])
|
||||
},
|
||||
on: (type, cb) => {
|
||||
subListeners[subid] = subListeners[subid] || {
|
||||
event: [],
|
||||
count: [],
|
||||
eose: [],
|
||||
}
|
||||
subListeners[subid][type].push(cb)
|
||||
},
|
||||
off: (type, cb): void => {
|
||||
let listeners = subListeners[subid]
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(subscription)
|
||||
},
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
function _publishEvent(event: Event<number>, type: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!event.id) {
|
||||
reject(new Error(`event ${event} has no id`))
|
||||
return
|
||||
}
|
||||
|
||||
let id = event.id
|
||||
trySend([type, event])
|
||||
pubListeners[id] = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
listeners[type].push(cb)
|
||||
if (type === 'connect' && ws?.readyState === 1) {
|
||||
// i would love to know why we need this
|
||||
;(cb as () => void)()
|
||||
}
|
||||
},
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
let index = listeners[type].indexOf(cb)
|
||||
if (index !== -1) listeners[type].splice(index, 1)
|
||||
},
|
||||
list: (filters, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, opts)
|
||||
let events: Event<any>[] = []
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(events)
|
||||
}, listTimeout)
|
||||
s.on('eose', () => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(events)
|
||||
})
|
||||
s.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
}),
|
||||
get: (filter, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub([filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, getTimeout)
|
||||
s.on('event', event => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
count: (filters: Filter[]): Promise<CountPayload | null> =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, { ...sub, verb: 'COUNT' })
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, countTimeout)
|
||||
s.on('count', (event: CountPayload) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
async publish(event): Promise<void> {
|
||||
await _publishEvent(event, 'EVENT')
|
||||
},
|
||||
async auth(event): Promise<void> {
|
||||
await _publishEvent(event, 'AUTH')
|
||||
},
|
||||
connect,
|
||||
close(): void {
|
||||
listeners = newListeners()
|
||||
subListeners = {}
|
||||
pubListeners = {}
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
get status() {
|
||||
return ws?.readyState ?? 3
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function* eventsGenerator<K extends number>(sub: Sub<K>): AsyncGenerator<Event<K>, void, unknown> {
|
||||
let nextResolve: ((event: Event<K>) => void) | undefined
|
||||
const eventQueue: Event<K>[] = []
|
||||
|
||||
const pushToQueue = (event: Event<K>) => {
|
||||
if (nextResolve) {
|
||||
nextResolve(event)
|
||||
nextResolve = undefined
|
||||
} else {
|
||||
eventQueue.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
sub.on('event', pushToQueue)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (eventQueue.length > 0) {
|
||||
yield eventQueue.shift()!
|
||||
} else {
|
||||
const event = await new Promise<Event<K>>(resolve => {
|
||||
nextResolve = resolve
|
||||
})
|
||||
yield event
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sub.off('event', pushToQueue)
|
||||
}
|
||||
}
|
||||
17
test-helpers.ts
Normal file
17
test-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
type EventParams<K extends number> = Partial<Event<K>>
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1 as K,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
tags: [],
|
||||
sig: '',
|
||||
...params,
|
||||
}
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
6
utils.js
6
utils.js
@@ -1,6 +0,0 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
|
||||
export const makeRandom32 = () => secp256k1.utils.randomPrivateKey()
|
||||
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
|
||||
export const getPublicKey = privateKey =>
|
||||
secp256k1.schnorr.getPublicKey(privateKey)
|
||||
259
utils.test.ts
Normal file
259
utils.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { MessageQueue, insertEventIntoAscendingList, insertEventIntoDescendingList } from './utils.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 1 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 15,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 5,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0: Event[] = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 1,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 25,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 50,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enque a message into MessageQueue', () => {
|
||||
test('enque into an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
})
|
||||
test('enque into a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
expect(queue.last!.value).toBe('node2')
|
||||
expect(queue.size).toBe(3)
|
||||
})
|
||||
test('dequeue from an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe(null)
|
||||
expect(queue.size).toBe(0)
|
||||
})
|
||||
test('dequeue from a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
})
|
||||
test('dequeue more than in queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
expect(queue.size).toBe(0)
|
||||
const item3 = queue.dequeue()
|
||||
expect(item3).toBe(null)
|
||||
})
|
||||
})
|
||||
169
utils.ts
Normal file
169
utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
//
|
||||
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
||||
//
|
||||
export function insertEventIntoDescendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at < sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at >= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at > sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at <= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export class MessageNode {
|
||||
private _value: string
|
||||
private _next: MessageNode | null
|
||||
|
||||
public get value(): string {
|
||||
return this._value
|
||||
}
|
||||
public set value(message: string) {
|
||||
this._value = message
|
||||
}
|
||||
public get next(): MessageNode | null {
|
||||
return this._next
|
||||
}
|
||||
public set next(node: MessageNode | null) {
|
||||
this._next = node
|
||||
}
|
||||
|
||||
constructor(message: string) {
|
||||
this._value = message
|
||||
this._next = null
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageQueue {
|
||||
private _first: MessageNode | null
|
||||
private _last: MessageNode | null
|
||||
|
||||
public get first(): MessageNode | null {
|
||||
return this._first
|
||||
}
|
||||
public set first(messageNode: MessageNode | null) {
|
||||
this._first = messageNode
|
||||
}
|
||||
public get last(): MessageNode | null {
|
||||
return this._last
|
||||
}
|
||||
public set last(messageNode: MessageNode | null) {
|
||||
this._last = messageNode
|
||||
}
|
||||
private _size: number
|
||||
public get size(): number {
|
||||
return this._size
|
||||
}
|
||||
public set size(v: number) {
|
||||
this._size = v
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._first = null
|
||||
this._last = null
|
||||
this._size = 0
|
||||
}
|
||||
enqueue(message: string): boolean {
|
||||
const newNode = new MessageNode(message)
|
||||
if (this._size === 0 || !this._last) {
|
||||
this._first = newNode
|
||||
this._last = newNode
|
||||
} else {
|
||||
this._last.next = newNode
|
||||
this._last = newNode
|
||||
}
|
||||
this._size++
|
||||
return true
|
||||
}
|
||||
dequeue(): string | null {
|
||||
if (this._size === 0 || !this._first) return null
|
||||
|
||||
let prev = this._first
|
||||
this._first = prev.next
|
||||
prev.next = null
|
||||
|
||||
this._size--
|
||||
return prev.value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user