mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
387 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d155bcdcda | ||
|
|
919d29363e | ||
|
|
ef12a451be | ||
|
|
a9acdada19 | ||
|
|
bf3818e434 | ||
|
|
b7389be5c7 | ||
|
|
7552a36ff2 | ||
|
|
1b31a27d89 | ||
|
|
0cc3c02d84 | ||
|
|
8625d45152 | ||
|
|
8f03116687 | ||
|
|
e6d1808fda | ||
|
|
9648de3470 | ||
|
|
fe87529646 | ||
|
|
1908e1ee0d | ||
|
|
2571db9afc | ||
|
|
f77b9eab10 | ||
|
|
71b412657f | ||
|
|
8840c4d8e2 | ||
|
|
804403f574 | ||
|
|
965ebdb6d1 | ||
|
|
c54fd95b3e | ||
|
|
7a6c0754ad | ||
|
|
9e4911160a | ||
|
|
73c6630cf7 | ||
|
|
88703e9ea2 | ||
|
|
07d208308f | ||
|
|
f56f2ae709 | ||
|
|
a0cb2eecae | ||
|
|
2a7fd83be8 | ||
|
|
1ebe098805 | ||
|
|
3bfb50e267 | ||
|
|
420a6910e9 | ||
|
|
7a640092d0 | ||
|
|
3d541e537e | ||
|
|
1357642575 | ||
|
|
d16f3f77c3 | ||
|
|
0108e3b605 | ||
|
|
2ac69278ce | ||
|
|
bf31f2eba3 | ||
|
|
39cfc5c09e | ||
|
|
3d767beeb9 | ||
|
|
36e0de2a68 | ||
|
|
9cd4f16e45 | ||
|
|
6a07e7c1cc | ||
|
|
1939c46eaa | ||
|
|
93538d2373 | ||
|
|
19b3faea17 | ||
|
|
867aa11d12 | ||
|
|
4fcf925387 | ||
|
|
40c5337ef0 | ||
|
|
350d8ec3b6 | ||
|
|
c5f3c8052e | ||
|
|
dc04d1eb85 | ||
|
|
a2a15567b7 | ||
|
|
318e3f8c88 | ||
|
|
894ffff1f0 | ||
|
|
ce11a5fc89 | ||
|
|
5e85bbc2ed | ||
|
|
eb0a9093f2 | ||
|
|
c73268c4e2 | ||
|
|
6874f58c0a | ||
|
|
e899cc32b7 | ||
|
|
de72172583 | ||
|
|
073dcaafd6 | ||
|
|
8e932f0c5a | ||
|
|
f9a048679f | ||
|
|
6db8b94275 | ||
|
|
13bc2ad5a8 | ||
|
|
55f032d0a4 | ||
|
|
c890e29290 | ||
|
|
c18f050468 | ||
|
|
401b9c7864 | ||
|
|
c175f6c804 | ||
|
|
41265a19f5 | ||
|
|
d88761907a | ||
|
|
8325d4351e | ||
|
|
62bf592d72 | ||
|
|
54f3bedf38 | ||
|
|
34e0ad8c41 | ||
|
|
e9eac28bab | ||
|
|
85035d61f2 | ||
|
|
cf46560619 | ||
|
|
e7aa23cb1d | ||
|
|
5977d68ec2 | ||
|
|
48767d382d | ||
|
|
718032022c | ||
|
|
2a70bb18ff | ||
|
|
9effe807d1 | ||
|
|
899c2bd0dc | ||
|
|
918d514a25 | ||
|
|
48cb9046c4 | ||
|
|
864dd28b26 | ||
|
|
fa085367c9 | ||
|
|
350951b88e | ||
|
|
c6133f7160 | ||
|
|
470512bbeb | ||
|
|
c3acb82464 | ||
|
|
fc23d05764 | ||
|
|
8296ce897c | ||
|
|
3ca78c0e13 | ||
|
|
837a05e54d | ||
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
1c6f39e4ae | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f | ||
|
|
55ff796b9f | ||
|
|
3ef2ad5bc4 | ||
|
|
45c07a5f45 | ||
|
|
6a037d1658 | ||
|
|
dcf101c6c2 | ||
|
|
eb97dbd9ef | ||
|
|
92988051c6 | ||
|
|
bf7e00d32a | ||
|
|
9241089997 | ||
|
|
32c47e9bd8 | ||
|
|
6e58fe371c | ||
|
|
26e35d50e0 | ||
|
|
ef3184a6e0 | ||
|
|
56fe3dd5dd | ||
|
|
f1bb5030c8 | ||
|
|
ac212cb5c8 | ||
|
|
204ae0eff1 | ||
|
|
f17ab41d72 | ||
|
|
f6f5ee8223 | ||
|
|
a05506468d | ||
|
|
674ff66b6f | ||
|
|
731705047a | ||
|
|
94b382a49f | ||
|
|
199411a971 | ||
|
|
a1dc6f41b9 | ||
|
|
5b59b93d86 | ||
|
|
12acd7bdca | ||
|
|
3bdb68020d | ||
|
|
b0a58e2ca4 | ||
|
|
b063be76ae | ||
|
|
e3cea5db16 | ||
|
|
9ee58bd6c7 | ||
|
|
f1eb9a3bc7 | ||
|
|
ce081bb4cb | ||
|
|
7413072e9f | ||
|
|
4c464b39cf | ||
|
|
11ef43abdc | ||
|
|
3e67f9b014 | ||
|
|
0933fba6d5 | ||
|
|
51b8f42529 | ||
|
|
24d885aaeb | ||
|
|
74c77a2e9f | ||
|
|
ce73b96565 | ||
|
|
8818e4f88a | ||
|
|
5a63c75f24 | ||
|
|
60e01a9006 | ||
|
|
687f387385 | ||
|
|
6d116a2f7f | ||
|
|
51c3aec788 | ||
|
|
613b2c177f | ||
|
|
24f5068fdb | ||
|
|
5733f9c4e4 | ||
|
|
6b73bbf8a3 | ||
|
|
d244b62c7a | ||
|
|
b00af9a30a | ||
|
|
be7c981c14 | ||
|
|
5539e5cf89 | ||
|
|
73decbc8e0 | ||
|
|
b3d95cecdd | ||
|
|
82228036ef | ||
|
|
01435ab9f5 | ||
|
|
63cbc4133a | ||
|
|
049f183d27 | ||
|
|
f9e3119ab4 | ||
|
|
f992c9c967 | ||
|
|
dbf625d6ac | ||
|
|
8622bd11dd | ||
|
|
0970eee70f | ||
|
|
086f8830e3 | ||
|
|
e48d722227 | ||
|
|
0d77013aab | ||
|
|
4c415280aa | ||
|
|
4188aaf7c8 | ||
|
|
673f4abab8 | ||
|
|
bcefaa0757 | ||
|
|
649af36a86 | ||
|
|
96a6f7af87 | ||
|
|
a4c713efcb | ||
|
|
9d345a8f01 | ||
|
|
c362212778 | ||
|
|
a8938a3a0f | ||
|
|
a21329da3f | ||
|
|
63f4a49a69 | ||
|
|
27749d91b8 | ||
|
|
9530849f0a | ||
|
|
b8aa75b6e1 | ||
|
|
344762820c | ||
|
|
f43d23d344 | ||
|
|
bf55ad6b5a | ||
|
|
04a46b815c | ||
|
|
165ff44dff | ||
|
|
7bfd23af3c | ||
|
|
3d93ec8446 | ||
|
|
0f841138cd | ||
|
|
336948b1d1 | ||
|
|
d46794c681 | ||
|
|
93cef5d886 | ||
|
|
2324f9548e | ||
|
|
f9748d9cc3 | ||
|
|
3a22dd3da6 | ||
|
|
d13039dc11 | ||
|
|
95b03902cc | ||
|
|
ab5ea8de36 | ||
|
|
a330b97590 | ||
|
|
24406b5679 | ||
|
|
6dbcc87d93 | ||
|
|
0ddcfdce68 | ||
|
|
87bf349ce8 | ||
|
|
54dfc7b972 | ||
|
|
32793146a4 | ||
|
|
c42cd925ce | ||
|
|
43ccb72476 | ||
|
|
b2b7999517 | ||
|
|
a568afc295 | ||
|
|
9bcaed6e60 | ||
|
|
5a9cbbb557 | ||
|
|
e9acc59809 | ||
|
|
18fe9637b9 | ||
|
|
ff3bf4a51c | ||
|
|
7ff97b5488 | ||
|
|
df169ea42b | ||
|
|
341f2bcb8d | ||
|
|
b2d1dd2110 | ||
|
|
75d7be5a54 | ||
|
|
b5c8255b2f | ||
|
|
4485c8ed5e | ||
|
|
3710866430 | ||
|
|
da59e3ce90 | ||
|
|
cc8e34163d | ||
|
|
9082953ede | ||
|
|
61f397463d | ||
|
|
312b6fd035 | ||
|
|
7f1bd4f4a8 | ||
|
|
26089ef958 | ||
|
|
2e305b7cd4 | ||
|
|
51c1a54ddf | ||
|
|
cb05ee188f | ||
|
|
fa9e169c46 | ||
|
|
bb1e3f2fa6 | ||
|
|
160987472f | ||
|
|
8b18341ebb | ||
|
|
901445dea1 | ||
|
|
91b67cd0d5 | ||
|
|
1e696e0f3b | ||
|
|
4b36848b2d | ||
|
|
3cb351a5f4 | ||
|
|
5db1934fa4 | ||
|
|
50c3f24b25 | ||
|
|
39ea47660d | ||
|
|
8071e2f4fa | ||
|
|
cc2250da1f | ||
|
|
c37d10bb9d | ||
|
|
97e28fdf9a | ||
|
|
87c0f0d061 | ||
|
|
83c397b839 | ||
|
|
cd7d1cec48 | ||
|
|
613a843838 | ||
|
|
74a0d5454a | ||
|
|
c0d1e41424 | ||
|
|
f7e510e1c8 | ||
|
|
c08bdac7a7 | ||
|
|
c5b64404f6 | ||
|
|
c7b26fdba2 | ||
|
|
ac698ef67d | ||
|
|
8262a81cb2 | ||
|
|
26e6da6ba3 | ||
|
|
8aa31bb437 | ||
|
|
4bd4469357 | ||
|
|
89ae21f796 | ||
|
|
41a1614d89 | ||
|
|
0500415a4e | ||
|
|
cee4357cab | ||
|
|
d5cf5930d1 | ||
|
|
a78e2036aa | ||
|
|
adc1854ac6 | ||
|
|
83148e8bdf | ||
|
|
364c37cac5 | ||
|
|
385cdb4ac6 | ||
|
|
3f1025f551 | ||
|
|
482c5affd4 | ||
|
|
679ac0c133 | ||
|
|
b96159ad36 | ||
|
|
6dede4a688 | ||
|
|
50c8bb72f9 | ||
|
|
72781e0eab | ||
|
|
bf120c1348 | ||
|
|
3630d377e5 | ||
|
|
53b0091bf4 | ||
|
|
1a7cc5f21f | ||
|
|
1162935f58 | ||
|
|
a49d971f6a | ||
|
|
897919be3b | ||
|
|
39aca167fb | ||
|
|
de8bdd8370 | ||
|
|
46a0a342db | ||
|
|
4fe2a9c91a | ||
|
|
e62b833464 | ||
|
|
100c77d2aa | ||
|
|
12be5a5338 | ||
|
|
b955ba2a09 | ||
|
|
ec805be4ab | ||
|
|
92fb339afb | ||
|
|
f8f125270a | ||
|
|
1b798b2eee | ||
|
|
ae717a1a4a | ||
|
|
b2015c8fe5 | ||
|
|
c5d2e3b037 | ||
|
|
0ef5d1e19c | ||
|
|
7d9d10fdb1 | ||
|
|
a1e1ce131a | ||
|
|
cdb07bb175 | ||
|
|
1f1bcff803 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"ecmaFeatures": {
|
||||
@@ -14,14 +19,11 @@
|
||||
"node": true
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
"babel"
|
||||
],
|
||||
|
||||
"globals": {
|
||||
"document": false,
|
||||
"navigator": false,
|
||||
"window": false,
|
||||
"crypto": false,
|
||||
"location": false,
|
||||
"URL": false,
|
||||
"URLSearchParams": false,
|
||||
@@ -44,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 }],
|
||||
@@ -100,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,
|
||||
@@ -117,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,
|
||||
|
||||
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: test every commit
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: bun i
|
||||
- run: just test
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: bun i
|
||||
- run: just lint
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,5 +2,6 @@ node_modules
|
||||
dist
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
nostr.js
|
||||
.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>
|
||||
303
README.md
303
README.md
@@ -1,93 +1,250 @@
|
||||
# nostr-tools
|
||||
#  nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
## Usage
|
||||
Only depends on _@scure_ and _@noble_ packages.
|
||||
|
||||
```js
|
||||
import {relayPool} from 'nostr-tools'
|
||||
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||
|
||||
const pool = relayPool()
|
||||
## Installation
|
||||
|
||||
pool.setPrivateKey('<hex>') // optional
|
||||
|
||||
pool.addRelay('ws://some.relay.com', {read: true, write: true})
|
||||
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
|
||||
|
||||
// example callback function for a subscription
|
||||
function onEvent(event, relay) {
|
||||
console.log(`got an event from ${relay.url} which is already validated.`, event)
|
||||
}
|
||||
|
||||
// subscribing to a single user
|
||||
// author is the user's public key
|
||||
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
|
||||
|
||||
// or bulk follow
|
||||
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
|
||||
|
||||
// reuse a subscription channel
|
||||
const mySubscription = pool.sub({cb: ..., filter: ....})
|
||||
mySubscription.sub({filter: ....})
|
||||
mySubscription.sub({cb: ...})
|
||||
mySubscription.unsub()
|
||||
|
||||
// get specific event
|
||||
const specificChannel = pool.sub({
|
||||
cb: (event, relay) => {
|
||||
console.log('got specific event from relay', event, relay)
|
||||
specificChannel.unsub()
|
||||
},
|
||||
filter: {id: '<hex>'}
|
||||
})
|
||||
|
||||
// or get a specific event plus all the events that reference it in the 'e' tag
|
||||
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
|
||||
|
||||
// get all events
|
||||
pool.sub({cb: (event, relay) => {...}, filter: {}})
|
||||
|
||||
// get recent events
|
||||
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
|
||||
|
||||
// publishing events(inside an async function):
|
||||
const ev = await pool.publish(eventObject, (status, url) => {
|
||||
if (status === 0) {
|
||||
console.log(`publish request sent to ${url}`)
|
||||
}
|
||||
if (status === 1) {
|
||||
console.log(`event published by ${url}`, ev)
|
||||
}
|
||||
})
|
||||
// it will be signed automatically with the key supplied above
|
||||
// or pass an already signed event to bypass this
|
||||
|
||||
// subscribing to a new relay
|
||||
pool.addRelay('<url>')
|
||||
// will automatically subscribe to the all the events called with .sub above
|
||||
```bash
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
```
|
||||
|
||||
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
For other utils please read the source (for now).
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
```
|
||||
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
|
||||
let event = finalizeEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
}, sk)
|
||||
|
||||
let isGood = verifyEvent(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
|
||||
```js
|
||||
import { relayConnect, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const relay = await relayConnect('wss://relay.example.com')
|
||||
console.log(`connected to ${relay.url}`)
|
||||
|
||||
// let's query for an event that exists
|
||||
const sub = relay.subscribe([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
console.log('got event:', event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello world',
|
||||
}
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(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 h = pool.subscribeMany(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
},
|
||||
oneose() {
|
||||
h.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, newEvent))
|
||||
console.log('published to at least one relay!')
|
||||
|
||||
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
```
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
|
||||
```js
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
let { text, profile, event, address } = references[i]
|
||||
let augmentedReference = profile
|
||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||
: event
|
||||
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
||||
: address
|
||||
? `<a href="${text}">[link]</a>`
|
||||
: text
|
||||
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
||||
}
|
||||
```
|
||||
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import { nip05 } from 'nostr-tools'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||
console.log(profile.relays)
|
||||
// prints: [wss://relay.damus.io]
|
||||
```
|
||||
|
||||
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||
|
||||
```js
|
||||
nip05.useFetchImplementation(require('node-fetch'))
|
||||
```
|
||||
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
let { type, data } = nip19.decode(nsec)
|
||||
assert(type === 'nsec')
|
||||
assert(data === sk)
|
||||
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
let { type, data } = nip19.decode(npub)
|
||||
assert(type === 'npub')
|
||||
assert(data === pk)
|
||||
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||
let { type, data } = nip19.decode(nprofile)
|
||||
assert(type === 'nprofile')
|
||||
assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
## Import modes
|
||||
|
||||
### Using just the packages you want
|
||||
|
||||
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
|
||||
|
||||
```js
|
||||
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||
import { matchFilter } from 'nostr-tools/filter'
|
||||
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
||||
// and so on and so forth
|
||||
```
|
||||
|
||||
### Using it with `nostr-wasm`
|
||||
|
||||
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||
|
||||
```js
|
||||
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
|
||||
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||
initNostrWasm().then(setNostrWasm)
|
||||
|
||||
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||
```
|
||||
|
||||
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`.
|
||||
|
||||
### Using from the browser (if you don't want to use a bundler)
|
||||
|
||||
You can import nostr-tools as an ES module. Just add a script tag like this:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import {generatePrivateKey} from 'https://unpkg.com/nostr-tools/nostr.js'
|
||||
console.log(generatePrivateKey())
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
<script>
|
||||
window.NostrTools.generateSecretKey('...') // and so on
|
||||
</script>
|
||||
```
|
||||
|
||||
And import whatever function you would import from `"nostr-tools"` in a bundler.
|
||||
## Plumbing
|
||||
|
||||
## TypeScript
|
||||
|
||||
This module has hand-authored TypeScript declarations. `npm run check-ts` will run a lint-check script to ensure the typings can be loaded and call at least a few standard library functions. It's not at all comprehensive and likely to contain bugs. Issues welcome; tag @rcoder as needed.
|
||||
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
|
||||
|
||||
## License
|
||||
|
||||
Public domain.
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
25
build.cjs
25
build.cjs
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const esbuild = require('esbuild')
|
||||
const alias = require('esbuild-plugin-alias')
|
||||
const nodeGlobals = require('@esbuild-plugins/node-globals-polyfill').default
|
||||
|
||||
const buildOptions = {
|
||||
entryPoints: ['index.js'],
|
||||
outfile: 'nostr.js',
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
plugins: [
|
||||
alias({
|
||||
stream: require.resolve('readable-stream')
|
||||
}),
|
||||
nodeGlobals({buffer: true})
|
||||
],
|
||||
define: {
|
||||
window: 'self',
|
||||
global: 'self'
|
||||
},
|
||||
loader: {'.js': 'jsx'}
|
||||
}
|
||||
|
||||
esbuild.build(buildOptions).then(() => console.log('build success.'))
|
||||
59
build.js
Executable file
59
build.js
Executable file
@@ -0,0 +1,59 @@
|
||||
const fs = require('node:fs')
|
||||
const esbuild = require('esbuild')
|
||||
const { join } = require('path')
|
||||
|
||||
const entryPoints = fs
|
||||
.readdirSync(process.cwd())
|
||||
.filter(
|
||||
file =>
|
||||
file.endsWith('.ts') &&
|
||||
file !== 'core.ts' &&
|
||||
file !== 'test-helpers.ts' &&
|
||||
file !== 'helpers.ts' &&
|
||||
!file.endsWith('.test.ts') &&
|
||||
fs.statSync(join(process.cwd(), file)).isFile(),
|
||||
)
|
||||
|
||||
let common = {
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
sourcemap: 'external',
|
||||
}
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outdir: 'lib/esm',
|
||||
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,
|
||||
outdir: 'lib/cjs',
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
entryPoints: ['index.ts'],
|
||||
outfile: 'lib/nostr.bundle.js',
|
||||
format: 'iife',
|
||||
globalName: 'NostrTools',
|
||||
define: {
|
||||
window: 'self',
|
||||
global: 'self',
|
||||
process: '{"env": {}}',
|
||||
},
|
||||
})
|
||||
.then(() => console.log('standalone build success.'))
|
||||
293
core.test.ts
Normal file
293
core.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import {
|
||||
finalizeEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifyEvent,
|
||||
verifiedSymbol,
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
} from './pure.ts'
|
||||
import { ShortTextNote } from './kinds.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
|
||||
describe('finalizeEvent', () => {
|
||||
test('should create a signed event from a template', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finalizeEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
test('should serialize a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
test('should return the correct event hash', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid properties', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid tags', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEvent', () => {
|
||||
test('should return true for a valid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifyEvent({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event id', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
50
core.ts
Normal file
50
core.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface Nostr {
|
||||
generateSecretKey(): Uint8Array
|
||||
getPublicKey(secretKey: Uint8Array): string
|
||||
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
|
||||
verifyEvent(event: Event): event is VerifiedEvent
|
||||
}
|
||||
|
||||
/** Designates a verified event signature. */
|
||||
export const verifiedSymbol = Symbol('verified')
|
||||
|
||||
export interface Event {
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
id: string
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
||||
|
||||
/** An event whose signature has been verified. */
|
||||
export interface VerifiedEvent extends Event {
|
||||
[verifiedSymbol]: true
|
||||
}
|
||||
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
if (typeof event.pubkey !== 'string') return false
|
||||
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
58
event.js
58
event.js
@@ -1,58 +0,0 @@
|
||||
import {Buffer} from 'buffer'
|
||||
import createHash from 'create-hash'
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
|
||||
export function getBlankEvent() {
|
||||
return {
|
||||
kind: 255,
|
||||
pubkey: null,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeEvent(evt) {
|
||||
return JSON.stringify([
|
||||
0,
|
||||
evt.pubkey,
|
||||
evt.created_at,
|
||||
evt.kind,
|
||||
evt.tags,
|
||||
evt.content
|
||||
])
|
||||
}
|
||||
|
||||
export function getEventHash(event) {
|
||||
let eventHash = createHash('sha256')
|
||||
.update(Buffer.from(serializeEvent(event)))
|
||||
.digest()
|
||||
return Buffer.from(eventHash).toString('hex')
|
||||
}
|
||||
|
||||
export function validateEvent(event) {
|
||||
if (event.id !== getEventHash(event)) return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function verifySignature(event) {
|
||||
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
|
||||
}
|
||||
|
||||
export async function signEvent(event, key) {
|
||||
return Buffer.from(
|
||||
await secp256k1.schnorr.sign(getEventHash(event), key)
|
||||
).toString('hex')
|
||||
}
|
||||
44
fakejson.test.ts
Normal file
44
fakejson.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||
|
||||
test('match id', () => {
|
||||
expect(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
test('match kind', () => {
|
||||
expect(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
1,
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||
12720,
|
||||
),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test('match subscription id', () => {
|
||||
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||
expect(
|
||||
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||
})
|
||||
41
fakejson.ts
Normal file
41
fakejson.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export function getHex64(json: string, field: string): string {
|
||||
let len = field.length + 3
|
||||
let idx = json.indexOf(`"${field}":`) + len
|
||||
let s = json.slice(idx).indexOf(`"`) + idx + 1
|
||||
return json.slice(s, s + 64)
|
||||
}
|
||||
|
||||
export function getInt(json: string, field: string): number {
|
||||
let len = field.length
|
||||
let idx = json.indexOf(`"${field}":`) + len + 3
|
||||
let sliced = json.slice(idx)
|
||||
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
|
||||
return parseInt(sliced.slice(0, end), 10)
|
||||
}
|
||||
|
||||
export function getSubscriptionId(json: string): string | null {
|
||||
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
|
||||
if (idx === -1) return null
|
||||
|
||||
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
|
||||
if (pstart === -1) return null
|
||||
let start = idx + 7 + 1 + pstart
|
||||
|
||||
let pend = json.slice(start + 1, 80).indexOf(`"`)
|
||||
if (pend === -1) return null
|
||||
let end = start + 1 + pend
|
||||
|
||||
return json.slice(start + 1, end)
|
||||
}
|
||||
|
||||
export function matchEventId(json: string, id: string): boolean {
|
||||
return id === getHex64(json, 'id')
|
||||
}
|
||||
|
||||
export function matchEventPubkey(json: string, pubkey: string): boolean {
|
||||
return pubkey === getHex64(json, 'pubkey')
|
||||
}
|
||||
|
||||
export function matchEventKind(json: string, kind: number): boolean {
|
||||
return kind === getInt(json, 'kind')
|
||||
}
|
||||
30
filter.js
30
filter.js
@@ -1,30 +0,0 @@
|
||||
export function matchFilter(filter, event) {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
||||
return false
|
||||
|
||||
for (let f in filter) {
|
||||
if (f[0] === '#') {
|
||||
if (
|
||||
filter[f] &&
|
||||
!event.tags.find(
|
||||
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.since && event.created_at < filter.since) return false
|
||||
if (filter.until && event.created_at >= filter.until) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(filters, event) {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
244
filter.test.ts
Normal file
244
filter.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
test('should return true when all filter conditions are met', () => {
|
||||
const filter = {
|
||||
ids: ['123', '456'],
|
||||
kinds: [1, 2, 3],
|
||||
authors: ['abc'],
|
||||
since: 100,
|
||||
until: 200,
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({ id: '789' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({ id: '001' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event kind is not in the filter', () => {
|
||||
const filter = { kinds: [1, 2, 3] }
|
||||
|
||||
const event = buildEvent({ kind: 4 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when the event author is not in the filter', () => {
|
||||
const filter = { authors: ['abc', 'def'] }
|
||||
|
||||
const event = buildEvent({ pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when a tag is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when a tag value is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = { '#tag1': ['foo'] }
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar'],
|
||||
],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event is before the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 50 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event is after the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 150 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFilters', () => {
|
||||
test('should return true when at least one filter matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return true when at least one prefix matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['1'], kinds: [1], authors: ['a'] },
|
||||
{ ids: ['4'], kinds: [2], authors: ['d'] },
|
||||
{ ids: ['9'], kinds: [3], authors: ['g'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return true when event matches one or more filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when no filters match the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when event matches none of the filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
const event = buildEvent({
|
||||
id: '456',
|
||||
kind: 2,
|
||||
pubkey: 'def',
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeFilters', () => {
|
||||
test('should merge filters', () => {
|
||||
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||
ids: ['a', 'b', 'c'],
|
||||
limit: 3,
|
||||
authors: ['x'],
|
||||
})
|
||||
|
||||
expect(
|
||||
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
72
filter.ts
Normal file
72
filter.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Event } from './pure.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[] | undefined
|
||||
}
|
||||
|
||||
export function matchFilter(filter: Filter, event: Event): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (let f in filter) {
|
||||
if (f[0] === '#') {
|
||||
let tagName = f.slice(1)
|
||||
let values = filter[`#${tagName}`]
|
||||
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.since && event.created_at < filter.since) return false
|
||||
if (filter.until && event.created_at > filter.until) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeFilters(...filters: Filter[]): Filter {
|
||||
let result: Filter = {}
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
let filter = filters[i]
|
||||
Object.entries(filter).forEach(([property, values]) => {
|
||||
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||
// @ts-ignore
|
||||
result[property] = result[property] || []
|
||||
// @ts-ignore
|
||||
for (let v = 0; v < values.length; v++) {
|
||||
// @ts-ignore
|
||||
let value = values[v]
|
||||
// @ts-ignore
|
||||
if (!result[property].includes(value)) result[property].push(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
9
helpers.ts
Normal file
9
helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export async function yieldThread() {
|
||||
return new Promise(resolve => {
|
||||
const ch = new MessageChannel()
|
||||
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||
ch.port1.addEventListener('message', resolve)
|
||||
ch.port2.postMessage(0)
|
||||
ch.port1.start()
|
||||
})
|
||||
}
|
||||
107
index.d.ts
vendored
107
index.d.ts
vendored
@@ -1,107 +0,0 @@
|
||||
import { type Buffer } from 'buffer';
|
||||
|
||||
// these should be available from the native @noble/secp256k1 type
|
||||
// declarations, but they somehow aren't so instead: copypasta
|
||||
declare type Hex = Uint8Array | string;
|
||||
declare type PrivKey = Hex | bigint | number;
|
||||
|
||||
declare enum EventKind {
|
||||
Metadata = 0,
|
||||
Text = 1,
|
||||
RelayRec = 2,
|
||||
Contacts = 3,
|
||||
DM = 4,
|
||||
Deleted = 5,
|
||||
}
|
||||
|
||||
// event.js
|
||||
declare type Event = {
|
||||
kind: EventKind,
|
||||
pubkey?: string,
|
||||
content: string,
|
||||
tags: string[],
|
||||
created_at: number,
|
||||
};
|
||||
|
||||
declare function getBlankEvent(): Event;
|
||||
declare function serializeEvent(event: Event): string;
|
||||
declare function getEventHash(event: Event): string;
|
||||
declare function validateEvent(event: Event): boolean;
|
||||
declare function validateSignature(event: Event): boolean;
|
||||
declare function signEvent(event: Event, key: PrivKey): Promise<[Uint8Array, number]>;
|
||||
|
||||
// filter.js
|
||||
declare type Filter = {
|
||||
ids: string[],
|
||||
kinds: EventKind[],
|
||||
authors: string[],
|
||||
since: number,
|
||||
until: number,
|
||||
"#e": string[],
|
||||
"#p": string[],
|
||||
};
|
||||
|
||||
declare function matchFilter(filter: Filter, event: Event): boolean;
|
||||
declare function matchFilters(filters: Filter[], event: Event): boolean;
|
||||
|
||||
// general
|
||||
declare type ClientMessage =
|
||||
["EVENT", Event] |
|
||||
["REQ", string, Filter[]] |
|
||||
["CLOSE", string];
|
||||
|
||||
declare type ServerMessage =
|
||||
["EVENT", string, Event] |
|
||||
["NOTICE", unknown];
|
||||
|
||||
// keys.js
|
||||
declare function generatePrivateKey(): string;
|
||||
declare function getPublicKey(privateKey: Buffer): string;
|
||||
|
||||
// pool.js
|
||||
declare type RelayPolicy = {
|
||||
read: boolean,
|
||||
write: boolean,
|
||||
};
|
||||
|
||||
declare type SubscriptionCallback = (event: Event, relay: string) => void;
|
||||
|
||||
declare type SubscriptionOptions = {
|
||||
cb: SubscriptionCallback,
|
||||
filter: Filter,
|
||||
skipVerification: boolean
|
||||
// TODO: thread through how `beforeSend` actually works before trying to type it
|
||||
// beforeSend(event: Event):
|
||||
};
|
||||
|
||||
declare type Subscription = {
|
||||
unsub(): void,
|
||||
};
|
||||
|
||||
declare type PublishCallback = (status: number) => void;
|
||||
|
||||
// relay.js
|
||||
declare type Relay = {
|
||||
url: string,
|
||||
sub: SubscriptionCallback,
|
||||
publish: (event: Event, cb: PublishCallback) => Promise<Event>,
|
||||
};
|
||||
|
||||
declare type PoolPublishCallback = (status: number, relay: string) => void;
|
||||
|
||||
declare type RelayPool = {
|
||||
setPrivateKey(key: string): void,
|
||||
addRelay(url: string, opts?: RelayPolicy): Relay,
|
||||
sub(opts: SubscriptionOptions, id?: string): Subscription,
|
||||
publish(event: Event, cb: PoolPublishCallback): Promise<Event>,
|
||||
close: () => void,
|
||||
status: number,
|
||||
};
|
||||
|
||||
declare function relayPool(): RelayPool;
|
||||
|
||||
// nip04.js
|
||||
|
||||
// nip05.js
|
||||
|
||||
// nip06.js
|
||||
27
index.js
27
index.js
@@ -1,27 +0,0 @@
|
||||
import {generatePrivateKey, getPublicKey} from './keys.js'
|
||||
import {relayConnect} from './relay.js'
|
||||
import {relayPool} from './pool.js'
|
||||
import {
|
||||
getBlankEvent,
|
||||
signEvent,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
serializeEvent,
|
||||
getEventHash
|
||||
} from './event.js'
|
||||
import {matchFilter, matchFilters} from './filter.js'
|
||||
|
||||
export {
|
||||
generatePrivateKey,
|
||||
relayConnect,
|
||||
relayPool,
|
||||
signEvent,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
getBlankEvent,
|
||||
matchFilter,
|
||||
matchFilters
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as process from 'process';
|
||||
import {
|
||||
relayPool,
|
||||
getBlankEvent,
|
||||
validateEvent,
|
||||
RelayPool,
|
||||
Event as NEvent
|
||||
} from './index.js';
|
||||
import { expectType } from 'tsd';
|
||||
|
||||
const pool = relayPool();
|
||||
expectType<RelayPool>(pool);
|
||||
|
||||
const privkey = process.env.NOSTR_PRIVATE_KEY;
|
||||
const pubkey = process.env.NOSTR_PUBLIC_KEY;
|
||||
|
||||
const message = {
|
||||
...getBlankEvent(),
|
||||
kind: 1,
|
||||
content: `just saying hi from pid ${process.pid}`,
|
||||
pubkey,
|
||||
};
|
||||
|
||||
const publishCb = (status: number, url: string) => {
|
||||
console.log({ status, url });
|
||||
};
|
||||
|
||||
pool.setPrivateKey(privkey!);
|
||||
|
||||
const publishF = pool.publish(message, publishCb);
|
||||
expectType<Promise<NEvent>>(publishF);
|
||||
|
||||
publishF.then((event) => {
|
||||
expectType<NEvent>(event);
|
||||
|
||||
console.info({ event });
|
||||
|
||||
if (!validateEvent(event)) {
|
||||
console.error(`event failed to validate!`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
29
index.ts
Normal file
29
index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export * from './pure.ts'
|
||||
export * from './relay.ts'
|
||||
export * from './pure.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04.ts'
|
||||
export * as nip05 from './nip05.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip11 from './nip11.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
export * as nip25 from './nip25.ts'
|
||||
export * as nip27 from './nip27.ts'
|
||||
export * as nip28 from './nip28.ts'
|
||||
export * as nip30 from './nip30.ts'
|
||||
export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as kinds from './kinds.ts'
|
||||
export * as fj from './fakejson.ts'
|
||||
export * as utils from './utils.ts'
|
||||
25
justfile
Normal file
25
justfile
Normal file
@@ -0,0 +1,25 @@
|
||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
|
||||
build:
|
||||
rm -rf lib
|
||||
bun run build.js
|
||||
|
||||
test:
|
||||
bun test --timeout 20000
|
||||
|
||||
test-only file:
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
|
||||
publish: build emit-types
|
||||
npm publish
|
||||
|
||||
format:
|
||||
eslint --ext .ts --fix *.ts
|
||||
prettier --write *.ts
|
||||
|
||||
lint:
|
||||
eslint --ext .ts *.ts
|
||||
prettier --check *.ts
|
||||
10
keys.js
10
keys.js
@@ -1,10 +0,0 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {Buffer} from 'buffer'
|
||||
|
||||
export function generatePrivateKey() {
|
||||
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey) {
|
||||
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
|
||||
}
|
||||
21
kinds.test.ts
Normal file
21
kinds.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { classifyKind } from './kinds.ts'
|
||||
|
||||
test('kind classification', () => {
|
||||
expect(classifyKind(1)).toBe('regular')
|
||||
expect(classifyKind(5)).toBe('regular')
|
||||
expect(classifyKind(6)).toBe('regular')
|
||||
expect(classifyKind(7)).toBe('regular')
|
||||
expect(classifyKind(1000)).toBe('regular')
|
||||
expect(classifyKind(9999)).toBe('regular')
|
||||
expect(classifyKind(0)).toBe('replaceable')
|
||||
expect(classifyKind(3)).toBe('replaceable')
|
||||
expect(classifyKind(10000)).toBe('replaceable')
|
||||
expect(classifyKind(19999)).toBe('replaceable')
|
||||
expect(classifyKind(20000)).toBe('ephemeral')
|
||||
expect(classifyKind(29999)).toBe('ephemeral')
|
||||
expect(classifyKind(30000)).toBe('parameterized')
|
||||
expect(classifyKind(39999)).toBe('parameterized')
|
||||
expect(classifyKind(40000)).toBe('unknown')
|
||||
expect(classifyKind(255)).toBe('unknown')
|
||||
})
|
||||
105
kinds.ts
Normal file
105
kinds.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
export function isRegularKind(kind: number) {
|
||||
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||
}
|
||||
|
||||
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||
export function isReplaceableKind(kind: number) {
|
||||
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
export function isEphemeralKind(kind: number) {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isParameterizedReplaceableKind(kind: number) {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** Classification of the event kind. */
|
||||
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||
export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export const Metadata = 0
|
||||
export const ShortTextNote = 1
|
||||
export const RecommendRelay = 2
|
||||
export const Contacts = 3
|
||||
export const EncryptedDirectMessage = 4
|
||||
export const EventDeletion = 5
|
||||
export const Repost = 6
|
||||
export const Reaction = 7
|
||||
export const BadgeAward = 8
|
||||
export const ChannelCreation = 40
|
||||
export const ChannelMetadata = 41
|
||||
export const ChannelMessage = 42
|
||||
export const ChannelHideMessage = 43
|
||||
export const ChannelMuteUser = 44
|
||||
export const Report = 1984
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const RelayList = 10002
|
||||
export const ClientAuth = 22242
|
||||
export const BadgeDefinition = 30009
|
||||
export const FileMetadata = 1063
|
||||
export const EncryptedDirectMessages = 4
|
||||
export const GenericRepost = 16
|
||||
export const OpenTimestamps = 1040
|
||||
export const LiveChatMessage = 1311
|
||||
export const ProblemTracker = 1971
|
||||
export const Reporting = 1984
|
||||
export const Label = 1985
|
||||
export const CommunityPostApproval = 4550
|
||||
export const JobRequest = 5999
|
||||
export const JobResult = 6999
|
||||
export const JobFeedback = 7000
|
||||
export const ZapGoal = 9041
|
||||
export const Highlights = 9802
|
||||
export const Mutelist = 10000
|
||||
export const Pinlist = 10001
|
||||
export const BookmarkList = 10003
|
||||
export const CommunitiesList = 10004
|
||||
export const PublicChatsList = 10005
|
||||
export const BlockedRelaysList = 10006
|
||||
export const SearchRelaysList = 10007
|
||||
export const InterestsList = 10015
|
||||
export const UserEmojiList = 10030
|
||||
export const NWCWalletInfo = 13194
|
||||
export const LightningPubRPC = 21000
|
||||
export const NWCWalletRequest = 23194
|
||||
export const NWCWalletResponse = 23195
|
||||
export const NostrConnect = 24133
|
||||
export const HTTPAuth = 27235
|
||||
export const Followsets = 30000
|
||||
export const Genericlists = 30001
|
||||
export const Relaysets = 30002
|
||||
export const Bookmarksets = 30003
|
||||
export const Curationsets = 30004
|
||||
export const ProfileBadges = 30008
|
||||
export const Interestsets = 30015
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
export const LongFormArticle = 30023
|
||||
export const DraftLong = 30024
|
||||
export const Emojisets = 30030
|
||||
export const Application = 30078
|
||||
export const LiveEvent = 30311
|
||||
export const UserStatuses = 30315
|
||||
export const ClassifiedListing = 30402
|
||||
export const DraftClassifiedListing = 30403
|
||||
export const Date = 31922
|
||||
export const Time = 31923
|
||||
export const Calendar = 31924
|
||||
export const CalendarEventRSVP = 31925
|
||||
export const Handlerrecommendation = 31989
|
||||
export const Handlerinformation = 31990
|
||||
export const CommunityDefinition = 34550
|
||||
42
nip04.js
42
nip04.js
@@ -1,42 +0,0 @@
|
||||
import aes from 'browserify-cipher'
|
||||
import {Buffer} from 'buffer'
|
||||
import {randomBytes} from '@noble/hashes/utils'
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
|
||||
export function encrypt(privkey, pubkey, text) {
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
var cipher = aes.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(normalizedKey, 'hex'),
|
||||
iv
|
||||
)
|
||||
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
|
||||
encryptedMessage += cipher.final('base64')
|
||||
|
||||
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
|
||||
}
|
||||
|
||||
export function decrypt(privkey, pubkey, ciphertext) {
|
||||
let [cip, iv] = ciphertext.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
var decipher = aes.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(normalizedKey, 'hex'),
|
||||
Buffer.from(iv, 'base64')
|
||||
)
|
||||
let decryptedMessage = decipher.update(cip, 'base64', 'utf8')
|
||||
decryptedMessage += decipher.final('utf8')
|
||||
|
||||
return decryptedMessage
|
||||
}
|
||||
|
||||
function getNormalizedX(key) {
|
||||
return typeof key === 'string'
|
||||
? key.substr(2, 64)
|
||||
: Buffer.from(key.slice(1, 33)).toString('hex')
|
||||
}
|
||||
50
nip04.test.ts
Normal file
50
nip04.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { encrypt, decrypt } from './nip04.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generateSecretKey()
|
||||
let sk2 = generateSecretKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
|
||||
|
||||
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
|
||||
})
|
||||
|
||||
test('decrypt message from go-nostr', async () => {
|
||||
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||
|
||||
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
|
||||
|
||||
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
|
||||
})
|
||||
|
||||
test('decrypt big payload from go-nostr', async () => {
|
||||
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||
|
||||
let ciphertext =
|
||||
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
|
||||
let plaintext = ''
|
||||
for (let i = 0; i < 800; i++) {
|
||||
plaintext += 'z'
|
||||
}
|
||||
|
||||
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
|
||||
})
|
||||
46
nip04.ts
Normal file
46
nip04.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
// @ts-ignore
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
let plaintext = utf8Encoder.encode(text)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
return key.slice(1, 33)
|
||||
}
|
||||
28
nip05.js
28
nip05.js
@@ -1,28 +0,0 @@
|
||||
import fetch from 'cross-fetch'
|
||||
|
||||
export async function searchDomain(domain, query = '') {
|
||||
try {
|
||||
let res = await (
|
||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
|
||||
).json()
|
||||
|
||||
return res.names
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryName(fullname) {
|
||||
try {
|
||||
let [name, domain] = fullname.split('@')
|
||||
if (!domain) return null
|
||||
|
||||
let res = await (
|
||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
).json()
|
||||
|
||||
return res.names && res.names[name]
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
20
nip05.test.ts
Normal file
20
nip05.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||
})
|
||||
81
nip05.ts
Normal file
81
nip05.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ProfilePointer } from './nip19.ts'
|
||||
|
||||
/**
|
||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||
*
|
||||
* - 0: full match
|
||||
* - 1: name (optional)
|
||||
* - 2: domain
|
||||
*/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||
try {
|
||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||
|
||||
return res.names
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||
const match = fullname.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const { names, relays } = parseNIP05Result(await res.json())
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** nostr.json result. */
|
||||
export interface NIP05Result {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
relays?: {
|
||||
[pubkey: string]: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the nostr.json and throw if it's not valid. */
|
||||
function parseNIP05Result(json: any): NIP05Result {
|
||||
const result: NIP05Result = {
|
||||
names: {},
|
||||
}
|
||||
|
||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||
result.names[name] = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if (json.relays) {
|
||||
result.relays = {}
|
||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
26
nip06.js
26
nip06.js
@@ -1,26 +0,0 @@
|
||||
import {wordlist} from 'micro-bip39/wordlists/english.js'
|
||||
import {
|
||||
generateMnemonic,
|
||||
mnemonicToSeedSync,
|
||||
validateMnemonic
|
||||
} from 'micro-bip39'
|
||||
import {HDKey} from 'micro-bip32'
|
||||
|
||||
export function privateKeyFromSeed(seed) {
|
||||
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
|
||||
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
|
||||
'hex'
|
||||
)
|
||||
}
|
||||
|
||||
export function seedFromWords(mnemonic) {
|
||||
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
|
||||
}
|
||||
|
||||
export function generateSeedWords() {
|
||||
return generateMnemonic(wordlist)
|
||||
}
|
||||
|
||||
export function validateWords(words) {
|
||||
return validateMnemonic(words, wordlist)
|
||||
}
|
||||
28
nip06.test.ts
Normal file
28
nip06.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||
})
|
||||
19
nip06.ts
Normal file
19
nip06.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||
import { HDKey } from '@scure/bip32'
|
||||
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return bytesToHex(privateKey)
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
return generateMnemonic(wordlist)
|
||||
}
|
||||
|
||||
export function validateWords(words: string): boolean {
|
||||
return validateMnemonic(words, wordlist)
|
||||
}
|
||||
229
nip10.test.ts
Normal file
229
nip10.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { parse } from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
91
nip10.ts
Normal file
91
nip10.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Event } from './pure.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
/**
|
||||
* Pointer to the root of the thread.
|
||||
*/
|
||||
root: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointer to a "parent" event that parsed event replies to (responded to).
|
||||
*/
|
||||
reply: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointers to events which may or may not be in the reply chain.
|
||||
*/
|
||||
mentions: EventPointer[]
|
||||
|
||||
/**
|
||||
* List of pubkeys that are involved in the thread in no particular order.
|
||||
*/
|
||||
profiles: ProfilePointer[]
|
||||
}
|
||||
|
||||
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
const result: NIP10Result = {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
eTags.push(tag)
|
||||
}
|
||||
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
result.profiles.push({
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||
const eTag = eTags[eTagIndex]
|
||||
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
const isLastETag = eTagIndex === eTags.length - 1
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFirstETag) {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLastETag) {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
16
nip11.test.ts
Normal file
16
nip11.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
test('testing a relay', async () => {
|
||||
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||
expect(info.name).toEqual('nostr.land')
|
||||
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
|
||||
expect(info.fees).toBeTruthy()
|
||||
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||
expect(info.software).toEqual('custom')
|
||||
})
|
||||
})
|
||||
286
nip11.ts
Normal file
286
nip11.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function fetchRelayInformation(url: string) {
|
||||
return (await (
|
||||
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||
headers: { Accept: 'application/nostr+json' },
|
||||
})
|
||||
).json()) as RelayInformation
|
||||
}
|
||||
|
||||
/**
|
||||
* ## Relay Information Document
|
||||
|
||||
* Relays may provide server metadata to clients to inform
|
||||
* them of capabilities, administrative contacts, and
|
||||
* various server attributes. This is made available as a
|
||||
* JSON document over HTTP, on the same URI as the relay's
|
||||
* websocket.
|
||||
|
||||
* Any field may be omitted, and clients MUST ignore any
|
||||
* additional fields they do not understand. Relays MUST
|
||||
* accept CORS requests by sending
|
||||
* `Access-Control-Allow-Origin`,
|
||||
* `Access-Control-Allow-Headers`, and
|
||||
* `Access-Control-Allow-Methods` headers.
|
||||
* @param name string identifying relay
|
||||
* @param description string with detailed information
|
||||
* @param pubkey administrative contact pubkey
|
||||
* @param contact: administrative alternate contact
|
||||
* @param supported_nips a list of NIP numbers supported by
|
||||
* the relay
|
||||
* @param software identifying relay software URL
|
||||
* @param version string version identifier
|
||||
*/
|
||||
export interface BasicRelayInformation {
|
||||
// string identifying relay
|
||||
name: string
|
||||
description: string
|
||||
pubkey: string
|
||||
contact: string
|
||||
supported_nips: number[]
|
||||
software: string
|
||||
version: string
|
||||
// limitation?: Limitations<A, P>
|
||||
}
|
||||
|
||||
/**
|
||||
* * ## Extra Fields
|
||||
|
||||
* * ### Server Limitations
|
||||
|
||||
* These are limitations imposed by the relay on clients.
|
||||
* Your client should expect that requests which exceed
|
||||
* these practical_ limitations are rejected or fail immediately.
|
||||
* @param max_message_length this is the maximum number of
|
||||
* bytes for incoming JSON that the relay will attempt to
|
||||
* decode and act upon. When you send large subscriptions,
|
||||
* you will be limited by this value. It also effectively
|
||||
* limits the maximum size of any event. Value is calculated
|
||||
* from `[` to `]` and is after UTF-8 serialization (so some
|
||||
* unicode characters will cost 2-3 bytes). It is equal to
|
||||
* the maximum size of the WebSocket message frame.
|
||||
* @param max_subscription total number of subscriptions
|
||||
* that may be active on a single websocket connection to
|
||||
* this relay. It's possible that authenticated clients with
|
||||
* a (paid) relationship to the relay may have higher limits.
|
||||
* @param max_filters maximum number of filter values in
|
||||
* each subscription. Must be one or higher.
|
||||
* @param max_limit the relay server will clamp each
|
||||
* filter's `limit` value to this number.
|
||||
* This means the client won't be able to get more than this
|
||||
* number of events from a single subscription filter. This
|
||||
* clamping is typically done silently by the relay, but
|
||||
* with this number, you can know that there are additional
|
||||
* results if you narrowed your filter's time range or other
|
||||
* parameters.
|
||||
* @param max_subid_length maximum length of subscription id as a
|
||||
* string.
|
||||
* @param min_prefix for `authors` and `ids` filters which
|
||||
* are to match against a hex prefix, you must provide at
|
||||
* least this many hex digits in the prefix.
|
||||
* @param max_event_tags in any event, this is the maximum
|
||||
* number of elements in the `tags` list.
|
||||
* @param max_content_length maximum number of characters in
|
||||
* the `content` field of any event. This is a count of
|
||||
* unicode characters. After serializing into JSON it may be
|
||||
* larger (in bytes), and is still subject to the
|
||||
* max_message_length`, if defined.
|
||||
* @param min_pow_difficulty new events will require at
|
||||
* least this difficulty of PoW, based on [NIP-13](13.md),
|
||||
* or they will be rejected by this server.
|
||||
* @param auth_required this relay requires [NIP-42](42.md)
|
||||
* authentication to happen before a new connection may
|
||||
* perform any other action. Even if set to False,
|
||||
* authentication may be required for specific actions.
|
||||
* @param payment_required this relay requires payment
|
||||
* before a new connection may perform any action.
|
||||
*/
|
||||
export interface Limitations {
|
||||
max_message_length: number
|
||||
max_subscription: number
|
||||
max_filters: number
|
||||
max_limit: number
|
||||
max_subid_length: number
|
||||
min_prefix: number
|
||||
max_event_tags: number
|
||||
max_content_length: number
|
||||
min_pow_difficulty: number
|
||||
auth_required: boolean
|
||||
payment_required: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
}
|
||||
type AnyRetentionDetails = RetentionDetails
|
||||
/**
|
||||
* ### Event Retention
|
||||
|
||||
* There may be a cost associated with storing data forever,
|
||||
* so relays may wish to state retention times. The values
|
||||
* stated here are defaults for unauthenticated users and
|
||||
* visitors. Paid users would likely have other policies.
|
||||
|
||||
* Retention times are given in seconds, with `null`
|
||||
* indicating infinity. If zero is provided, this means the
|
||||
* event will not be stored at all, and preferably an error
|
||||
* will be provided when those are received.
|
||||
* ```json
|
||||
{
|
||||
...
|
||||
"retention": [
|
||||
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
|
||||
{ "kinds": [[40000, 49999]], "time": 100 },
|
||||
{ "kinds": [[30000, 39999]], "count": 1000 },
|
||||
{ "time": 3600, "count": 10000 }
|
||||
]
|
||||
...
|
||||
}
|
||||
```
|
||||
* @param retention is a list of specifications: each will
|
||||
* apply to either all kinds, or a subset of kinds. Ranges
|
||||
* may be specified for the kind field as a tuple of
|
||||
* inclusive start and end values. Events of indicated kind
|
||||
* (or all) are then limited to a `count` and/or time
|
||||
* period.
|
||||
|
||||
* It is possible to effectively blacklist Nostr-based
|
||||
* protocols that rely on a specific `kind` number, by
|
||||
* giving a retention time of zero for those `kind` values.
|
||||
* While that is unfortunate, it does allow clients to
|
||||
* discover servers that will support their protocol quickly
|
||||
* via a single HTTP fetch.
|
||||
|
||||
* There is no need to specify retention times for
|
||||
* _ephemeral events_ as defined in [NIP-16](16.md) since
|
||||
* they are not retained.
|
||||
*/
|
||||
export interface Retention {
|
||||
retention: AnyRetentionDetails[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Some relays may be governed by the arbitrary laws of a
|
||||
* nation state. This may limit what content can be stored
|
||||
* in cleartext on those relays. All clients are encouraged
|
||||
* to use encryption to work around this limitation.
|
||||
|
||||
* It is not possible to describe the limitations of each
|
||||
* country's laws and policies which themselves are
|
||||
* typically vague and constantly shifting.
|
||||
|
||||
* Therefore, this field allows the relay operator to
|
||||
* indicate which countries' laws might end up being
|
||||
* enforced on them, and then indirectly on their users'
|
||||
* content.
|
||||
|
||||
* Users should be able to avoid relays in countries they
|
||||
* don't like, and/or select relays in more favourable
|
||||
* zones. Exposing this flexibility is up to the client
|
||||
* software.
|
||||
|
||||
* @param relay_countries a list of two-level ISO country
|
||||
* codes (ISO 3166-1 alpha-2) whose laws and policies may
|
||||
* affect this relay. `EU` may be used for European Union
|
||||
* countries.
|
||||
|
||||
* Remember that a relay may be hosted in a country which is
|
||||
* not the country of the legal entities who own the relay,
|
||||
* so it's very likely a number of countries are involved.
|
||||
*/
|
||||
export interface ContentLimitations {
|
||||
relay_countries: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Community Preferences
|
||||
|
||||
* For public text notes at least, a relay may try to foster
|
||||
* a local community. This would encourage users to follow
|
||||
* the global feed on that relay, in addition to their usual
|
||||
* individual follows. To support this goal, relays MAY
|
||||
* specify some of the following values.
|
||||
|
||||
* @param language_tags is an ordered list of [IETF
|
||||
* language
|
||||
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
|
||||
* indicating the major languages spoken on the relay.
|
||||
* @param tags is a list of limitations on the topics to be
|
||||
* discussed. For example `sfw-only` indicates that only
|
||||
* "Safe For Work" content is encouraged on this relay. This
|
||||
* relies on assumptions of what the "work" "community"
|
||||
* feels "safe" talking about. In time, a common set of tags
|
||||
* may emerge that allow users to find relays that suit
|
||||
* their needs, and client software will be able to parse
|
||||
* these tags easily. The `bitcoin-only` tag indicates that
|
||||
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
|
||||
* be ridiculed without mercy.
|
||||
* @param posting_policy is a link to a human-readable page
|
||||
* which specifies the community policies for the relay. In
|
||||
* cases where `sfw-only` is True, it's important to link to
|
||||
* a page which gets into the specifics of your posting
|
||||
* policy.
|
||||
|
||||
* The `description` field should be used to describe your
|
||||
* community goals and values, in brief. The
|
||||
* `posting_policy` is for additional detail and legal
|
||||
* terms. Use the `tags` field to signify limitations on
|
||||
* content, or topics to be discussed, which could be
|
||||
* machine processed by appropriate client software.
|
||||
*/
|
||||
export interface CommunityPreferences {
|
||||
language_tags: string[]
|
||||
tags: string[]
|
||||
posting_policy: string
|
||||
}
|
||||
|
||||
export interface Amount {
|
||||
amount: number
|
||||
unit: 'msat'
|
||||
}
|
||||
export interface PublicationAmount extends Amount {
|
||||
kinds: number[]
|
||||
}
|
||||
export interface Subscription extends Amount {
|
||||
period: number
|
||||
}
|
||||
export interface Fees {
|
||||
admission: Amount[]
|
||||
subscription: Subscription[]
|
||||
publication: PublicationAmount[]
|
||||
}
|
||||
/**
|
||||
* Relays that require payments may want to expose their fee
|
||||
* schedules.
|
||||
*/
|
||||
export interface PayToRelay {
|
||||
payments_url: string
|
||||
fees: Fees
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL pointing to an image to be used as an icon for the
|
||||
* relay. Recommended to be squared in shape.
|
||||
*/
|
||||
export interface Icon {
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type RelayInformation = BasicRelayInformation &
|
||||
Partial<Retention> & {
|
||||
limitation?: Partial<Limitations>
|
||||
} & Partial<ContentLimitations> &
|
||||
Partial<CommunityPreferences> &
|
||||
Partial<PayToRelay> &
|
||||
Partial<Icon>
|
||||
25
nip13.test.ts
Normal file
25
nip13.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { getPow, minePow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
})
|
||||
|
||||
test('mines POW for an event', async () => {
|
||||
const difficulty = 10
|
||||
|
||||
const event = minePow(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 0,
|
||||
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
difficulty,
|
||||
)
|
||||
|
||||
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
|
||||
})
|
||||
52
nip13.ts
Normal file
52
nip13.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(hex: string): number {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine an event with the desired POW. This function mutates the event.
|
||||
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
|
||||
*
|
||||
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
|
||||
*/
|
||||
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||
let count = 0
|
||||
|
||||
const event = unsigned as Omit<Event, 'sig'>
|
||||
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||
|
||||
event.tags.push(tag)
|
||||
|
||||
while (true) {
|
||||
const now = Math.floor(new Date().getTime() / 1000)
|
||||
|
||||
if (now !== event.created_at) {
|
||||
count = 0
|
||||
event.created_at = now
|
||||
}
|
||||
|
||||
tag[1] = (++count).toString()
|
||||
|
||||
event.id = getEventHash(event)
|
||||
|
||||
if (getPow(event.id) >= difficulty) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
102
nip18.test.ts
Normal file
102
nip18.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Repost, ShortTextNote } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
const relayUrl = 'https://relay.example.com'
|
||||
|
||||
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
test('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
|
||||
test('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '' as const,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual('')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
kind: Repost,
|
||||
tags: [['e', 'reposted event id', relayUrl]],
|
||||
})
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||
expect(repostedEventPointer!.author).toBeUndefined()
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
97
nip18.ts
Normal file
97
nip18.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip18 tags if you have to.
|
||||
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* Pass an empty string to NOT include the stringified JSON of the reposted event.
|
||||
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
|
||||
* @default Stringified JSON of the reposted event
|
||||
*/
|
||||
content?: ''
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishRepostEvent(
|
||||
t: RepostEventTemplate,
|
||||
reposted: Event,
|
||||
relayUrl: string,
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||
author: lastPTag?.[1],
|
||||
}
|
||||
}
|
||||
|
||||
export type GetRepostedEventOptions = {
|
||||
skipVerification?: boolean
|
||||
}
|
||||
|
||||
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
|
||||
const pointer = getRepostedEventPointer(event)
|
||||
|
||||
if (pointer === undefined || event.content === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let repostedEvent: undefined | Event
|
||||
|
||||
try {
|
||||
repostedEvent = JSON.parse(event.content) as Event
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (repostedEvent.id !== pointer.id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!skipVerification && !verifyEvent(repostedEvent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return repostedEvent
|
||||
}
|
||||
144
nip19.test.ts
Normal file
144
nip19.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let { type, data } = decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let npub = npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let { type, data } = decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
})
|
||||
|
||||
test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
decode(
|
||||
nprofileEncode({
|
||||
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: [],
|
||||
}),
|
||||
).data,
|
||||
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana',
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nevent', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
})
|
||||
|
||||
test('encode and decode nevent with kind 0', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 0,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(0)
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nrelay', () => {
|
||||
let url = 'wss://relay.nostr.example'
|
||||
let nrelay = nrelayEncode(url)
|
||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||
let { type, data } = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
})
|
||||
241
nip19.ts
Normal file
241
nip19.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||
*/
|
||||
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
|
||||
function integerToUint8Array(number: number) {
|
||||
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
|
||||
const uint8Array = new Uint8Array(4)
|
||||
|
||||
// Use bitwise operations to extract the bytes.
|
||||
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
|
||||
uint8Array[1] = (number >> 16) & 0xff
|
||||
uint8Array[2] = (number >> 8) & 0xff
|
||||
uint8Array[3] = number & 0xff // Least significant byte (LSB)
|
||||
|
||||
return uint8Array
|
||||
}
|
||||
|
||||
export type ProfilePointer = {
|
||||
pubkey: string // hex
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export type EventPointer = {
|
||||
id: string // hex
|
||||
relays?: string[]
|
||||
author?: string
|
||||
kind?: number
|
||||
}
|
||||
|
||||
export type AddressPointer = {
|
||||
identifier: string
|
||||
pubkey: string
|
||||
kind: number
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
case 'nprofile': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
|
||||
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||
|
||||
return {
|
||||
type: 'nprofile',
|
||||
data: {
|
||||
pubkey: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'nevent': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
||||
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||
|
||||
return {
|
||||
type: 'nevent',
|
||||
data: {
|
||||
id: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
|
||||
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
|
||||
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
|
||||
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||
|
||||
return {
|
||||
type: 'naddr',
|
||||
data: {
|
||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||
pubkey: bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'nrelay': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
return { type: prefix, data }
|
||||
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return { type: prefix, data: bytesToHex(data) }
|
||||
|
||||
default:
|
||||
throw new Error(`unknown prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
type TLV = { [t: number]: Uint8Array[] }
|
||||
|
||||
function parseTLV(data: Uint8Array): TLV {
|
||||
let result: TLV = {}
|
||||
let rest = data
|
||||
while (rest.length > 0) {
|
||||
let t = rest[0]
|
||||
let l = rest[1]
|
||||
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(key: Uint8Array): `nsec1${string}` {
|
||||
return encodeBytes('nsec', key)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
return encodeBytes('npub', hexToBytes(hex))
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
return encodeBytes('note', hexToBytes(hex))
|
||||
}
|
||||
|
||||
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||
}
|
||||
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
return encodeBech32(prefix, bytes)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
})
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
let kindArray
|
||||
if (event.kind !== undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
}
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(event.id)],
|
||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: event.author ? [hexToBytes(event.author)] : [],
|
||||
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||
})
|
||||
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
let kind = new ArrayBuffer(4)
|
||||
new DataView(kind).setUint32(0, addr.kind, false)
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(addr.identifier)],
|
||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: [hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)],
|
||||
})
|
||||
return encodeBech32('naddr', data)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)],
|
||||
})
|
||||
return encodeBech32('nrelay', data)
|
||||
}
|
||||
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
Object.entries(tlv).forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
})
|
||||
})
|
||||
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
24
nip21.test.ts
Normal file
24
nip21.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { test as testRegex, parse } from './nip21.ts'
|
||||
|
||||
test('test()', () => {
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
|
||||
expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
|
||||
expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('nostr:')).toBe(false)
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('gggggg')).toBe(false)
|
||||
})
|
||||
|
||||
test('parse', () => {
|
||||
const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||
|
||||
expect(result).toEqual({
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
})
|
||||
})
|
||||
30
nip21.ts
Normal file
30
nip21.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||
}
|
||||
|
||||
/** Parsed Nostr URI data. */
|
||||
export interface NostrURI {
|
||||
/** Full URI including the `nostr:` protocol. */
|
||||
uri: `nostr:${string}`
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
export function parse(uri: string): NostrURI {
|
||||
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||
return {
|
||||
uri: match[0] as `nostr:${string}`,
|
||||
value: match[1],
|
||||
decoded: decode(match[1]),
|
||||
}
|
||||
}
|
||||
78
nip25.test.ts
Normal file
78
nip25.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Reaction, ShortTextNote } from './kinds.ts'
|
||||
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
|
||||
|
||||
describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const reactedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
test('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('+')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
|
||||
test('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '👍',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('👍')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
})
|
||||
62
nip25.ts
Normal file
62
nip25.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { Reaction } from './kinds.ts'
|
||||
|
||||
import type { EventPointer } from './nip19.ts'
|
||||
|
||||
export type ReactionEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* @default '+'
|
||||
*/
|
||||
content?: string
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, privateKey: Uint8Array): Event {
|
||||
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
...t,
|
||||
kind: Reaction,
|
||||
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||
content: t.content ?? '+',
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getReactedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Reaction) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined || lastPTag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
|
||||
author: lastPTag[1],
|
||||
}
|
||||
}
|
||||
68
nip27.test.ts
Normal file
68
nip27.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
})
|
||||
63
nip27.ts
Normal file
63
nip27.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [uri, value] = match
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||
return content.replaceAll(regex(), (uri, value: string) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
}
|
||||
130
nip28.test.ts
Normal file
130
nip28.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { getPublicKey } from './pure.ts'
|
||||
import * as Kind from './kinds.ts'
|
||||
import {
|
||||
channelCreateEvent,
|
||||
channelMetadataEvent,
|
||||
channelMessageEvent,
|
||||
channelHideMessageEvent,
|
||||
channelMuteUserEvent,
|
||||
ChannelMetadata,
|
||||
ChannelMessageEventTemplate,
|
||||
} from './nip28.ts'
|
||||
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
describe('NIP-28 Functions', () => {
|
||||
const channelMetadata: ChannelMetadata = {
|
||||
name: 'Test Channel',
|
||||
about: 'This is a test channel',
|
||||
picture: 'https://example.com/picture.jpg',
|
||||
}
|
||||
|
||||
test('channelCreateEvent should create an event with given template', () => {
|
||||
const template = {
|
||||
content: channelMetadata,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelCreateEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelCreation)
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
})
|
||||
|
||||
test('channelMetadataEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
content: channelMetadata,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMetadataEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelMetadata)
|
||||
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMessageEvent should create a signed message event with given template', () => {
|
||||
const template: ChannelMessageEventTemplate = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
relay_url: 'https://relay.example.com',
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMessageEvent(template, privateKey)
|
||||
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMessageEvent should create a signed message reply event with given template', () => {
|
||||
const template: ChannelMessageEventTemplate = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
reply_to_channel_message_event_id: 'channel message event id',
|
||||
relay_url: 'https://relay.example.com',
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMessageEvent(template, privateKey)
|
||||
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
|
||||
'e',
|
||||
template.channel_create_event_id,
|
||||
template.relay_url,
|
||||
'root',
|
||||
])
|
||||
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
|
||||
'e',
|
||||
template.reply_to_channel_message_event_id as string,
|
||||
template.relay_url,
|
||||
'reply',
|
||||
])
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelHideMessageEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_message_event_id: 'channel message event id',
|
||||
content: { reason: 'Inappropriate content' },
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelHideMessageEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
|
||||
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMuteUserEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
content: { reason: 'Spamming' },
|
||||
created_at: 1617932115,
|
||||
pubkey_to_mute: 'pubkey to mute',
|
||||
}
|
||||
|
||||
const event = channelMuteUserEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
|
||||
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
152
nip28.ts
Normal file
152
nip28.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||
|
||||
export interface ChannelMetadata {
|
||||
name: string
|
||||
about: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export interface ChannelCreateEventTemplate {
|
||||
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||
content: string | ChannelMetadata
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMetadataEventTemplate {
|
||||
channel_create_event_id: string
|
||||
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||
content: string | ChannelMetadata
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMessageEventTemplate {
|
||||
channel_create_event_id: string
|
||||
reply_to_channel_message_event_id?: string
|
||||
relay_url: string
|
||||
content: string
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelHideMessageEventTemplate {
|
||||
channel_message_event_id: string
|
||||
content: string | { reason: string }
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMuteUserEventTemplate {
|
||||
content: string | { reason: string }
|
||||
created_at: number
|
||||
pubkey_to_mute: string
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelCreation,
|
||||
tags: [...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMetadata,
|
||||
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
|
||||
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
|
||||
|
||||
if (t.reply_to_channel_message_event_id) {
|
||||
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMessage,
|
||||
tags: [...tags, ...(t.tags ?? [])],
|
||||
content: t.content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
/* "e" tag should be the kind 42 event to hide */
|
||||
export const channelHideMessageEvent = (
|
||||
t: ChannelHideMessageEventTemplate,
|
||||
privateKey: Uint8Array,
|
||||
): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelHideMessage,
|
||||
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMuteUser,
|
||||
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
33
nip30.test.ts
Normal file
33
nip30.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip30.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
name: 'blobcat',
|
||||
shortcode: ':blobcat:',
|
||||
start: 6,
|
||||
end: 15,
|
||||
},
|
||||
{
|
||||
name: 'disputed',
|
||||
shortcode: ':disputed:',
|
||||
start: 16,
|
||||
end: 26,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
|
||||
|
||||
const result = replaceAll(content, ({ name }) => {
|
||||
return `<img src="https://ditto.pub/emoji/${name}.png" />`
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
|
||||
)
|
||||
})
|
||||
51
nip30.ts
Normal file
51
nip30.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Regex for a single emoji shortcode. */
|
||||
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
|
||||
|
||||
/** Regex to find emoji shortcodes in content. */
|
||||
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||
|
||||
/** Represents a Nostr custom emoji. */
|
||||
export interface CustomEmoji {
|
||||
/** The matched emoji name with colons. */
|
||||
shortcode: `:${string}:`
|
||||
/** The matched emoji name without colons. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Match result for a custom emoji in text content. */
|
||||
export interface CustomEmojiMatch extends CustomEmoji {
|
||||
/** Index where the emoji begins in the text content. */
|
||||
start: number
|
||||
/** Index where the emoji ends in the text content. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Find all custom emoji shortcodes. */
|
||||
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [shortcode, name] = match
|
||||
|
||||
yield {
|
||||
shortcode: shortcode as `:${string}:`,
|
||||
name,
|
||||
start: match.index!,
|
||||
end: match.index! + shortcode.length,
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace all emoji shortcodes in the content. */
|
||||
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
|
||||
return content.replaceAll(regex(), (shortcode, name) => {
|
||||
return replacer({
|
||||
shortcode: shortcode as `:${string}:`,
|
||||
name,
|
||||
})
|
||||
})
|
||||
}
|
||||
15
nip39.test.ts
Normal file
15
nip39.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||
|
||||
test('validate github claim', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let result = await validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421',
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
18
nip39.ts
Normal file
18
nip39.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||
try {
|
||||
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
14
nip42.test.ts
Normal file
14
nip42.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { relayConnect } from './relay.ts'
|
||||
|
||||
test('auth flow', async () => {
|
||||
const relay = await relayConnect('wss://nostr.wine')
|
||||
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
17
nip42.ts
Normal file
17
nip42.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { EventTemplate } from './pure.ts'
|
||||
import { ClientAuth } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* creates an EventTemplate for an AUTH event to be signed.
|
||||
*/
|
||||
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
|
||||
return {
|
||||
kind: ClientAuth,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relayURL],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
}
|
||||
44
nip44.test.ts
Normal file
44
nip44.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { v2 } from './nip44.js'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
test('get_conversation_key', () => {
|
||||
for (const v of v2vec.valid.get_conversation_key) {
|
||||
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
}
|
||||
})
|
||||
|
||||
test('encrypt_decrypt', () => {
|
||||
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||
expect(ciphertext).toEqual(v.payload)
|
||||
const decrypted = v2.decrypt(ciphertext, key)
|
||||
expect(decrypted).toEqual(v.plaintext)
|
||||
}
|
||||
})
|
||||
|
||||
test('calc_padded_len', () => {
|
||||
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||
const actual = v2.utils.calcPaddedLen(len)
|
||||
expect(actual).toEqual(shouldBePaddedTo)
|
||||
}
|
||||
})
|
||||
|
||||
test('decrypt', async () => {
|
||||
for (const v of v2vec.invalid.decrypt) {
|
||||
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
|
||||
}
|
||||
})
|
||||
|
||||
test('get_conversation_key', async () => {
|
||||
for (const v of v2vec.invalid.get_conversation_key) {
|
||||
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
|
||||
}
|
||||
})
|
||||
131
nip44.ts
Normal file
131
nip44.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { chacha20 } from '@noble/ciphers/chacha'
|
||||
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||
import { hmac } from '@noble/hashes/hmac'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const u = {
|
||||
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||
|
||||
utf8Encode: utf8ToBytes,
|
||||
utf8Decode(bytes: Uint8Array) {
|
||||
return decoder.decode(bytes)
|
||||
},
|
||||
|
||||
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
},
|
||||
|
||||
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||
ensureBytes(conversationKey, 32)
|
||||
ensureBytes(nonce, 32)
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
},
|
||||
|
||||
calcPaddedLen(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||
if (len <= 32) return 32
|
||||
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||
},
|
||||
|
||||
writeU16BE(num: number) {
|
||||
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||
const arr = new Uint8Array(2)
|
||||
new DataView(arr.buffer).setUint16(0, num, false)
|
||||
return arr
|
||||
},
|
||||
|
||||
pad(plaintext: string): Uint8Array {
|
||||
const unpadded = u.utf8Encode(plaintext)
|
||||
const unpaddedLen = unpadded.length
|
||||
const prefix = u.writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
return concatBytes(prefix, unpadded, suffix)
|
||||
},
|
||||
|
||||
unpad(padded: Uint8Array): string {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||
if (
|
||||
unpaddedLen < u.minPlaintextSize ||
|
||||
unpaddedLen > u.maxPlaintextSize ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return u.utf8Decode(unpadded)
|
||||
},
|
||||
|
||||
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
},
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
// padded plaintext: 32b to 0xffff
|
||||
// ciphertext: 32b+2 to 0xffff+2
|
||||
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||
// compressed payload (base64): 132b to 87472b
|
||||
decodePayload(payload: string) {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = base64.decode(payload)
|
||||
} catch (error) {
|
||||
throw new Error('invalid base64: ' + (error as any).message)
|
||||
}
|
||||
const dlen = data.length
|
||||
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||
const vers = data[0]
|
||||
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||
return {
|
||||
nonce: data.subarray(1, 33),
|
||||
ciphertext: data.subarray(33, -32),
|
||||
mac: data.subarray(-32),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const padded = u.pad(plaintext)
|
||||
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||
}
|
||||
|
||||
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||
return u.unpad(padded)
|
||||
}
|
||||
|
||||
export const v2 = {
|
||||
utils: u,
|
||||
encrypt,
|
||||
decrypt,
|
||||
}
|
||||
|
||||
export default { v2 }
|
||||
605
nip44.vectors.json
Normal file
605
nip44.vectors.json
Normal file
@@ -0,0 +1,605 @@
|
||||
{
|
||||
"v2": {
|
||||
"valid": {
|
||||
"get_conversation_key": [
|
||||
{
|
||||
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
|
||||
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
|
||||
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
|
||||
},
|
||||
{
|
||||
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
|
||||
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
|
||||
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
|
||||
},
|
||||
{
|
||||
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
|
||||
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
|
||||
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
|
||||
},
|
||||
{
|
||||
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
|
||||
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
|
||||
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
|
||||
},
|
||||
{
|
||||
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
|
||||
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
|
||||
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
|
||||
},
|
||||
{
|
||||
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
|
||||
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
|
||||
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
|
||||
},
|
||||
{
|
||||
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
|
||||
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
|
||||
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
|
||||
},
|
||||
{
|
||||
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
|
||||
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
|
||||
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
|
||||
},
|
||||
{
|
||||
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
|
||||
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
|
||||
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
|
||||
},
|
||||
{
|
||||
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
|
||||
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
|
||||
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
|
||||
},
|
||||
{
|
||||
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
|
||||
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
|
||||
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
|
||||
},
|
||||
{
|
||||
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
|
||||
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
|
||||
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
|
||||
},
|
||||
{
|
||||
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
|
||||
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
|
||||
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
|
||||
},
|
||||
{
|
||||
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
|
||||
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
|
||||
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
|
||||
},
|
||||
{
|
||||
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
|
||||
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
|
||||
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
|
||||
},
|
||||
{
|
||||
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
|
||||
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
|
||||
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
|
||||
},
|
||||
{
|
||||
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
|
||||
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
|
||||
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
|
||||
},
|
||||
{
|
||||
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
|
||||
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
|
||||
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
|
||||
},
|
||||
{
|
||||
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
|
||||
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
|
||||
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
|
||||
},
|
||||
{
|
||||
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
|
||||
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
|
||||
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
|
||||
},
|
||||
{
|
||||
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
|
||||
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
|
||||
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
|
||||
},
|
||||
{
|
||||
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
|
||||
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
|
||||
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
|
||||
},
|
||||
{
|
||||
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
|
||||
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
|
||||
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
|
||||
},
|
||||
{
|
||||
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
|
||||
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
|
||||
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
|
||||
},
|
||||
{
|
||||
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
|
||||
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
|
||||
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
|
||||
},
|
||||
{
|
||||
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
|
||||
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
|
||||
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
|
||||
},
|
||||
{
|
||||
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
|
||||
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
|
||||
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
|
||||
},
|
||||
{
|
||||
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
|
||||
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
|
||||
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
|
||||
},
|
||||
{
|
||||
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
|
||||
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
|
||||
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
|
||||
},
|
||||
{
|
||||
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
|
||||
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
|
||||
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
|
||||
},
|
||||
{
|
||||
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
|
||||
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
|
||||
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
|
||||
},
|
||||
{
|
||||
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
|
||||
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
|
||||
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
|
||||
"note": "sec1 = n-2, pub2: random, 0x02"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
|
||||
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
|
||||
"note": "sec1 = 2, pub2: rand"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
|
||||
"note": "sec1 == pub2"
|
||||
}
|
||||
],
|
||||
"get_message_keys": {
|
||||
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
|
||||
"keys": [
|
||||
{
|
||||
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
|
||||
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
|
||||
"chacha_nonce": "c4ad129bb01180c0933a160c",
|
||||
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
|
||||
},
|
||||
{
|
||||
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
|
||||
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
|
||||
"chacha_nonce": "22925e920cee4a50a478be90",
|
||||
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
|
||||
},
|
||||
{
|
||||
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
|
||||
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
|
||||
"chacha_nonce": "d3594987af769a52904656ac",
|
||||
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
|
||||
},
|
||||
{
|
||||
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
|
||||
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
|
||||
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
|
||||
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
|
||||
},
|
||||
{
|
||||
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
|
||||
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
|
||||
"chacha_nonce": "400224ba67fc2f1b76736916",
|
||||
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
|
||||
},
|
||||
{
|
||||
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
|
||||
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
|
||||
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
|
||||
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
|
||||
},
|
||||
{
|
||||
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
|
||||
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
|
||||
"chacha_nonce": "72f69a5a5f795465cee59da8",
|
||||
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
|
||||
},
|
||||
{
|
||||
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
|
||||
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
|
||||
"chacha_nonce": "6e69be92d61c04a276021565",
|
||||
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
|
||||
},
|
||||
{
|
||||
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
|
||||
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
|
||||
"chacha_nonce": "3dda53569cfcb7fac1805c35",
|
||||
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
|
||||
},
|
||||
{
|
||||
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
|
||||
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
|
||||
"chacha_nonce": "65064239186e50304cc0f156",
|
||||
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
|
||||
},
|
||||
{
|
||||
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
|
||||
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
|
||||
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
|
||||
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
|
||||
},
|
||||
{
|
||||
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
|
||||
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
|
||||
"chacha_nonce": "cbb2530ea653766e5a37a83a",
|
||||
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
|
||||
},
|
||||
{
|
||||
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
|
||||
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
|
||||
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
|
||||
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
|
||||
},
|
||||
{
|
||||
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
|
||||
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
|
||||
"chacha_nonce": "67803605a7e5010d0f63f8c8",
|
||||
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
|
||||
},
|
||||
{
|
||||
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
|
||||
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
|
||||
"chacha_nonce": "4e62a0073087ed808be62469",
|
||||
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
|
||||
},
|
||||
{
|
||||
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
|
||||
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
|
||||
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
|
||||
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
|
||||
},
|
||||
{
|
||||
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
|
||||
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
|
||||
"chacha_nonce": "329bb3024461e84b2e1c489b",
|
||||
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
|
||||
},
|
||||
{
|
||||
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
|
||||
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
|
||||
"chacha_nonce": "653d759042b85194d4d8c0a7",
|
||||
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
|
||||
},
|
||||
{
|
||||
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
|
||||
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
|
||||
"chacha_nonce": "b822e2c959df32b3cb772a7c",
|
||||
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
|
||||
},
|
||||
{
|
||||
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
|
||||
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
|
||||
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
|
||||
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
|
||||
},
|
||||
{
|
||||
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
|
||||
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
|
||||
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
|
||||
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
|
||||
},
|
||||
{
|
||||
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
|
||||
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
|
||||
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
|
||||
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
|
||||
},
|
||||
{
|
||||
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
|
||||
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
|
||||
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
|
||||
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
|
||||
},
|
||||
{
|
||||
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
|
||||
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
|
||||
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
|
||||
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
|
||||
},
|
||||
{
|
||||
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
|
||||
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
|
||||
"chacha_nonce": "263830a065af33d9c6c5aa1f",
|
||||
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
|
||||
},
|
||||
{
|
||||
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
|
||||
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
|
||||
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
|
||||
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
|
||||
},
|
||||
{
|
||||
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
|
||||
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
|
||||
"chacha_nonce": "ccdaad5b3b7645be430992eb",
|
||||
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
|
||||
},
|
||||
{
|
||||
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
|
||||
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
|
||||
"chacha_nonce": "ed02dece5fc3a186f123420b",
|
||||
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
|
||||
},
|
||||
{
|
||||
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
|
||||
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
|
||||
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
|
||||
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
|
||||
},
|
||||
{
|
||||
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
|
||||
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
|
||||
"chacha_nonce": "26b450612ca5e905b937e147",
|
||||
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
|
||||
},
|
||||
{
|
||||
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
|
||||
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
|
||||
"chacha_nonce": "4a296a1fb0048e5020d3b129",
|
||||
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
|
||||
},
|
||||
{
|
||||
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
|
||||
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
|
||||
"chacha_nonce": "a8188daff807a1182200b39d",
|
||||
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"calc_padded_len": [
|
||||
[16, 32],
|
||||
[32, 32],
|
||||
[33, 64],
|
||||
[37, 64],
|
||||
[45, 64],
|
||||
[49, 64],
|
||||
[64, 64],
|
||||
[65, 96],
|
||||
[100, 128],
|
||||
[111, 128],
|
||||
[200, 224],
|
||||
[250, 256],
|
||||
[320, 320],
|
||||
[383, 384],
|
||||
[384, 384],
|
||||
[400, 448],
|
||||
[500, 512],
|
||||
[512, 512],
|
||||
[515, 640],
|
||||
[700, 768],
|
||||
[800, 896],
|
||||
[900, 1024],
|
||||
[1020, 1024],
|
||||
[65536, 65536]
|
||||
],
|
||||
"encrypt_decrypt": [
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"plaintext": "a",
|
||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||
"plaintext": "🍕🫃",
|
||||
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
|
||||
},
|
||||
{
|
||||
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
|
||||
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
|
||||
},
|
||||
{
|
||||
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
|
||||
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
|
||||
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
|
||||
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
|
||||
"plaintext": "ability🤝的 ȺȾ",
|
||||
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
|
||||
},
|
||||
{
|
||||
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
|
||||
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
|
||||
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
|
||||
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
|
||||
"plaintext": "pepper👀їжак",
|
||||
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
|
||||
},
|
||||
{
|
||||
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
|
||||
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||
"plaintext": "( ͡° ͜ʖ ͡°)",
|
||||
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
|
||||
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
|
||||
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
|
||||
"plaintext": "الكل في المجمو عة (5)",
|
||||
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
|
||||
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
|
||||
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
|
||||
}
|
||||
],
|
||||
"encrypt_decrypt_long_msg": [
|
||||
{
|
||||
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
|
||||
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
|
||||
"letter": "ф",
|
||||
"repeat": 65535,
|
||||
"payload_checksum_sha256": "",
|
||||
"note": "фффф... (65535 times)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"invalid": {
|
||||
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
|
||||
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
|
||||
"get_conversation_key": [
|
||||
{
|
||||
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 higher than curve.n"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 is 0"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"note": "pub2 is invalid, no sqrt, all-ff"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 == curve.n"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "pub2 is invalid, no sqrt"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"note": "pub2 is point of order 3 on twist"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
|
||||
"note": "pub2 is point of order 13 on twist"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
|
||||
"note": "pub2 is point of order 3319 on twist"
|
||||
}
|
||||
],
|
||||
"decrypt": [
|
||||
{
|
||||
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o b l e",
|
||||
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
|
||||
"note": "unknown encryption version"
|
||||
},
|
||||
{
|
||||
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
|
||||
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
|
||||
"plaintext": "⚠️",
|
||||
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
|
||||
"note": "unknown encryption version 0"
|
||||
},
|
||||
{
|
||||
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o s t r",
|
||||
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
|
||||
"note": "invalid base64"
|
||||
},
|
||||
{
|
||||
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
|
||||
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
|
||||
"plaintext": "¯\\_(ツ)_/¯",
|
||||
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
|
||||
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
|
||||
"plaintext": "🥎",
|
||||
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
|
||||
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
|
||||
"plaintext": "elliptic-curve cryptography",
|
||||
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
|
||||
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
|
||||
"plaintext": "noble",
|
||||
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
|
||||
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
|
||||
"plaintext": "censorship-resistant and global social network",
|
||||
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
|
||||
"note": "invalid padding"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
65
nip47.test.ts
Normal file
65
nip47.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { decrypt } from './nip04.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
describe('parseConnectionString', () => {
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||
|
||||
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||
expect(relay).toBe('wss://relay.damus.io')
|
||||
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
})
|
||||
|
||||
test('throws an error if no pubkey in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no relay in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no secret in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeNwcRequestEvent', () => {
|
||||
test('returns a valid NWC request event', async () => {
|
||||
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
const invoice =
|
||||
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
|
||||
expect(result.kind).toBe(NWCWalletRequest)
|
||||
expect(await decrypt(secret, pubkey, result.content)).toEqual(
|
||||
JSON.stringify({
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result.tags).toEqual([['p', pubkey]])
|
||||
expect(result.id).toEqual(expect.any(String))
|
||||
expect(result.sig).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
34
nip47.ts
Normal file
34
nip47.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
import { encrypt } from './nip04.ts'
|
||||
|
||||
export function parseConnectionString(connectionString: string) {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const relay = searchParams.get('relay')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (!pubkey || !relay || !secret) {
|
||||
throw new Error('invalid connection string')
|
||||
}
|
||||
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: NWCWalletRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: encryptedContent,
|
||||
tags: [['p', pubkey]],
|
||||
}
|
||||
|
||||
return finalizeEvent(eventTemplate, secretKey)
|
||||
}
|
||||
319
nip57.test.ts
Normal file
319
nip57.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = mock(() => Promise.reject(new Error()))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns null if the response does not allow Nostr payments', async () => {
|
||||
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
const fetchImplementation = mock(() =>
|
||||
Promise.resolve({
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
nostrPubkey: 'pubkey',
|
||||
callback: 'callback',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBe('callback')
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapRequest', () => {
|
||||
test('throws an error if amount is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('throws an error if profile is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('returns a valid Zap request', () => {
|
||||
const result = makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
relays: ['relay1', 'relay2'],
|
||||
comment: 'comment',
|
||||
})
|
||||
expect(result.kind).toBe(9734)
|
||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||
expect(result.content).toBe('comment')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
['e', 'event'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||
const zapRequest = {
|
||||
kind: 1234,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||
})
|
||||
|
||||
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = {
|
||||
pubkey: publicKey,
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['e', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapReceipt', () => {
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const preimage = 'preimage'
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', publicKey],
|
||||
['preimage', preimage],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('returns a valid Zap receipt without a preimage', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', publicKey],
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||
})
|
||||
})
|
||||
130
nip57.ts
Normal file
130
nip57.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
let { words } = bech32.decode(lud06, 1000)
|
||||
let data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
let res = await _fetch(lnurl)
|
||||
let body = await res.json()
|
||||
|
||||
if (body.allowsNostr && body.nostrPubkey) {
|
||||
return body.callback
|
||||
}
|
||||
} catch (err) {
|
||||
/*-*/
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapRequest({
|
||||
profile,
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
}): EventTemplate {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
|
||||
let zr: EventTemplate = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
zr.tags.push(['e', event])
|
||||
}
|
||||
|
||||
return zr
|
||||
}
|
||||
|
||||
export function validateZapRequest(zapRequestString: string): string | null {
|
||||
let zapRequest: Event
|
||||
|
||||
try {
|
||||
zapRequest = JSON.parse(zapRequestString)
|
||||
} catch (err) {
|
||||
return 'Invalid zap request JSON.'
|
||||
}
|
||||
|
||||
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||
|
||||
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
|
||||
|
||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||
|
||||
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||
|
||||
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapReceipt({
|
||||
zapRequest,
|
||||
preimage,
|
||||
bolt11,
|
||||
paidAt,
|
||||
}: {
|
||||
zapRequest: string
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate {
|
||||
let zr: Event = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||
|
||||
let zap: EventTemplate = {
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
||||
}
|
||||
|
||||
if (preimage) {
|
||||
zap.tags.push(['preimage', preimage])
|
||||
}
|
||||
|
||||
return zap
|
||||
}
|
||||
159
nip98.test.ts
Normal file
159
nip98.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const sk = generateSecretKey()
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
121
nip98.ts
Normal file
121
nip98.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { base64 } from '@scure/base'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||
*/
|
||||
export async function getToken(
|
||||
loginUrl: string,
|
||||
httpMethod: string,
|
||||
sign: (e: EventTemplate) => Promise<Event> | Event,
|
||||
includeAuthorizationScheme: boolean = false,
|
||||
payload?: Record<string, any>,
|
||||
): Promise<string> {
|
||||
const event: EventTemplate = {
|
||||
kind: HTTPAuth,
|
||||
tags: [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod],
|
||||
],
|
||||
created_at: Math.round(new Date().getTime() / 1000),
|
||||
content: '',
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||
|
||||
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||
*/
|
||||
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||
throw new Error('Invalid token')
|
||||
}
|
||||
|
||||
const event = JSON.parse(eventB64) as Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
}
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== HTTPAuth) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
||||
if (payloadTag?.[1] !== payloadHash) {
|
||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
209
package.json
209
package.json
@@ -1,49 +1,204 @@
|
||||
{
|
||||
"name": "nostr-tools",
|
||||
"version": "0.24.1",
|
||||
"version": "2.0.3",
|
||||
"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"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"module": "./lib/esm/index.js",
|
||||
"main": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./lib/esm/index.js",
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./pure": {
|
||||
"import": "./lib/esm/pure.js",
|
||||
"require": "./lib/cjs/pure.js",
|
||||
"types": "./lib/types/pure.d.ts"
|
||||
},
|
||||
"./wasm": {
|
||||
"import": "./lib/esm/wasm.js",
|
||||
"require": "./lib/cjs/wasm.js",
|
||||
"types": "./lib/types/wasm.d.ts"
|
||||
},
|
||||
"./kinds": {
|
||||
"import": "./lib/esm/kinds.js",
|
||||
"require": "./lib/cjs/kinds.js",
|
||||
"types": "./lib/types/kinds.d.ts"
|
||||
},
|
||||
"./filter": {
|
||||
"import": "./lib/esm/filter.js",
|
||||
"require": "./lib/cjs/filter.js",
|
||||
"types": "./lib/types/filter.d.ts"
|
||||
},
|
||||
"./relay": {
|
||||
"import": "./lib/esm/relay.js",
|
||||
"require": "./lib/cjs/relay.js",
|
||||
"types": "./lib/types/relay.d.ts"
|
||||
},
|
||||
"./pool": {
|
||||
"import": "./lib/esm/pool.js",
|
||||
"require": "./lib/cjs/pool.js",
|
||||
"types": "./lib/types/pool.d.ts"
|
||||
},
|
||||
"./references": {
|
||||
"import": "./lib/esm/references.js",
|
||||
"require": "./lib/cjs/references.js",
|
||||
"types": "./lib/types/references.d.ts"
|
||||
},
|
||||
"./nip04": {
|
||||
"import": "./lib/esm/nip04.js",
|
||||
"require": "./lib/cjs/nip04.js",
|
||||
"types": "./lib/types/nip04.d.ts"
|
||||
},
|
||||
"./nip44": {
|
||||
"import": "./lib/esm/nip44.js",
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip05": {
|
||||
"import": "./lib/esm/nip05.js",
|
||||
"require": "./lib/cjs/nip05.js",
|
||||
"types": "./lib/types/nip05.d.ts"
|
||||
},
|
||||
"./nip06": {
|
||||
"import": "./lib/esm/nip06.js",
|
||||
"require": "./lib/cjs/nip06.js",
|
||||
"types": "./lib/types/nip06.d.ts"
|
||||
},
|
||||
"./nip10": {
|
||||
"import": "./lib/esm/nip10.js",
|
||||
"require": "./lib/cjs/nip10.js",
|
||||
"types": "./lib/types/nip10.d.ts"
|
||||
},
|
||||
"./nip11": {
|
||||
"import": "./lib/esm/nip11.js",
|
||||
"require": "./lib/cjs/nip11.js",
|
||||
"types": "./lib/types/nip11.d.ts"
|
||||
},
|
||||
"./nip13": {
|
||||
"import": "./lib/esm/nip13.js",
|
||||
"require": "./lib/cjs/nip13.js",
|
||||
"types": "./lib/types/nip13.d.ts"
|
||||
},
|
||||
"./nip18": {
|
||||
"import": "./lib/esm/nip18.js",
|
||||
"require": "./lib/cjs/nip18.js",
|
||||
"types": "./lib/types/nip18.d.ts"
|
||||
},
|
||||
"./nip19": {
|
||||
"import": "./lib/esm/nip19.js",
|
||||
"require": "./lib/cjs/nip19.js",
|
||||
"types": "./lib/types/nip19.d.ts"
|
||||
},
|
||||
"./nip21": {
|
||||
"import": "./lib/esm/nip21.js",
|
||||
"require": "./lib/cjs/nip21.js",
|
||||
"types": "./lib/types/nip21.d.ts"
|
||||
},
|
||||
"./nip25": {
|
||||
"import": "./lib/esm/nip25.js",
|
||||
"require": "./lib/cjs/nip25.js",
|
||||
"types": "./lib/types/nip25.d.ts"
|
||||
},
|
||||
"./nip27": {
|
||||
"import": "./lib/esm/nip27.js",
|
||||
"require": "./lib/cjs/nip27.js",
|
||||
"types": "./lib/types/nip27.d.ts"
|
||||
},
|
||||
"./nip28": {
|
||||
"import": "./lib/esm/nip28.js",
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
"types": "./lib/types/nip30.d.ts"
|
||||
},
|
||||
"./nip39": {
|
||||
"import": "./lib/esm/nip39.js",
|
||||
"require": "./lib/cjs/nip39.js",
|
||||
"types": "./lib/types/nip39.d.ts"
|
||||
},
|
||||
"./nip42": {
|
||||
"import": "./lib/esm/nip42.js",
|
||||
"require": "./lib/cjs/nip42.js",
|
||||
"types": "./lib/types/nip42.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
"types": "./lib/types/nip57.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
"types": "./lib/types/nip98.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
"types": "./lib/types/fakejson.d.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"import": "./lib/esm/utils.js",
|
||||
"require": "./lib/cjs/utils.js",
|
||||
"types": "./lib/types/utils.d.ts"
|
||||
}
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^0.5.7",
|
||||
"@noble/secp256k1": "^1.5.2",
|
||||
"browserify-cipher": ">=1",
|
||||
"buffer": ">=5",
|
||||
"create-hash": "^1.2.0",
|
||||
"cross-fetch": "^3.1.4",
|
||||
"micro-bip32": "^0.1.0",
|
||||
"micro-bip39": "^0.1.3",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"nostr-wasm": "v0.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
"twitter",
|
||||
"p2p",
|
||||
"mastodon",
|
||||
"ssb",
|
||||
"social",
|
||||
"unstoppable",
|
||||
"censorship",
|
||||
"censorship-resistance",
|
||||
"client"
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
|
||||
"@types/node": "^18.0.3",
|
||||
"esbuild": "^0.14.38",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"esm-loader-typescript": "^1.0.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "node build.cjs",
|
||||
"check-ts": "tsd && node --no-warnings --loader=esm-loader-typescript index.test-d.ts"
|
||||
"prepublish": "just build && just emit-types"
|
||||
}
|
||||
}
|
||||
|
||||
206
pool.js
206
pool.js
@@ -1,206 +0,0 @@
|
||||
import {getEventHash, verifySignature, signEvent} from './event.js'
|
||||
import {relayConnect, normalizeRelayURL} from './relay.js'
|
||||
|
||||
export function relayPool() {
|
||||
var globalPrivateKey
|
||||
var globalSigningFunction
|
||||
|
||||
const poolPolicy = {
|
||||
// setting this to a number will cause events to be published to a random
|
||||
// set of relays only, instead of publishing to all relays all the time
|
||||
randomChoice: null,
|
||||
|
||||
// setting this to true will cause .publish() calls to wait until the event has
|
||||
// been published -- or at least attempted to be published -- to all relays
|
||||
wait: false
|
||||
}
|
||||
const relays = {}
|
||||
const noticeCallbacks = []
|
||||
|
||||
function propagateNotice(notice, relayURL) {
|
||||
for (let i = 0; i < noticeCallbacks.length; i++) {
|
||||
let {relay} = relays[relayURL]
|
||||
noticeCallbacks[i](notice, relay)
|
||||
}
|
||||
}
|
||||
|
||||
const activeSubscriptions = {}
|
||||
|
||||
const sub = ({cb, filter, beforeSend}, id) => {
|
||||
if (!id) id = Math.random().toString().slice(2)
|
||||
|
||||
const subControllers = Object.fromEntries(
|
||||
Object.values(relays)
|
||||
.filter(({policy}) => policy.read)
|
||||
.map(({relay}) => [
|
||||
relay.url,
|
||||
relay.sub({cb: event => cb(event, relay.url), filter, beforeSend}, id)
|
||||
])
|
||||
)
|
||||
|
||||
const activeCallback = cb
|
||||
const activeFilters = filter
|
||||
const activeBeforeSend = beforeSend
|
||||
|
||||
const unsub = () => {
|
||||
Object.values(subControllers).forEach(sub => sub.unsub())
|
||||
delete activeSubscriptions[id]
|
||||
}
|
||||
const sub = ({
|
||||
cb = activeCallback,
|
||||
filter = activeFilters,
|
||||
beforeSend = activeBeforeSend
|
||||
}) => {
|
||||
Object.entries(subControllers).map(([relayURL, sub]) => [
|
||||
relayURL,
|
||||
sub.sub({cb: event => cb(event, relayURL), filter, beforeSend}, id)
|
||||
])
|
||||
return activeSubscriptions[id]
|
||||
}
|
||||
const addRelay = relay => {
|
||||
subControllers[relay.url] = relay.sub(
|
||||
{cb: event => cb(event, relay.url), filter, beforeSend},
|
||||
id
|
||||
)
|
||||
return activeSubscriptions[id]
|
||||
}
|
||||
const removeRelay = relayURL => {
|
||||
if (relayURL in subControllers) {
|
||||
subControllers[relayURL].unsub()
|
||||
if (Object.keys(subControllers).length === 0) unsub()
|
||||
}
|
||||
return activeSubscriptions[id]
|
||||
}
|
||||
|
||||
activeSubscriptions[id] = {
|
||||
sub,
|
||||
unsub,
|
||||
addRelay,
|
||||
removeRelay
|
||||
}
|
||||
|
||||
return activeSubscriptions[id]
|
||||
}
|
||||
|
||||
return {
|
||||
sub,
|
||||
relays,
|
||||
setPrivateKey(privateKey) {
|
||||
globalPrivateKey = privateKey
|
||||
},
|
||||
registerSigningFunction(fn) {
|
||||
globalSigningFunction = fn
|
||||
},
|
||||
setPolicy(key, value) {
|
||||
poolPolicy[key] = value
|
||||
},
|
||||
addRelay(url, policy = {read: true, write: true}) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
if (relayURL in relays) return
|
||||
|
||||
let relay = relayConnect(url, notice => {
|
||||
propagateNotice(notice, relayURL)
|
||||
})
|
||||
relays[relayURL] = {relay, policy}
|
||||
|
||||
if (policy.read) {
|
||||
Object.values(activeSubscriptions).forEach(subscription =>
|
||||
subscription.addRelay(relay)
|
||||
)
|
||||
}
|
||||
|
||||
return relay
|
||||
},
|
||||
removeRelay(url) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
let data = relays[relayURL]
|
||||
if (!data) return
|
||||
|
||||
let {relay} = data
|
||||
Object.values(activeSubscriptions).forEach(subscription =>
|
||||
subscription.removeRelay(relay)
|
||||
)
|
||||
relay.close()
|
||||
delete relays[relayURL]
|
||||
},
|
||||
onNotice(cb) {
|
||||
noticeCallbacks.push(cb)
|
||||
},
|
||||
offNotice(cb) {
|
||||
let index = noticeCallbacks.indexOf(cb)
|
||||
if (index !== -1) noticeCallbacks.splice(index, 1)
|
||||
},
|
||||
async publish(event, statusCallback) {
|
||||
event.id = getEventHash(event)
|
||||
|
||||
if (!event.sig) {
|
||||
event.tags = event.tags || []
|
||||
|
||||
if (globalPrivateKey) {
|
||||
event.sig = await signEvent(event, globalPrivateKey)
|
||||
} else if (globalSigningFunction) {
|
||||
event.sig = await globalSigningFunction(event)
|
||||
if (!event.sig) {
|
||||
// abort here
|
||||
return
|
||||
} else {
|
||||
// check
|
||||
if (!(await verifySignature(event)))
|
||||
throw new Error(
|
||||
'signature provided by custom signing function is invalid.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let writeable = Object.values(relays)
|
||||
.filter(({policy}) => policy.write)
|
||||
.sort(() => Math.random() - 0.5) // random
|
||||
|
||||
let maxTargets = poolPolicy.randomChoice
|
||||
? poolPolicy.randomChoice
|
||||
: writeable.length
|
||||
|
||||
let successes = 0
|
||||
|
||||
if (poolPolicy.wait) {
|
||||
for (let i = 0; i < writeable.length; i++) {
|
||||
let {relay} = writeable[i]
|
||||
|
||||
try {
|
||||
await new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
await relay.publish(event, status => {
|
||||
if (statusCallback) statusCallback(status, relay.url)
|
||||
resolve()
|
||||
})
|
||||
} catch (err) {
|
||||
if (statusCallback) statusCallback(-1, relay.url)
|
||||
}
|
||||
})
|
||||
|
||||
successes++
|
||||
if (successes >= maxTargets) {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
}
|
||||
} else {
|
||||
writeable.forEach(async ({relay}) => {
|
||||
let callback = statusCallback
|
||||
? status => statusCallback(status, relay.url)
|
||||
: null
|
||||
relay.publish(event, callback)
|
||||
})
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
}
|
||||
}
|
||||
99
pool.test.ts
Normal file
99
pool.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { test, expect, afterAll } from 'bun:test'
|
||||
|
||||
import { finalizeEvent, type Event } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
let received: Event[] = []
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event)
|
||||
})
|
||||
|
||||
test('same with double querying', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('querySync()', async () => {
|
||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toBeGreaterThan(2)
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
expect(events).toHaveLength(uniqueEventCount)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
||||
})
|
||||
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
||||
})
|
||||
183
pool.ts
Normal file
183
pool.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Relay, SubscriptionParams, Subscription } from './relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event } from './pure.ts'
|
||||
import { type Filter } from './filter.ts'
|
||||
|
||||
export type SubCloser = { close: () => void }
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export class SimplePool {
|
||||
private relays = new Map<string, Relay>()
|
||||
public seenOn = new Map<string, Set<Relay>>()
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public trustedRelayURLs = new Set<string>()
|
||||
|
||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<Relay> {
|
||||
url = normalizeURL(url)
|
||||
|
||||
let relay = this.relays.get(url)
|
||||
if (!relay) {
|
||||
relay = new Relay(url)
|
||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||
if (this.trustedRelayURLs.has(relay.url)) relay.trusted = true
|
||||
this.relays.set(url, relay)
|
||||
}
|
||||
await relay.connect()
|
||||
|
||||
return relay
|
||||
}
|
||||
|
||||
close(relays: string[]) {
|
||||
relays.map(normalizeURL).forEach(url => {
|
||||
this.relays.get(url)?.close()
|
||||
})
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: Relay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(id, set)
|
||||
}
|
||||
set.add(relay)
|
||||
}
|
||||
}
|
||||
|
||||
const _knownIds = new Set<string>()
|
||||
const subs: Subscription[] = []
|
||||
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
}
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relays.length) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const localAlreadyHaveEventHandler = (id: string) => {
|
||||
if (params.alreadyHaveEvent?.(id)) {
|
||||
return true
|
||||
}
|
||||
const have = _knownIds.has(id)
|
||||
_knownIds.add(id)
|
||||
return have
|
||||
}
|
||||
|
||||
// open a subscription in all given relays
|
||||
const allOpened = Promise.all(
|
||||
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
handleClose(i, 'duplicate url')
|
||||
return
|
||||
}
|
||||
|
||||
let relay: Relay
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
handleClose(i, (err as any)?.message || String(err))
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => handleClose(i, reason),
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
|
||||
subs.push(subscription)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
async close() {
|
||||
await allOpened
|
||||
subs.forEach(sub => {
|
||||
sub.close()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close()
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
}
|
||||
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
this.subscribeManyEose(relays, [filter], {
|
||||
...params,
|
||||
onevent(event: Event) {
|
||||
events.push(event)
|
||||
},
|
||||
onclose(_: string[]) {
|
||||
resolve(events)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event): Promise<string>[] {
|
||||
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
return Promise.reject('duplicate url')
|
||||
}
|
||||
|
||||
let r = await this.ensureRelay(url)
|
||||
return r.publish(event)
|
||||
})
|
||||
}
|
||||
}
|
||||
59
pure.ts
Normal file
59
pure.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
class JS implements Nostr {
|
||||
generateSecretKey(): Uint8Array {
|
||||
return schnorr.utils.randomPrivateKey()
|
||||
}
|
||||
getPublicKey(secretKey: Uint8Array): string {
|
||||
return bytesToHex(schnorr.getPublicKey(secretKey))
|
||||
}
|
||||
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||
const event = t as VerifiedEvent
|
||||
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
||||
event.id = getEventHash(event)
|
||||
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
|
||||
event[verifiedSymbol] = true
|
||||
return event
|
||||
}
|
||||
verifyEvent(event: Event): event is VerifiedEvent {
|
||||
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||
|
||||
const hash = getEventHash(event)
|
||||
if (hash !== event.id) {
|
||||
event[verifiedSymbol] = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = schnorr.verify(event.sig, hash, event.pubkey)
|
||||
event[verifiedSymbol] = valid
|
||||
return valid
|
||||
} catch (err) {
|
||||
event[verifiedSymbol] = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent): string {
|
||||
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const i = new JS()
|
||||
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
export const verifyEvent = i.verifyEvent
|
||||
export * from './core.ts'
|
||||
47
references.test.ts
Normal file
47
references.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { parseReferences } from './references.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
test('parse mentions', () => {
|
||||
let evt = buildEvent({
|
||||
tags: [
|
||||
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
|
||||
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
|
||||
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
|
||||
],
|
||||
content:
|
||||
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||
})
|
||||
|
||||
expect(parseReferences(evt)).toEqual([
|
||||
{
|
||||
text: '#[0]',
|
||||
profile: {
|
||||
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
relays: ['wss://nostr.com'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '#[2]',
|
||||
event: {
|
||||
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||
profile: {
|
||||
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||
event: {
|
||||
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
author: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
104
references.ts
Normal file
104
references.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||
|
||||
import type { Event } from './pure.ts'
|
||||
|
||||
type Reference = {
|
||||
text: string
|
||||
profile?: ProfilePointer
|
||||
event?: EventPointer
|
||||
address?: AddressPointer
|
||||
}
|
||||
|
||||
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
|
||||
export function parseReferences(evt: Event): Reference[] {
|
||||
let references: Reference[] = []
|
||||
for (let ref of evt.content.matchAll(mentionRegex)) {
|
||||
if (ref[2]) {
|
||||
// it's a NIP-27 mention
|
||||
try {
|
||||
let { type, data } = decode(ref[1])
|
||||
switch (type) {
|
||||
case 'npub': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: { pubkey: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nprofile': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: data as ProfilePointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'note': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: { id: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nevent': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: data as EventPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'naddr': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: data as AddressPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
} else if (ref[3]) {
|
||||
// it's a NIP-10 mention
|
||||
let idx = parseInt(ref[3], 10)
|
||||
let tag = evt.tags[idx]
|
||||
if (!tag) continue
|
||||
|
||||
switch (tag[0]) {
|
||||
case 'p': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
try {
|
||||
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: {
|
||||
identifier,
|
||||
pubkey,
|
||||
kind: parseInt(kind, 10),
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
195
relay.js
195
relay.js
@@ -1,195 +0,0 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import {verifySignature, validateEvent} from './event.js'
|
||||
import {matchFilters} from './filter.js'
|
||||
|
||||
export function normalizeRelayURL(url) {
|
||||
let [host, ...qs] = url.trim().split('?')
|
||||
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
|
||||
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
|
||||
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
|
||||
return [host, ...qs].join('?')
|
||||
}
|
||||
|
||||
export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
|
||||
url = normalizeRelayURL(url)
|
||||
|
||||
var ws, resolveOpen, untilOpen, wasClosed
|
||||
var openSubs = {}
|
||||
var isSetToSkipVerification = {}
|
||||
let attemptNumber = 1
|
||||
let nextAttemptSeconds = 1
|
||||
|
||||
function resetOpenState() {
|
||||
untilOpen = new Promise(resolve => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
}
|
||||
|
||||
var channels = {}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('connected to', url)
|
||||
resolveOpen()
|
||||
|
||||
// restablish old subscriptions
|
||||
if (wasClosed) {
|
||||
wasClosed = false
|
||||
for (let channel in openSubs) {
|
||||
let filters = openSubs[channel]
|
||||
let cb = channels[channel]
|
||||
sub({cb, filter: filters}, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
ws.onerror = err => {
|
||||
console.log('error connecting to relay', url)
|
||||
onError(err)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
resetOpenState()
|
||||
attemptNumber++
|
||||
nextAttemptSeconds += attemptNumber ** 3
|
||||
if (nextAttemptSeconds > 14400) {
|
||||
nextAttemptSeconds = 14400 // 4 hours
|
||||
}
|
||||
console.log(
|
||||
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
|
||||
)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
connect()
|
||||
} catch (err) {}
|
||||
}, nextAttemptSeconds * 1000)
|
||||
|
||||
wasClosed = true
|
||||
}
|
||||
|
||||
ws.onmessage = async e => {
|
||||
var data
|
||||
try {
|
||||
data = JSON.parse(e.data)
|
||||
} catch (err) {
|
||||
data = e.data
|
||||
}
|
||||
|
||||
if (data.length > 1) {
|
||||
if (data[0] === 'NOTICE') {
|
||||
if (data.length < 2) return
|
||||
|
||||
console.log('message from relay ' + url + ': ' + data[1])
|
||||
onNotice(data[1])
|
||||
return
|
||||
}
|
||||
|
||||
if (data[0] === 'EVENT') {
|
||||
if (data.length < 3) return
|
||||
|
||||
let channel = data[1]
|
||||
let event = data[2]
|
||||
|
||||
if (
|
||||
validateEvent(event) &&
|
||||
(isSetToSkipVerification[channel] || verifySignature(event)) &&
|
||||
channels[channel] &&
|
||||
matchFilters(openSubs[channel], event)
|
||||
) {
|
||||
channels[channel](event)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetOpenState()
|
||||
|
||||
try {
|
||||
connect()
|
||||
} catch (err) {}
|
||||
|
||||
async function trySend(params) {
|
||||
let msg = JSON.stringify(params)
|
||||
|
||||
await untilOpen
|
||||
ws.send(msg)
|
||||
}
|
||||
|
||||
const sub = (
|
||||
{cb, filter, beforeSend, skipVerification},
|
||||
channel = Math.random().toString().slice(2)
|
||||
) => {
|
||||
var filters = []
|
||||
if (Array.isArray(filter)) {
|
||||
filters = filter
|
||||
} else {
|
||||
filters.push(filter)
|
||||
}
|
||||
|
||||
if (beforeSend) {
|
||||
const beforeSendResult = beforeSend({filter, relay: url, channel})
|
||||
filters = beforeSendResult.filter
|
||||
}
|
||||
|
||||
trySend(['REQ', channel, ...filters])
|
||||
channels[channel] = cb
|
||||
openSubs[channel] = filters
|
||||
isSetToSkipVerification[channel] = skipVerification
|
||||
|
||||
const activeCallback = cb
|
||||
const activeFilters = filters
|
||||
const activeBeforeSend = beforeSend
|
||||
|
||||
return {
|
||||
sub: ({
|
||||
cb = activeCallback,
|
||||
filter = activeFilters,
|
||||
beforeSend = activeBeforeSend
|
||||
}) => sub({cb, filter, beforeSend, skipVerification}, channel),
|
||||
unsub: () => {
|
||||
delete openSubs[channel]
|
||||
delete channels[channel]
|
||||
delete isSetToSkipVerification[channel]
|
||||
trySend(['CLOSE', channel])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
async publish(event, statusCallback) {
|
||||
try {
|
||||
await trySend(['EVENT', event])
|
||||
if (statusCallback) {
|
||||
statusCallback(0)
|
||||
let {unsub} = sub(
|
||||
{
|
||||
cb: () => {
|
||||
statusCallback(1)
|
||||
unsub()
|
||||
clearTimeout(willUnsub)
|
||||
},
|
||||
filter: {ids: [event.id]}
|
||||
},
|
||||
`monitor-${event.id.slice(0, 5)}`
|
||||
)
|
||||
let willUnsub = setTimeout(unsub, 5000)
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusCallback) statusCallback(-1)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
ws.close()
|
||||
},
|
||||
get status() {
|
||||
return ws.readyState
|
||||
}
|
||||
}
|
||||
}
|
||||
114
relay.test.ts
Normal file
114
relay.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay, relayConnect } from './relay.ts'
|
||||
|
||||
let relay = new Relay('wss://public.relaying.io')
|
||||
|
||||
afterEach(() => {
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity', async () => {
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
})
|
||||
|
||||
test('connectivity, with relayConnect()', async () => {
|
||||
const relay = await relayConnect('wss://public.relaying.io')
|
||||
expect(relay.connected).toBeTrue()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
await relay.connect()
|
||||
|
||||
let resolve1: () => void
|
||||
let resolve2: () => void
|
||||
|
||||
let waiting = Promise.all([
|
||||
new Promise<void>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
])
|
||||
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
ids: ['3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('id', '3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4')
|
||||
expect(event).toHaveProperty('content', '+')
|
||||
expect(event).toHaveProperty('kind', 7)
|
||||
resolve1()
|
||||
},
|
||||
oneose() {
|
||||
resolve2()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
let [t1, t2] = await waiting
|
||||
expect(t1).toBeUndefined()
|
||||
expect(t2).toBeUndefined()
|
||||
}, 10000)
|
||||
|
||||
test('listening and publishing and closing', async () => {
|
||||
await relay.connect()
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1: (_: void) => void
|
||||
var resolve2: (_: void) => void
|
||||
|
||||
let waiting = Promise.all([
|
||||
new Promise(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
])
|
||||
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [23571],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 23571)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve1()
|
||||
},
|
||||
onclose() {
|
||||
resolve2()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
kind: 23571,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
sub.close()
|
||||
|
||||
let [t1, t2] = await waiting
|
||||
expect(t1).toBeUndefined()
|
||||
expect(t2).toBeUndefined()
|
||||
})
|
||||
351
relay.ts
Normal file
351
relay.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent, validateEvent, type Event, EventTemplate } from './pure.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { Queue, normalizeURL } from './utils.ts'
|
||||
import { nip42 } from './index.ts'
|
||||
import { yieldThread } from './helpers.ts'
|
||||
|
||||
export async function relayConnect(url: string) {
|
||||
const relay = new Relay(url)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
export class Relay {
|
||||
public readonly url: string
|
||||
private _connected: boolean = false
|
||||
|
||||
public trusted: boolean = false
|
||||
public onclose: (() => void) | null = null
|
||||
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||
|
||||
public baseEoseTimeout: number = 4400
|
||||
public connectionTimeout: number = 4400
|
||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
private connectionPromise: Promise<void> | undefined
|
||||
private openSubs = new Map<string, Subscription>()
|
||||
private openCountRequests = new Map<string, CountResolver>()
|
||||
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||
private ws: WebSocket | undefined
|
||||
private incomingMessageQueue = new Queue<string>()
|
||||
private queueRunning = false
|
||||
private challenge: string | undefined
|
||||
private serial: number = 0
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = normalizeURL(url)
|
||||
}
|
||||
|
||||
private closeAllSubscriptions(reason: string) {
|
||||
for (let [_, sub] of this.openSubs) {
|
||||
sub.close(reason)
|
||||
}
|
||||
this.openSubs.clear()
|
||||
|
||||
for (let [_, ep] of this.openEventPublishes) {
|
||||
ep.reject(new Error(reason))
|
||||
}
|
||||
this.openEventPublishes.clear()
|
||||
|
||||
for (let [_, cr] of this.openCountRequests) {
|
||||
cr.reject(new Error(reason))
|
||||
}
|
||||
this.openCountRequests.clear()
|
||||
}
|
||||
|
||||
public get connected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionPromise) return this.connectionPromise
|
||||
|
||||
this.challenge = undefined
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connectionTimeoutHandle = setTimeout(() => {
|
||||
reject('connection timed out')
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection timed out')
|
||||
}, this.connectionTimeout)
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
this._connected = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = ev => {
|
||||
reject((ev as any).message)
|
||||
if (this._connected) {
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection errored')
|
||||
this._connected = false
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
this._connected = false
|
||||
}
|
||||
|
||||
this.ws.onmessage = ev => {
|
||||
this.incomingMessageQueue.enqueue(ev.data as string)
|
||||
if (!this.queueRunning) {
|
||||
this.runQueue()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return this.connectionPromise
|
||||
}
|
||||
|
||||
private async runQueue() {
|
||||
this.queueRunning = true
|
||||
while (true) {
|
||||
if (false === this.handleNext()) {
|
||||
break
|
||||
}
|
||||
await yieldThread()
|
||||
}
|
||||
this.queueRunning = false
|
||||
}
|
||||
|
||||
private handleNext(): undefined | false {
|
||||
const json = this.incomingMessageQueue.dequeue()
|
||||
if (!json) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
const so = this.openSubs.get(subid as string)
|
||||
if (!so) {
|
||||
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
||||
return
|
||||
}
|
||||
|
||||
// this will be called only when this message is a EVENT message for a subscription we have
|
||||
// we do this before parsing the JSON to not have to do that for duplicate events
|
||||
// since JSON parsing is slow
|
||||
const id = getHex64(json, 'id')
|
||||
const alreadyHave = so.alreadyHaveEvent?.(id)
|
||||
|
||||
// notify any interested client that the relay has this event
|
||||
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
||||
so.receivedEvent?.(this, id)
|
||||
|
||||
if (alreadyHave) {
|
||||
// if we had already seen this event we can just stop here
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json)
|
||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||
const event = data[2] as Event
|
||||
if ((this.trusted || (validateEvent(event) && verifyEvent(event))) && matchFilters(so.filters, event)) {
|
||||
so.onevent(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT': {
|
||||
const id: string = data[1]
|
||||
const payload = data[2] as { count: number }
|
||||
const cr = this.openCountRequests.get(id) as CountResolver
|
||||
if (cr) {
|
||||
cr.resolve(payload.count)
|
||||
this.openCountRequests.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'EOSE': {
|
||||
const so = this.openSubs.get(data[1] as string)
|
||||
if (!so) return
|
||||
so.receivedEose()
|
||||
return
|
||||
}
|
||||
case 'OK': {
|
||||
const id: string = data[1]
|
||||
const ok: boolean = data[2]
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
return
|
||||
}
|
||||
case 'CLOSED': {
|
||||
const id: string = data[1]
|
||||
const so = this.openSubs.get(id)
|
||||
if (!so) return
|
||||
so.closed = true
|
||||
so.close(data[2] as string)
|
||||
this.openSubs.delete(id)
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
this.onnotice(data[1] as string)
|
||||
return
|
||||
case 'AUTH': {
|
||||
this.challenge = data[1] as string
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
public async send(message: string) {
|
||||
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||
|
||||
this.connectionPromise.then(() => {
|
||||
this.ws?.send(message)
|
||||
})
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = nip42.makeAuthEvent(this.url, this.challenge)
|
||||
await signAuthEvent(evt)
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
||||
this.serial++
|
||||
const id = params?.id || 'count:' + this.serial
|
||||
const ret = new Promise<number>((resolve, reject) => {
|
||||
this.openCountRequests.set(id, { resolve, reject })
|
||||
})
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.closeAllSubscriptions('relay connection closed by us')
|
||||
this._connected = false
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class Subscription {
|
||||
public readonly relay: Relay
|
||||
public readonly id: string
|
||||
|
||||
public closed: boolean = false
|
||||
public eosed: boolean = false
|
||||
public filters: Filter[]
|
||||
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
||||
public receivedEvent: ((relay: Relay, id: string) => void) | undefined
|
||||
|
||||
public onevent: (evt: Event) => void
|
||||
public oneose: (() => void) | undefined
|
||||
public onclose: ((reason: string) => void) | undefined
|
||||
|
||||
public eoseTimeout: number
|
||||
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(relay: Relay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||
this.relay = relay
|
||||
this.filters = filters
|
||||
this.id = id
|
||||
this.alreadyHaveEvent = params.alreadyHaveEvent
|
||||
this.receivedEvent = params.receivedEvent
|
||||
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
||||
|
||||
this.oneose = params.oneose
|
||||
this.onclose = params.onclose
|
||||
this.onevent =
|
||||
params.onevent ||
|
||||
(event => {
|
||||
console.warn(
|
||||
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
||||
event,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public fire() {
|
||||
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
||||
|
||||
// only now we start counting the eoseTimeout
|
||||
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
||||
}
|
||||
|
||||
public receivedEose() {
|
||||
if (this.eosed) return
|
||||
clearTimeout(this.eoseTimeoutHandle)
|
||||
this.eosed = true
|
||||
this.oneose?.()
|
||||
}
|
||||
|
||||
public close(reason: string = 'closed by caller') {
|
||||
if (!this.closed) {
|
||||
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||
// otherwise this._open will be already set to false so we will skip this
|
||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||
this.closed = true
|
||||
}
|
||||
this.onclose?.(reason)
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscriptionParams = {
|
||||
onevent?: (evt: Event) => void
|
||||
oneose?: () => void
|
||||
onclose?: (reason: string) => void
|
||||
alreadyHaveEvent?: (id: string) => boolean
|
||||
receivedEvent?: (relay: Relay, id: string) => void
|
||||
eoseTimeout?: number
|
||||
}
|
||||
|
||||
export type CountResolver = {
|
||||
resolve: (count: number) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
15
test-helpers.ts
Normal file
15
test-helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Event } from './pure.ts'
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
tags: [],
|
||||
sig: '',
|
||||
...params,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2020",
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "es2020"],
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"baseUrl": "./",
|
||||
"typeRoots": ["."],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": [
|
||||
"index.d.ts",
|
||||
"t/nostr-tools-tests.ts"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "lib/types",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
|
||||
265
utils.test.ts
Normal file
265
utils.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||
|
||||
import type { Event } from './pure.ts'
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 1 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 15,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 5,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0: Event[] = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 1,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 25,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 50,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enqueue a message into MessageQueue', () => {
|
||||
test('enqueue into an empty queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
})
|
||||
test('enqueue into a non-empty queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
expect(queue.last!.value).toBe('node2')
|
||||
})
|
||||
test('dequeue from an empty queue', () => {
|
||||
const queue = new Queue()
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe(null)
|
||||
})
|
||||
test('dequeue from a non-empty queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
})
|
||||
test('dequeue more than in queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
const item3 = queue.dequeue()
|
||||
expect(item3).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
test('binary search', () => {
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
|
||||
})
|
||||
116
utils.ts
Normal file
116
utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Event } from './pure.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return b.created_at - event.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return event.created_at - b.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
|
||||
let start = 0
|
||||
let end = arr.length - 1
|
||||
|
||||
while (start <= end) {
|
||||
const mid = Math.floor((start + end) / 2)
|
||||
const cmp = compare(arr[mid])
|
||||
|
||||
if (cmp === 0) {
|
||||
return [mid, true]
|
||||
}
|
||||
|
||||
if (cmp < 0) {
|
||||
end = mid - 1
|
||||
} else {
|
||||
start = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
return [start, false]
|
||||
}
|
||||
|
||||
export class QueueNode<V> {
|
||||
public value: V
|
||||
public next: QueueNode<V> | null = null
|
||||
public prev: QueueNode<V> | null = null
|
||||
|
||||
constructor(message: V) {
|
||||
this.value = message
|
||||
}
|
||||
}
|
||||
|
||||
export class Queue<V> {
|
||||
public first: QueueNode<V> | null
|
||||
public last: QueueNode<V> | null
|
||||
|
||||
constructor() {
|
||||
this.first = null
|
||||
this.last = null
|
||||
}
|
||||
|
||||
enqueue(value: V): boolean {
|
||||
const newNode = new QueueNode(value)
|
||||
if (!this.last) {
|
||||
// list is empty
|
||||
this.first = newNode
|
||||
this.last = newNode
|
||||
} else if (this.last === this.first) {
|
||||
// list has a single element
|
||||
this.last = newNode
|
||||
this.last.prev = this.first
|
||||
this.first.next = newNode
|
||||
} else {
|
||||
// list has elements, add as last
|
||||
newNode.prev = this.last
|
||||
this.last.next = newNode
|
||||
this.last = newNode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
dequeue(): V | null {
|
||||
if (!this.first) return null
|
||||
|
||||
if (this.first === this.last) {
|
||||
const target = this.first
|
||||
this.first = null
|
||||
this.last = null
|
||||
return target.value
|
||||
}
|
||||
|
||||
const target = this.first
|
||||
this.first = target.next
|
||||
|
||||
return target.value
|
||||
}
|
||||
}
|
||||
38
wasm.ts
Normal file
38
wasm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||
|
||||
let nw: NostrWasm
|
||||
|
||||
export function setNostrWasm(x: NostrWasm) {
|
||||
nw = x
|
||||
}
|
||||
|
||||
class Wasm implements Nostr {
|
||||
generateSecretKey(): Uint8Array {
|
||||
return nw.generateSecretKey()
|
||||
}
|
||||
getPublicKey(secretKey: Uint8Array): string {
|
||||
return bytesToHex(nw.getPublicKey(secretKey))
|
||||
}
|
||||
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||
nw.finalizeEvent(t as any, secretKey)
|
||||
return t as VerifiedEvent
|
||||
}
|
||||
verifyEvent(event: Event): event is VerifiedEvent {
|
||||
try {
|
||||
nw.verifyEvent(event)
|
||||
event[verifiedSymbol] = true
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const i = new Wasm()
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
export const verifyEvent = i.verifyEvent
|
||||
export * from './core.ts'
|
||||
Reference in New Issue
Block a user