mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
300 Commits
v2.1.2
...
de7d459f6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7d459f6f | ||
|
|
21ec5bb2dc | ||
|
|
e959409c14 | ||
|
|
8a76c4e329 | ||
|
|
34a1d8db47 | ||
|
|
d3ddd490c2 | ||
|
|
7730e321a5 | ||
|
|
400d132612 | ||
|
|
01880b6fb5 | ||
|
|
e87ffc433c | ||
|
|
c45e861493 | ||
|
|
66cc55c7f0 | ||
|
|
5841b0936b | ||
|
|
f5d0c0eb0f | ||
|
|
e19db61bec | ||
|
|
1e0f393268 | ||
|
|
1bec9fa365 | ||
|
|
e8927d78e6 | ||
|
|
bc1294e4e6 | ||
|
|
226d7d07e2 | ||
|
|
c9ff51e278 | ||
|
|
23aebbd341 | ||
|
|
a3fcd79545 | ||
|
|
0e6e7af934 | ||
|
|
8866042edf | ||
|
|
ebe7df7b9e | ||
|
|
86235314c4 | ||
|
|
b39dac3551 | ||
|
|
929d62bbbb | ||
|
|
b575e47844 | ||
|
|
b076c34a2f | ||
|
|
4bb3eb2d40 | ||
|
|
87f2c74bb3 | ||
|
|
4b6cc19b9c | ||
|
|
b2f3a01439 | ||
|
|
6ec19b618c | ||
|
|
b3cc9f50e5 | ||
|
|
de1cf0ed60 | ||
|
|
d706ef961f | ||
|
|
2f529b3f8a | ||
|
|
f0357805c3 | ||
|
|
ffa7fb926e | ||
|
|
12acb900ab | ||
|
|
d773012658 | ||
|
|
b8f91c37fa | ||
|
|
2da3528362 | ||
|
|
315e9a472c | ||
|
|
a2b1bf0338 | ||
|
|
861a77e2b3 | ||
|
|
9132b722f3 | ||
|
|
ae2f97655b | ||
|
|
5b78a829c7 | ||
|
|
de26ee98c5 | ||
|
|
1437bbdb0f | ||
|
|
57354b9fb4 | ||
|
|
924075b803 | ||
|
|
666a02027e | ||
|
|
eff9ea9579 | ||
|
|
ca174e6cd8 | ||
|
|
4ba9c8886b | ||
|
|
7dbd86eb5c | ||
|
|
3e839db6f2 | ||
|
|
cb370fbf4f | ||
|
|
c015b6e794 | ||
|
|
52079f6e75 | ||
|
|
ef28b2eb73 | ||
|
|
2a422774fb | ||
|
|
b80f8a0bcc | ||
|
|
dd603e47d8 | ||
|
|
ba26b92973 | ||
|
|
aec8ff5946 | ||
|
|
e498c9144d | ||
|
|
42d47abba1 | ||
|
|
303c35120c | ||
|
|
4a738c93d0 | ||
|
|
2a11c9ec91 | ||
|
|
cbe3a9d683 | ||
|
|
2944a932b8 | ||
|
|
6b39de04d7 | ||
|
|
9a612e59a2 | ||
|
|
266dbdf766 | ||
|
|
19ae9837a7 | ||
|
|
4188f2c596 | ||
|
|
97bded8f5b | ||
|
|
174d36a440 | ||
|
|
0177b130c3 | ||
|
|
05eb62da5b | ||
|
|
3c4019a154 | ||
|
|
e7e8db1dbd | ||
|
|
44a679e642 | ||
|
|
c1172caf1d | ||
|
|
86f37d6003 | ||
|
|
3daade322c | ||
|
|
fcf10541c8 | ||
|
|
548abb5d4a | ||
|
|
1e5bfe856b | ||
|
|
3266b4d4c2 | ||
|
|
a0b950ab12 | ||
|
|
be741159d7 | ||
|
|
9c50b2c655 | ||
|
|
bbb09420fe | ||
|
|
2e85f7a5fe | ||
|
|
b22e2465cc | ||
|
|
43ce7f9377 | ||
|
|
5a55c670fb | ||
|
|
bf0c4d4988 | ||
|
|
50fe7c2a8b | ||
|
|
29270c8c9d | ||
|
|
cb29d62033 | ||
|
|
0d237405d9 | ||
|
|
659ad36b62 | ||
|
|
d062ab8afd | ||
|
|
94f841f347 | ||
|
|
c1d03cf00b | ||
|
|
29ecdfc5ec | ||
|
|
d3fc4734b4 | ||
|
|
66d0b8a4e1 | ||
|
|
e2ec7a4b55 | ||
|
|
a72e47135a | ||
|
|
de7bbfc6a2 | ||
|
|
f2d421fa4f | ||
|
|
cae06fc4fe | ||
|
|
5c538efa38 | ||
|
|
013daae91b | ||
|
|
75660e7ff1 | ||
|
|
4c2d2b5ce6 | ||
|
|
aba266b8e6 | ||
|
|
d7dcc75ebe | ||
|
|
b18510b460 | ||
|
|
b04e0d16c0 | ||
|
|
633696bf46 | ||
|
|
bf975c9a87 | ||
|
|
7aa4f09769 | ||
|
|
f646fcd889 | ||
|
|
1d89038375 | ||
|
|
0b5b35714c | ||
|
|
e398617fdc | ||
|
|
1b236faa7b | ||
|
|
7064e0b828 | ||
|
|
4f6976f6f8 | ||
|
|
a61cde77ea | ||
|
|
23d95acb26 | ||
|
|
13ac04b8f8 | ||
|
|
45b25c5bf5 | ||
|
|
ee76d69b4b | ||
|
|
21433049b8 | ||
|
|
e8ff68f0b3 | ||
|
|
1b77d6e080 | ||
|
|
76d3a91600 | ||
|
|
6f334f31a7 | ||
|
|
9c009ac543 | ||
|
|
a87099fa5c | ||
|
|
475a22a95f | ||
|
|
54e352d8e2 | ||
|
|
235a1c50cb | ||
|
|
dfc2107569 | ||
|
|
986b9d0cce | ||
|
|
753ff323ea | ||
|
|
f8c3e20f3d | ||
|
|
87a91c2daf | ||
|
|
4f1dc9ef1c | ||
|
|
faa1a9d556 | ||
|
|
97d838f254 | ||
|
|
260400b24d | ||
|
|
6e5ab34a54 | ||
|
|
9562c408b3 | ||
|
|
4f4de458e9 | ||
|
|
88454de628 | ||
|
|
9f5984d78d | ||
|
|
80df21d47f | ||
|
|
296e99d2a4 | ||
|
|
1cd9847ad5 | ||
|
|
fa31fdca78 | ||
|
|
5876acd67a | ||
|
|
44efd49bc0 | ||
|
|
f4f9bece6e | ||
|
|
e217f751da | ||
|
|
d0ae8b36a2 | ||
|
|
fd945757be | ||
|
|
c12ddd3c53 | ||
|
|
1e9f828e3e | ||
|
|
0a5eaac088 | ||
|
|
e858698cb9 | ||
|
|
b349ee577d | ||
|
|
849a2ac3f3 | ||
|
|
c18b94677c | ||
|
|
f306cec716 | ||
|
|
5c7e9c8f36 | ||
|
|
1d7620a057 | ||
|
|
88247e56c1 | ||
|
|
e5cda3509c | ||
|
|
02da1dc036 | ||
|
|
7aed747bb2 | ||
|
|
747a7944d7 | ||
|
|
9f8b7274b3 | ||
|
|
ee565db7f5 | ||
|
|
e9ee8258e7 | ||
|
|
ad07d260ab | ||
|
|
632184afb8 | ||
|
|
d7d5d30f41 | ||
|
|
387ce2c335 | ||
|
|
b62b8f88af | ||
|
|
6b43533f2e | ||
|
|
e30e08d8e2 | ||
|
|
59426d9f35 | ||
|
|
5429142858 | ||
|
|
564c9bca17 | ||
|
|
0190ae94a7 | ||
|
|
e1bde08ff3 | ||
|
|
71456feb20 | ||
|
|
ca928c697b | ||
|
|
7b98cae7fa | ||
|
|
db53f37161 | ||
|
|
1691f0b51d | ||
|
|
3b582a0206 | ||
|
|
8ed2c13c28 | ||
|
|
27a536f41d | ||
|
|
fbc82d0b73 | ||
|
|
9c0ade1329 | ||
|
|
63ccc8b4c8 | ||
|
|
7cf7df88db | ||
|
|
bded539122 | ||
|
|
3647bbd68a | ||
|
|
fb085ffdf7 | ||
|
|
280d483ef4 | ||
|
|
54b55b98f1 | ||
|
|
84f9881812 | ||
|
|
db6baf2e6b | ||
|
|
bb1e6f4356 | ||
|
|
5626d3048b | ||
|
|
058d0276e2 | ||
|
|
37b046c047 | ||
|
|
846654b449 | ||
|
|
b676dc0987 | ||
|
|
b1ce901555 | ||
|
|
62e5730965 | ||
|
|
01f13292bb | ||
|
|
7b0458db72 | ||
|
|
3aab7121f7 | ||
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 |
19
.devcontainer/Dockerfile
Executable file
19
.devcontainer/Dockerfile
Executable file
@@ -0,0 +1,19 @@
|
||||
FROM node:20
|
||||
|
||||
RUN npm install typescript eslint prettier -g
|
||||
|
||||
# Install bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Install just
|
||||
WORKDIR /usr/bin
|
||||
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
RUN chmod +x ./just
|
||||
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
|
||||
WORKDIR /nostr-tools
|
||||
ENV LANG C.UTF-8
|
||||
|
||||
# The run the start script
|
||||
CMD [ "/bin/bash" ]
|
||||
19
.devcontainer/devcontainer.json
Executable file
19
.devcontainer/devcontainer.json
Executable file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Nostr Tools",
|
||||
"dockerComposeFile": [
|
||||
"docker-compose.yml"
|
||||
],
|
||||
"service": "nostr-tools-dev",
|
||||
"workspaceFolder": "/nostr-tools",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"eamodio.gitlens",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"manishsencha.readme-preview",
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
.devcontainer/docker-compose.yml
Executable file
13
.devcontainer/docker-compose.yml
Executable file
@@ -0,0 +1,13 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
nostr-tools-dev:
|
||||
image: nostr-tools-dev
|
||||
container_name: nostr-tools-dev
|
||||
build:
|
||||
context: ../.
|
||||
dockerfile: ./.devcontainer/Dockerfile
|
||||
working_dir: /nostr-tools
|
||||
volumes:
|
||||
- ..:/nostr-tools:cached
|
||||
tty: true
|
||||
@@ -3,7 +3,7 @@
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -45,7 +45,6 @@
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
@@ -117,7 +116,8 @@
|
||||
"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": "off",
|
||||
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
@@ -138,6 +138,7 @@
|
||||
"valid-typeof": 2,
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": [0]
|
||||
"yoda": [0],
|
||||
"no-labels": [0]
|
||||
}
|
||||
}
|
||||
|
||||
358
README.md
358
README.md
@@ -1,34 +1,51 @@
|
||||
#  nostr-tools
|
||||
#  [](https://jsr.io/@nostr/tools) nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
Only depends on _@scure_ and _@noble_ packages.
|
||||
|
||||
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).
|
||||
This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
# npm
|
||||
npm install --save nostr-tools
|
||||
|
||||
# jsr
|
||||
npx jsr add @nostr/tools
|
||||
```
|
||||
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
## Documentation
|
||||
|
||||
https://jsr.io/@nostr/tools/doc
|
||||
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
|
||||
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
```
|
||||
|
||||
To get the secret key in hex format, use
|
||||
|
||||
```js
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
|
||||
|
||||
let skHex = bytesToHex(sk)
|
||||
let backToBytes = hexToBytes(skHex)
|
||||
```
|
||||
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||
|
||||
let event = finalizeEvent({
|
||||
kind: 1,
|
||||
@@ -40,42 +57,57 @@ let event = finalizeEvent({
|
||||
let isGood = verifyEvent(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
### Interacting with one or multiple relays
|
||||
|
||||
Doesn't matter what you do, you always should be using a `SimplePool`:
|
||||
|
||||
```js
|
||||
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const relay = await Relay.connect('wss://relay.example.com')
|
||||
console.log(`connected to ${relay.url}`)
|
||||
const pool = new SimplePool()
|
||||
|
||||
// let's query for an event that exists
|
||||
const sub = relay.subscribe([
|
||||
const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
|
||||
// let's query for one event that exists
|
||||
const event = pool.get(
|
||||
relays,
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
)
|
||||
if (event) {
|
||||
console.log('it exists indeed on this relay:', event)
|
||||
}
|
||||
|
||||
// let's query for more than one event that exists
|
||||
const events = pool.querySync(
|
||||
relays,
|
||||
{
|
||||
kinds: [1],
|
||||
limit: 10
|
||||
},
|
||||
)
|
||||
if (events) {
|
||||
console.log('it exists indeed on this relay:', events)
|
||||
}
|
||||
})
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
relay.sub([
|
||||
pool.subscribe(
|
||||
['wss://a.com', 'wss://b.com', 'wss://c.com'],
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
], {
|
||||
{
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
@@ -86,79 +118,231 @@ let eventTemplate = {
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
|
||||
|
||||
relay.close()
|
||||
```
|
||||
|
||||
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
||||
To use this on Node.js you first must install `ws` and call something like this:
|
||||
|
||||
```js
|
||||
import 'websocket-polyfill'
|
||||
import { useWebSocketImplementation } from 'nostr-tools/pool'
|
||||
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
|
||||
|
||||
import WebSocket from 'ws'
|
||||
useWebSocketImplementation(WebSocket)
|
||||
```
|
||||
|
||||
### Interacting with multiple relays
|
||||
#### enablePing
|
||||
|
||||
You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms, like Node.js, don't report websocket disconnections due to network issues, and enabling this can increase the reliability of the `onclose` event.
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const pool = new SimplePool()
|
||||
const pool = new SimplePool({ enablePing: true })
|
||||
```
|
||||
|
||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
#### enableReconnect
|
||||
|
||||
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()
|
||||
You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly.
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const pool = new SimplePool({ enableReconnect: true })
|
||||
```
|
||||
|
||||
Using both `enablePing: true` and `enableReconnect: true` is recommended as it will improve the reliability and timeliness of the reconnection (at the expense of slighly higher bandwidth due to the ping messages).
|
||||
|
||||
```js
|
||||
// on Node.js
|
||||
const pool = new SimplePool({ enablePing: true, enableReconnect: true })
|
||||
```
|
||||
|
||||
The `enableReconnect` option can also be a callback function which will receive the current subscription filters and should return a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events.
|
||||
|
||||
```js
|
||||
const pool = new SimplePool({
|
||||
enableReconnect: (filters) => {
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
return filters.map(filter => ({ ...filter, since: newSince }))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
### Parsing references (mentions) from a content based on NIP-27
|
||||
|
||||
```js
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
import * as nip27 from '@nostr/tools/nip27'
|
||||
|
||||
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)
|
||||
for (let block of nip27.parse(evt.content)) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
console.log(block.text)
|
||||
break
|
||||
case 'reference': {
|
||||
if ('id' in block.pointer) {
|
||||
console.log("it's a nevent1 uri", block.pointer)
|
||||
} else if ('identifier' in block.pointer) {
|
||||
console.log("it's a naddr1 uri", block.pointer)
|
||||
} else {
|
||||
console.log("it's an npub1 or nprofile1 uri", block.pointer)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
console.log("it's a normal url:", block.url)
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
console.log("it's a media url:", block.url)
|
||||
break
|
||||
case 'relay':
|
||||
console.log("it's a websocket url, probably a relay address:", block.url)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting to a bunker using NIP-46
|
||||
|
||||
`BunkerSigner` allows your application to request signatures and other actions from a remote NIP-46 signer, often called a "bunker". There are two primary ways to establish a connection, depending on whether the client or the bunker initiates the connection.
|
||||
|
||||
A local secret key is required for the client to communicate securely with the bunker. This key should generally be persisted for the user's session.
|
||||
|
||||
```js
|
||||
import { generateSecretKey } from '@nostr/tools/pure'
|
||||
|
||||
const localSecretKey = generateSecretKey()
|
||||
```
|
||||
|
||||
### Method 1: Using a Bunker URI (`bunker://`)
|
||||
|
||||
This is the bunker-initiated flow. Your client receives a `bunker://` string or a NIP-05 identifier from the user. You use `BunkerSigner.fromBunker()` to create an instance, which returns immediately. For the **initial connection** with a new bunker, you must explicitly call `await bunker.connect()` to establish the connection and receive authorization.
|
||||
|
||||
```js
|
||||
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
|
||||
import { SimplePool } from '@nostr/tools/pool'
|
||||
|
||||
// parse a bunker URI
|
||||
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
|
||||
if (!bunkerPointer) {
|
||||
throw new Error('Invalid bunker input')
|
||||
}
|
||||
|
||||
// create the bunker instance
|
||||
const pool = new SimplePool()
|
||||
const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
|
||||
await bunker.connect()
|
||||
|
||||
// and use it
|
||||
const pubkey = await bunker.getPublicKey()
|
||||
const event = await bunker.signEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello from bunker!'
|
||||
})
|
||||
|
||||
// cleanup
|
||||
await signer.close()
|
||||
pool.close([])
|
||||
```
|
||||
> **Note on Reconnecting:** Once a connection has been successfully established and the `BunkerPointer` is stored, you do **not** need to call `await bunker.connect()` on subsequent sessions.
|
||||
|
||||
### Method 2: Using a Client-generated URI (`nostrconnect://`)
|
||||
|
||||
This is the client-initiated flow, which generally provides a better user experience for first-time connections (e.g., via QR code). Your client generates a `nostrconnect://` URI and waits for the bunker to connect to it.
|
||||
|
||||
`BunkerSigner.fromURI()` is an **asynchronous** method. It returns a `Promise` that resolves only after the bunker has successfully connected. Therefore, the returned signer instance is already fully connected and ready to use, so you **do not** need to call `.connect()` on it.
|
||||
|
||||
```js
|
||||
import { getPublicKey } from '@nostr/tools/pure'
|
||||
import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46'
|
||||
import { SimplePool } from '@nostr/tools/pool'
|
||||
|
||||
const clientPubkey = getPublicKey(localSecretKey)
|
||||
|
||||
// generate a connection URI for the bunker to scan
|
||||
const connectionUri = createNostrConnectURI({
|
||||
clientPubkey,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.primal.net'],
|
||||
secret: 'a-random-secret-string', // A secret to verify the bunker's response
|
||||
name: 'My Awesome App'
|
||||
})
|
||||
|
||||
// wait for the bunker to connect
|
||||
const pool = new SimplePool()
|
||||
const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool })
|
||||
|
||||
// and use it
|
||||
const pubkey = await signer.getPublicKey()
|
||||
const event = await signer.signEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello from a client-initiated connection!'
|
||||
})
|
||||
|
||||
// cleanup
|
||||
await signer.close()
|
||||
pool.close([])
|
||||
```
|
||||
> **Note on Persistence:** This method is ideal for the initial sign-in. To allow users to stay logged in across sessions, you should store the connection details and use `Method 1` for subsequent reconnections.
|
||||
|
||||
### Parsing thread from any note based on NIP-10
|
||||
|
||||
```js
|
||||
import * as nip10 from '@nostr/tools/nip10'
|
||||
|
||||
// event is a nostr event with tags
|
||||
const refs = nip10.parse(event)
|
||||
|
||||
// get the root event of the thread
|
||||
if (refs.root) {
|
||||
console.log('root event:', refs.root.id)
|
||||
console.log('root event relay hints:', refs.root.relays)
|
||||
console.log('root event author:', refs.root.author)
|
||||
}
|
||||
|
||||
// get the immediate parent being replied to
|
||||
if (refs.reply) {
|
||||
console.log('reply to:', refs.reply.id)
|
||||
console.log('reply relay hints:', refs.reply.relays)
|
||||
console.log('reply author:', refs.reply.author)
|
||||
}
|
||||
|
||||
// get any mentioned events
|
||||
for (let mention of refs.mentions) {
|
||||
console.log('mentioned event:', mention.id)
|
||||
console.log('mention relay hints:', mention.relays)
|
||||
console.log('mention author:', mention.author)
|
||||
}
|
||||
|
||||
// get any quoted events
|
||||
for (let quote of refs.quotes) {
|
||||
console.log('quoted event:', quote.id)
|
||||
console.log('quote relay hints:', quote.relays)
|
||||
}
|
||||
|
||||
// get any referenced profiles
|
||||
for (let profile of refs.profiles) {
|
||||
console.log('referenced profile:', profile.pubkey)
|
||||
console.log('profile relay hints:', profile.relays)
|
||||
}
|
||||
```
|
||||
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import { nip05 } from 'nostr-tools'
|
||||
import { queryProfile } from 'nostr-tools/nip05'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
let profile = await queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||
console.log(profile.relays)
|
||||
@@ -168,13 +352,26 @@ console.log(profile.relays)
|
||||
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'))
|
||||
import { useFetchImplementation } from 'nostr-tools/nip05'
|
||||
useFetchImplementation(require('node-fetch'))
|
||||
```
|
||||
|
||||
### Including NIP-07 types
|
||||
```js
|
||||
import type { WindowNostr } from 'nostr-tools/nip07'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: WindowNostr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import * as nip19 from 'nostr-tools/nip19'
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
@@ -197,21 +394,6 @@ assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
## Import modes
|
||||
|
||||
### Using just the packages you want
|
||||
|
||||
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
|
||||
|
||||
```js
|
||||
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { Relay, Subscription } from 'nostr-tools/relay'
|
||||
import { matchFilter } from 'nostr-tools/filter'
|
||||
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
||||
// and so on and so forth
|
||||
```
|
||||
|
||||
### Using it with `nostr-wasm`
|
||||
|
||||
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||
@@ -274,3 +456,11 @@ To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just
|
||||
## License
|
||||
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
## Contributing to this repository
|
||||
|
||||
Use NIP-34 to send your patches to:
|
||||
|
||||
```
|
||||
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
|
||||
```
|
||||
|
||||
204
abstract-pool.ts
204
abstract-pool.ts
@@ -1,28 +1,53 @@
|
||||
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||
/* global WebSocket */
|
||||
|
||||
import {
|
||||
AbstractRelay as AbstractRelay,
|
||||
SubscriptionParams,
|
||||
Subscription,
|
||||
type AbstractRelayConstructorOptions,
|
||||
} from './abstract-relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event, Nostr } from './core.ts'
|
||||
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
|
||||
import { type Filter } from './filter.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
export type SubCloser = { close: () => void }
|
||||
export type SubCloser = { close: (reason?: string) => void }
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {
|
||||
// automaticallyAuth takes a relay URL and should return null
|
||||
// in case that relay shouldn't be authenticated against
|
||||
// or a function to sign the AUTH event template otherwise (that function may still throw in case of failure)
|
||||
automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
|
||||
}
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||
id?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export class AbstractSimplePool {
|
||||
private relays = new Map<string, AbstractRelay>()
|
||||
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||
protected relays: Map<string, AbstractRelay> = new Map()
|
||||
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public verifyEvent: Nostr['verifyEvent']
|
||||
public trustedRelayURLs = new Set<string>()
|
||||
public enablePing: boolean | undefined
|
||||
public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) | undefined
|
||||
public automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
|
||||
public trustedRelayURLs: Set<string> = new Set()
|
||||
|
||||
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
private _WebSocket?: typeof WebSocket
|
||||
|
||||
constructor(opts: AbstractPoolConstructorOptions) {
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation
|
||||
this.enablePing = opts.enablePing
|
||||
this.enableReconnect = opts.enableReconnect
|
||||
this.automaticallyAuth = opts.automaticallyAuth
|
||||
}
|
||||
|
||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||
@@ -32,10 +57,26 @@ export class AbstractSimplePool {
|
||||
if (!relay) {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
websocketImplementation: this._WebSocket,
|
||||
enablePing: this.enablePing,
|
||||
enableReconnect: this.enableReconnect,
|
||||
})
|
||||
relay.onclose = () => {
|
||||
if (relay && !relay.enableReconnect) {
|
||||
this.relays.delete(url)
|
||||
}
|
||||
}
|
||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||
this.relays.set(url, relay)
|
||||
}
|
||||
|
||||
if (this.automaticallyAuth) {
|
||||
const authSignerFn = this.automaticallyAuth(url)
|
||||
if (authSignerFn) {
|
||||
relay.onauth = authSignerFn
|
||||
}
|
||||
}
|
||||
|
||||
await relay.connect()
|
||||
|
||||
return relay
|
||||
@@ -44,10 +85,45 @@ export class AbstractSimplePool {
|
||||
close(relays: string[]) {
|
||||
relays.map(normalizeURL).forEach(url => {
|
||||
this.relays.get(url)?.close()
|
||||
this.relays.delete(url)
|
||||
})
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
|
||||
const request: { url: string; filter: Filter }[] = []
|
||||
for (let i = 0; i < relays.length; i++) {
|
||||
const url = normalizeURL(relays[i])
|
||||
if (!request.find(r => r.url === url)) {
|
||||
request.push({ url, filter: filter })
|
||||
}
|
||||
}
|
||||
|
||||
return this.subscribeMap(request, params)
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
|
||||
const request: { url: string; filter: Filter }[] = []
|
||||
const uniqUrls: string[] = []
|
||||
for (let i = 0; i < relays.length; i++) {
|
||||
const url = normalizeURL(relays[i])
|
||||
if (uniqUrls.indexOf(url) === -1) {
|
||||
uniqUrls.push(url)
|
||||
request.push({ url, filter: filter })
|
||||
}
|
||||
}
|
||||
|
||||
return this.subscribeMap(request, params)
|
||||
}
|
||||
|
||||
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
|
||||
const grouped = new Map<string, Filter[]>()
|
||||
for (const req of requests) {
|
||||
const { url, filter } = req
|
||||
if (!grouped.has(url)) grouped.set(url, [])
|
||||
grouped.get(url)!.push(filter)
|
||||
}
|
||||
const groupedRequests = Array.from(grouped.entries()).map(([url, filters]) => ({ url, filters }))
|
||||
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
@@ -65,8 +141,9 @@ export class AbstractSimplePool {
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||
if (eosesReceived.filter(a => a).length === groupedRequests.length) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
@@ -74,9 +151,10 @@ export class AbstractSimplePool {
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
if (closesReceived[i]) return // do not act twice for the same relay
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relays.length) {
|
||||
if (closesReceived.filter(a => a).length === groupedRequests.length) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
@@ -93,13 +171,7 @@ export class AbstractSimplePool {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
groupedRequests.map(async ({ url, filters }, i) => {
|
||||
let relay: AbstractRelay
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
@@ -113,7 +185,28 @@ export class AbstractSimplePool {
|
||||
let subscription = relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => handleClose(i, reason),
|
||||
onclose: reason => {
|
||||
if (reason.startsWith('auth-required: ') && params.onauth) {
|
||||
relay
|
||||
.auth(params.onauth)
|
||||
.then(() => {
|
||||
relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => {
|
||||
handleClose(i, reason) // the second time we won't try to auth anymore
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
|
||||
})
|
||||
} else {
|
||||
handleClose(i, reason)
|
||||
}
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
@@ -123,24 +216,38 @@ export class AbstractSimplePool {
|
||||
)
|
||||
|
||||
return {
|
||||
async close() {
|
||||
async close(reason?: string) {
|
||||
await allOpened
|
||||
subs.forEach(sub => {
|
||||
sub.close()
|
||||
sub.close(reason)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
subscribeEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
filter: Filter,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
const subcloser = this.subscribe(relays, filter, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close()
|
||||
subcloser.close('closed automatically on eose')
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filter, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close('closed automatically on eose')
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
@@ -149,11 +256,11 @@ export class AbstractSimplePool {
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
this.subscribeManyEose(relays, [filter], {
|
||||
this.subscribeEose(relays, filter, {
|
||||
...params,
|
||||
onevent(event: Event) {
|
||||
events.push(event)
|
||||
@@ -168,7 +275,7 @@ export class AbstractSimplePool {
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
@@ -176,7 +283,11 @@ export class AbstractSimplePool {
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event): Promise<string>[] {
|
||||
publish(
|
||||
relays: string[],
|
||||
event: Event,
|
||||
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
|
||||
): Promise<string>[] {
|
||||
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
@@ -184,7 +295,38 @@ export class AbstractSimplePool {
|
||||
}
|
||||
|
||||
let r = await this.ensureRelay(url)
|
||||
return r.publish(event)
|
||||
return r
|
||||
.publish(event)
|
||||
.catch(async err => {
|
||||
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
|
||||
await r.auth(options.onauth)
|
||||
return r.publish(event) // retry
|
||||
}
|
||||
throw err
|
||||
})
|
||||
.then(reason => {
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(event.id, set)
|
||||
}
|
||||
set.add(r)
|
||||
}
|
||||
return reason
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
listConnectionStatus(): Map<string, boolean> {
|
||||
const map = new Map<string, boolean>()
|
||||
this.relays.forEach((relay, url) => map.set(url, relay.connected))
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.relays.forEach(conn => conn.close())
|
||||
this.relays = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||
import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { Queue, normalizeURL } from './utils.ts'
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { yieldThread } from './helpers.ts'
|
||||
|
||||
type RelayWebSocket = WebSocket & {
|
||||
ping?(): void
|
||||
on?(event: 'pong', listener: () => void): any
|
||||
}
|
||||
|
||||
export type AbstractRelayConstructorOptions = {
|
||||
verifyEvent: Nostr['verifyEvent']
|
||||
websocketImplementation?: typeof WebSocket
|
||||
enablePing?: boolean
|
||||
enableReconnect?: boolean | ((filters: Filter[]) => Filter[])
|
||||
}
|
||||
|
||||
export class SendingOnClosedConnection extends Error {
|
||||
constructor(message: string, relay: string) {
|
||||
super(`Tried to send message '${message} on a closed connection to ${relay}.`)
|
||||
this.name = 'SendingOnClosedConnection'
|
||||
}
|
||||
}
|
||||
|
||||
export class AbstractRelay {
|
||||
public readonly url: string
|
||||
private _connected: boolean = false
|
||||
|
||||
public onclose: (() => void) | null = null
|
||||
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||
public onauth: undefined | ((evt: EventTemplate) => Promise<VerifiedEvent>)
|
||||
|
||||
public baseEoseTimeout: number = 4400
|
||||
public connectionTimeout: number = 4400
|
||||
public openSubs = new Map<string, Subscription>()
|
||||
public publishTimeout: number = 4400
|
||||
public pingFrequency: number = 20000
|
||||
public pingTimeout: number = 20000
|
||||
public resubscribeBackoff: number[] = [10000, 10000, 10000, 20000, 20000, 30000, 60000]
|
||||
public openSubs: Map<string, Subscription> = new Map()
|
||||
public enablePing: boolean | undefined
|
||||
public enableReconnect: boolean | ((filters: Filter[]) => Filter[])
|
||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private pingTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private reconnectAttempts: number = 0
|
||||
private closedIntentionally: boolean = false
|
||||
|
||||
private connectionPromise: Promise<void> | undefined
|
||||
private openCountRequests = new Map<string, CountResolver>()
|
||||
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||
private ws: WebSocket | undefined
|
||||
private ws: RelayWebSocket | undefined
|
||||
private incomingMessageQueue = new Queue<string>()
|
||||
private queueRunning = false
|
||||
private challenge: string | undefined
|
||||
private authPromise: Promise<string> | undefined
|
||||
private serial: number = 0
|
||||
private verifyEvent: Nostr['verifyEvent']
|
||||
|
||||
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
private _WebSocket: typeof WebSocket
|
||||
|
||||
constructor(url: string, opts: AbstractRelayConstructorOptions) {
|
||||
this.url = normalizeURL(url)
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation || WebSocket
|
||||
this.enablePing = opts.enablePing
|
||||
this.enableReconnect = opts.enableReconnect || false
|
||||
}
|
||||
|
||||
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
|
||||
const relay = new AbstractRelay(url, opts)
|
||||
await relay.connect()
|
||||
return relay
|
||||
@@ -61,10 +97,45 @@ export class AbstractRelay {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
const backoff = this.resubscribeBackoff[Math.min(this.reconnectAttempts, this.resubscribeBackoff.length - 1)]
|
||||
this.reconnectAttempts++
|
||||
|
||||
this.reconnectTimeoutHandle = setTimeout(async () => {
|
||||
try {
|
||||
await this.connect()
|
||||
} catch (err) {
|
||||
// this will be called again through onclose/onerror
|
||||
}
|
||||
}, backoff)
|
||||
}
|
||||
|
||||
private handleHardClose(reason: string) {
|
||||
if (this.pingTimeoutHandle) {
|
||||
clearTimeout(this.pingTimeoutHandle)
|
||||
this.pingTimeoutHandle = undefined
|
||||
}
|
||||
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
|
||||
const wasIntentional = this.closedIntentionally
|
||||
this.closedIntentionally = false // reset for next time
|
||||
|
||||
this.onclose?.()
|
||||
|
||||
if (this.enableReconnect && !wasIntentional) {
|
||||
this.reconnect()
|
||||
} else {
|
||||
this.closeAllSubscriptions(reason)
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionPromise) return this.connectionPromise
|
||||
|
||||
this.challenge = undefined
|
||||
this.authPromise = undefined
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connectionTimeoutHandle = setTimeout(() => {
|
||||
reject('connection timed out')
|
||||
@@ -74,32 +145,49 @@ export class AbstractRelay {
|
||||
}, this.connectionTimeout)
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.ws = new this._WebSocket(this.url)
|
||||
} catch (err) {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (this.reconnectTimeoutHandle) {
|
||||
clearTimeout(this.reconnectTimeoutHandle)
|
||||
this.reconnectTimeoutHandle = undefined
|
||||
}
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
this._connected = true
|
||||
|
||||
const isReconnection = this.reconnectAttempts > 0
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// resubscribe to all open subscriptions
|
||||
for (const sub of this.openSubs.values()) {
|
||||
sub.eosed = false
|
||||
if (isReconnection && typeof this.enableReconnect === 'function') {
|
||||
sub.filters = this.enableReconnect(sub.filters)
|
||||
}
|
||||
sub.fire()
|
||||
}
|
||||
|
||||
if (this.enablePing) {
|
||||
this.pingpong()
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = ev => {
|
||||
reject((ev as any).message)
|
||||
if (this._connected) {
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection errored')
|
||||
this._connected = false
|
||||
}
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject((ev as any).message || 'websocket error')
|
||||
this.handleHardClose('relay connection errored')
|
||||
}
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
this._connected = false
|
||||
this.ws.onclose = ev => {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject((ev as any).message || 'websocket closed')
|
||||
this.handleHardClose('relay connection closed')
|
||||
}
|
||||
|
||||
this.ws.onmessage = this._onmessage.bind(this)
|
||||
@@ -108,6 +196,55 @@ export class AbstractRelay {
|
||||
return this.connectionPromise
|
||||
}
|
||||
|
||||
private waitForPingPong() {
|
||||
return new Promise(resolve => {
|
||||
// listen for pong
|
||||
;(this.ws as any).once('pong', () => resolve(true))
|
||||
// send a ping
|
||||
this.ws!.ping!()
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForDummyReq() {
|
||||
return new Promise((resolve, _) => {
|
||||
// make a dummy request with expected empty eose reply
|
||||
// ["REQ", "_", {"ids":["aaaa...aaaa"], "limit": 0}]
|
||||
const sub = this.subscribe(
|
||||
[{ ids: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], limit: 0 }],
|
||||
{
|
||||
oneose: () => {
|
||||
sub.close()
|
||||
resolve(true)
|
||||
},
|
||||
eoseTimeout: this.pingTimeout + 1000,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// nodejs requires this magic here to ensure connections are closed when internet goes off and stuff
|
||||
// in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491
|
||||
private async pingpong() {
|
||||
// if the websocket is connected
|
||||
if (this.ws?.readyState === 1) {
|
||||
// wait for either a ping-pong reply or a timeout
|
||||
const result = await Promise.any([
|
||||
// browsers don't have ping so use a dummy req
|
||||
this.ws && this.ws.ping && (this.ws as any).once ? this.waitForPingPong() : this.waitForDummyReq(),
|
||||
new Promise(res => setTimeout(() => res(false), this.pingTimeout)),
|
||||
])
|
||||
if (result) {
|
||||
// schedule another pingpong
|
||||
this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency)
|
||||
} else {
|
||||
// pingpong closing socket
|
||||
if (this.ws?.readyState === this._WebSocket.OPEN) {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runQueue() {
|
||||
this.queueRunning = true
|
||||
while (true) {
|
||||
@@ -125,6 +262,7 @@ export class AbstractRelay {
|
||||
return false
|
||||
}
|
||||
|
||||
// shortcut EVENT sub
|
||||
const subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
const so = this.openSubs.get(subid as string)
|
||||
@@ -157,7 +295,7 @@ export class AbstractRelay {
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||
const event = data[2] as Event
|
||||
const event = data[2] as NostrEvent
|
||||
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
|
||||
so.onevent(event)
|
||||
}
|
||||
@@ -184,9 +322,12 @@ export class AbstractRelay {
|
||||
const ok: boolean = data[2]
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ep) {
|
||||
clearTimeout(ep.timeout)
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'CLOSED': {
|
||||
@@ -197,11 +338,20 @@ export class AbstractRelay {
|
||||
so.close(data[2] as string)
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
case 'NOTICE': {
|
||||
this.onnotice(data[1] as string)
|
||||
return
|
||||
}
|
||||
case 'AUTH': {
|
||||
this.challenge = data[1] as string
|
||||
if (this.onauth) {
|
||||
this.auth(this.onauth)
|
||||
}
|
||||
return
|
||||
}
|
||||
default: {
|
||||
const so = this.openSubs.get(data[1])
|
||||
so?.oncustom?.(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -211,26 +361,47 @@ export class AbstractRelay {
|
||||
}
|
||||
|
||||
public async send(message: string) {
|
||||
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||
if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
|
||||
|
||||
this.connectionPromise.then(() => {
|
||||
this.ws?.send(message)
|
||||
})
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
})
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
|
||||
const challenge = this.challenge
|
||||
if (!challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
if (this.authPromise) return this.authPromise
|
||||
|
||||
this.authPromise = new Promise<string>(async (resolve, reject) => {
|
||||
try {
|
||||
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
|
||||
let timeout = setTimeout(() => {
|
||||
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('auth timed out'))
|
||||
this.openEventPublishes.delete(evt.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
} catch (err) {
|
||||
console.warn('subscribe auth function failed:', err)
|
||||
}
|
||||
})
|
||||
return this.authPromise
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('publish timed out'))
|
||||
this.openEventPublishes.delete(event.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
return ret
|
||||
@@ -242,29 +413,47 @@ export class AbstractRelay {
|
||||
const ret = new Promise<number>((resolve, reject) => {
|
||||
this.openCountRequests.set(id, { resolve, reject })
|
||||
})
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
public subscribe(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
const sub = this.prepareSubscription(filters, params)
|
||||
sub.fire()
|
||||
return sub
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public prepareSubscription(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.closedIntentionally = true
|
||||
if (this.reconnectTimeoutHandle) {
|
||||
clearTimeout(this.reconnectTimeoutHandle)
|
||||
this.reconnectTimeoutHandle = undefined
|
||||
}
|
||||
if (this.pingTimeoutHandle) {
|
||||
clearTimeout(this.pingTimeoutHandle)
|
||||
this.pingTimeoutHandle = undefined
|
||||
}
|
||||
this.closeAllSubscriptions('relay connection closed by us')
|
||||
this._connected = false
|
||||
this.onclose?.()
|
||||
if (this.ws?.readyState === this._WebSocket.OPEN) {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
// this is the function assigned to this.ws.onmessage
|
||||
// it's exposed for testing and debugging purposes
|
||||
@@ -290,10 +479,15 @@ export class Subscription {
|
||||
public oneose: (() => void) | undefined
|
||||
public onclose: ((reason: string) => void) | undefined
|
||||
|
||||
// will get any messages that have this subscription id as their second item and are not default standard
|
||||
public oncustom: ((msg: string[]) => void) | undefined
|
||||
|
||||
public eoseTimeout: number
|
||||
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||
if (filters.length === 0) throw new Error("subscription can't be created with zero filters")
|
||||
|
||||
this.relay = relay
|
||||
this.filters = filters
|
||||
this.id = id
|
||||
@@ -328,10 +522,18 @@ export class Subscription {
|
||||
}
|
||||
|
||||
public close(reason: string = 'closed by caller') {
|
||||
if (!this.closed) {
|
||||
if (!this.closed && this.relay.connected) {
|
||||
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||
// otherwise this._open will be already set to false so we will skip this
|
||||
try {
|
||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||
} catch (err) {
|
||||
if (err instanceof SendingOnClosedConnection) {
|
||||
/* doesn't matter, it's ok */
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
this.closed = true
|
||||
}
|
||||
this.relay.openSubs.delete(this.id)
|
||||
@@ -356,4 +558,5 @@ export type CountResolver = {
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { run, bench, group, baseline } from 'mitata'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
import { NostrEvent } from './core'
|
||||
import { finalizeEvent, generateSecretKey } from './pure'
|
||||
import { setNostrWasm, verifyEvent } from './wasm'
|
||||
import { NostrEvent } from './core.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
import { setNostrWasm, verifyEvent } from './wasm.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
import { Relay as PureRelay } from './relay.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
1
build.js
1
build.js
@@ -7,7 +7,6 @@ const entryPoints = fs
|
||||
.filter(
|
||||
file =>
|
||||
file.endsWith('.ts') &&
|
||||
file !== 'core.ts' &&
|
||||
file !== 'test-helpers.ts' &&
|
||||
file !== 'helpers.ts' &&
|
||||
file !== 'benchmarks.ts' &&
|
||||
|
||||
305
core.test.ts
305
core.test.ts
@@ -1,293 +1,18 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { sortEvents } from './core.ts'
|
||||
|
||||
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('sortEvents', () => {
|
||||
const events = [
|
||||
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||
]
|
||||
|
||||
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)
|
||||
})
|
||||
const sortedEvents = sortEvents(events)
|
||||
|
||||
expect(sortedEvents).toEqual([
|
||||
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||
])
|
||||
})
|
||||
|
||||
16
core.ts
16
core.ts
@@ -43,9 +43,23 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||
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
|
||||
if (typeof tag[j] !== 'string') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort events in reverse-chronological order by the `created_at` timestamp,
|
||||
* and then by the event `id` (lexicographically) in case of ties.
|
||||
* This mutates the array.
|
||||
*/
|
||||
export function sortEvents(events: Event[]): Event[] {
|
||||
return events.sort((a: NostrEvent, b: NostrEvent): number => {
|
||||
if (a.created_at !== b.created_at) {
|
||||
return b.created_at - a.created_at
|
||||
}
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
@@ -13,7 +13,6 @@ describe('Filter', () => {
|
||||
until: 200,
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
@@ -21,39 +20,21 @@ describe('Filter', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -154,25 +135,8 @@ describe('Filter', () => {
|
||||
{ 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)
|
||||
})
|
||||
|
||||
@@ -201,11 +165,8 @@ describe('Filter', () => {
|
||||
{ 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)
|
||||
})
|
||||
|
||||
@@ -221,9 +182,7 @@ describe('Filter', () => {
|
||||
pubkey: 'def',
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -241,4 +200,41 @@ describe('Filter', () => {
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilterLimit', () => {
|
||||
test('should handle ids', () => {
|
||||
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||
})
|
||||
|
||||
test('should count the authors times replaceable kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||
})
|
||||
|
||||
test('should handle parameterized replaceable events', () => {
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
|
||||
expect(
|
||||
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
|
||||
).toEqual(8)
|
||||
})
|
||||
|
||||
test('should return Infinity for authors with regular kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('should return Infinity for empty filters', () => {
|
||||
expect(getFilterLimit({})).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('empty tags return 0', () => {
|
||||
expect(getFilterLimit({ '#p': [] })).toEqual(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
43
filter.ts
43
filter.ts
@@ -1,4 +1,5 @@
|
||||
import { Event } from './core.ts'
|
||||
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
@@ -13,16 +14,14 @@ export type Filter = {
|
||||
|
||||
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.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] === '#') {
|
||||
@@ -40,7 +39,9 @@ export function matchFilter(filter: Filter, event: Event): boolean {
|
||||
|
||||
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
if (matchFilter(filters[i], event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -70,3 +71,35 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the intrinsic limit of a filter.
|
||||
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
|
||||
*/
|
||||
export function getFilterLimit(filter: Filter): number {
|
||||
if (filter.ids && !filter.ids.length) return 0
|
||||
if (filter.kinds && !filter.kinds.length) return 0
|
||||
if (filter.authors && !filter.authors.length) return 0
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
// The `limit` property creates an artificial limit.
|
||||
Math.max(0, filter.limit ?? Infinity),
|
||||
|
||||
// There can only be one event per `id`.
|
||||
filter.ids?.length ?? Infinity,
|
||||
|
||||
// Replaceable events are limited by the number of authors and kinds.
|
||||
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||
? filter.authors.length * filter.kinds.length
|
||||
: Infinity,
|
||||
|
||||
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
|
||||
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
|
||||
? filter.authors.length * filter.kinds.length * filter['#d'].length
|
||||
: Infinity,
|
||||
)
|
||||
}
|
||||
|
||||
19
helpers.ts
19
helpers.ts
@@ -1,7 +1,10 @@
|
||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||
|
||||
export async function yieldThread() {
|
||||
return new Promise<void>(resolve => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
// Check if MessageChannel is available
|
||||
if (typeof MessageChannel !== 'undefined') {
|
||||
const ch = new MessageChannel()
|
||||
const handler = () => {
|
||||
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||
@@ -12,6 +15,20 @@ export async function yieldThread() {
|
||||
ch.port1.addEventListener('message', handler)
|
||||
ch.port2.postMessage(0)
|
||||
ch.port1.start()
|
||||
} else {
|
||||
if (typeof setImmediate !== 'undefined') {
|
||||
setImmediate(resolve)
|
||||
} else if (typeof setTimeout !== 'undefined') {
|
||||
setTimeout(resolve, 0)
|
||||
} else {
|
||||
// Last resort - resolve immediately
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('during yield: ', e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
8
index.ts
8
index.ts
@@ -1,7 +1,7 @@
|
||||
export * from './pure.ts'
|
||||
export * from './relay.ts'
|
||||
export { Relay } from './relay.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export { SimplePool } from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04.ts'
|
||||
@@ -9,6 +9,7 @@ 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 nip17 from './nip17.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
@@ -20,7 +21,10 @@ export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip54 from './nip54.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip59 from './nip59.ts'
|
||||
export * as nip77 from './nip77.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as kinds from './kinds.ts'
|
||||
|
||||
50
jsr.json
Normal file
50
jsr.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@nostr/tools",
|
||||
"version": "2.19.0",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./core": "./core.ts",
|
||||
"./pure": "./pure.ts",
|
||||
"./wasm": "./wasm.ts",
|
||||
"./kinds": "./kinds.ts",
|
||||
"./filter": "./filter.ts",
|
||||
"./abstract-relay": "./abstract-relay.ts",
|
||||
"./relay": "./relay.ts",
|
||||
"./abstract-pool": "./abstract-pool.ts",
|
||||
"./pool": "./pool.ts",
|
||||
"./references": "./references.ts",
|
||||
"./nip04": "./nip04.ts",
|
||||
"./nip05": "./nip05.ts",
|
||||
"./nip06": "./nip06.ts",
|
||||
"./nip07": "./nip07.ts",
|
||||
"./nip10": "./nip10.ts",
|
||||
"./nip11": "./nip11.ts",
|
||||
"./nip13": "./nip13.ts",
|
||||
"./nip17": "./nip17.ts",
|
||||
"./nip18": "./nip18.ts",
|
||||
"./nip19": "./nip19.ts",
|
||||
"./nip21": "./nip21.ts",
|
||||
"./nip25": "./nip25.ts",
|
||||
"./nip27": "./nip27.ts",
|
||||
"./nip28": "./nip28.ts",
|
||||
"./nip29": "./nip29.ts",
|
||||
"./nip30": "./nip30.ts",
|
||||
"./nip39": "./nip39.ts",
|
||||
"./nip42": "./nip42.ts",
|
||||
"./nip44": "./nip44.ts",
|
||||
"./nip46": "./nip46.ts",
|
||||
"./nip49": "./nip49.ts",
|
||||
"./nip54": "./nip54.ts",
|
||||
"./nip57": "./nip57.ts",
|
||||
"./nip58": "./nip58.ts",
|
||||
"./nip59": "./nip59.ts",
|
||||
"./nip75": "./nip75.ts",
|
||||
"./nip94": "./nip94.ts",
|
||||
"./nip98": "./nip98.ts",
|
||||
"./nip99": "./nip99.ts",
|
||||
"./nipb7": "./nipb7.ts",
|
||||
"./fakejson": "./fakejson.ts",
|
||||
"./utils": "./utils.ts",
|
||||
"./signer": "./signer.ts"
|
||||
}
|
||||
}
|
||||
10
justfile
10
justfile
@@ -3,6 +3,7 @@ export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
build:
|
||||
rm -rf lib
|
||||
bun run build.js
|
||||
tsc
|
||||
|
||||
test:
|
||||
bun test --timeout 20000
|
||||
@@ -10,10 +11,13 @@ test:
|
||||
test-only file:
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
publish: build
|
||||
# publish to jsr first because it is more strict and will catch some errors
|
||||
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
|
||||
jsr publish --allow-dirty
|
||||
git checkout -- package.json
|
||||
|
||||
publish: build emit-types
|
||||
# then to npm
|
||||
npm publish
|
||||
|
||||
format:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { classifyKind } from './kinds.ts'
|
||||
import { expect, test } from 'bun:test'
|
||||
import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
test('kind classification', () => {
|
||||
expect(classifyKind(1)).toBe('regular')
|
||||
@@ -17,5 +18,24 @@ test('kind classification', () => {
|
||||
expect(classifyKind(30000)).toBe('parameterized')
|
||||
expect(classifyKind(39999)).toBe('parameterized')
|
||||
expect(classifyKind(40000)).toBe('unknown')
|
||||
expect(classifyKind(255)).toBe('unknown')
|
||||
expect(classifyKind(255)).toBe('regular')
|
||||
})
|
||||
|
||||
test('kind type guard', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
|
||||
expect(isKind(repostedEvent, Repost)).toBeFalse()
|
||||
})
|
||||
|
||||
130
kinds.ts
130
kinds.ts
@@ -1,20 +1,22 @@
|
||||
import { NostrEvent, validateEvent } from './pure.ts'
|
||||
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
export function isRegularKind(kind: number) {
|
||||
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||
export function isRegularKind(kind: number): boolean {
|
||||
return kind < 10000 && kind !== 0 && kind !== 3
|
||||
}
|
||||
|
||||
/** 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)
|
||||
export function isReplaceableKind(kind: number): boolean {
|
||||
return kind === 0 || kind === 3 || (10000 <= kind && kind < 20000)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
export function isEphemeralKind(kind: number) {
|
||||
export function isEphemeralKind(kind: number): boolean {
|
||||
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) {
|
||||
/** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isAddressableKind(kind: number): boolean {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
@@ -26,80 +28,166 @@ 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'
|
||||
if (isAddressableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
|
||||
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
|
||||
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
|
||||
}
|
||||
|
||||
export const Metadata = 0
|
||||
export type Metadata = typeof Metadata
|
||||
export const ShortTextNote = 1
|
||||
export type ShortTextNote = typeof ShortTextNote
|
||||
export const RecommendRelay = 2
|
||||
export type RecommendRelay = typeof RecommendRelay
|
||||
export const Contacts = 3
|
||||
export type Contacts = typeof Contacts
|
||||
export const EncryptedDirectMessage = 4
|
||||
export type EncryptedDirectMessage = typeof EncryptedDirectMessage
|
||||
export const EventDeletion = 5
|
||||
export type EventDeletion = typeof EventDeletion
|
||||
export const Repost = 6
|
||||
export type Repost = typeof Repost
|
||||
export const Reaction = 7
|
||||
export type Reaction = typeof Reaction
|
||||
export const BadgeAward = 8
|
||||
export 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 type BadgeAward = typeof BadgeAward
|
||||
export const Seal = 13
|
||||
export type Seal = typeof Seal
|
||||
export const PrivateDirectMessage = 14
|
||||
export type PrivateDirectMessage = typeof PrivateDirectMessage
|
||||
export const GenericRepost = 16
|
||||
export type GenericRepost = typeof GenericRepost
|
||||
export const ChannelCreation = 40
|
||||
export type ChannelCreation = typeof ChannelCreation
|
||||
export const ChannelMetadata = 41
|
||||
export type ChannelMetadata = typeof ChannelMetadata
|
||||
export const ChannelMessage = 42
|
||||
export type ChannelMessage = typeof ChannelMessage
|
||||
export const ChannelHideMessage = 43
|
||||
export type ChannelHideMessage = typeof ChannelHideMessage
|
||||
export const ChannelMuteUser = 44
|
||||
export type ChannelMuteUser = typeof ChannelMuteUser
|
||||
export const OpenTimestamps = 1040
|
||||
export type OpenTimestamps = typeof OpenTimestamps
|
||||
export const GiftWrap = 1059
|
||||
export type GiftWrap = typeof GiftWrap
|
||||
export const FileMetadata = 1063
|
||||
export type FileMetadata = typeof FileMetadata
|
||||
export const LiveChatMessage = 1311
|
||||
export type LiveChatMessage = typeof LiveChatMessage
|
||||
export const ProblemTracker = 1971
|
||||
export type ProblemTracker = typeof ProblemTracker
|
||||
export const Report = 1984
|
||||
export type Report = typeof Report
|
||||
export const Reporting = 1984
|
||||
export type Reporting = typeof Reporting
|
||||
export const Label = 1985
|
||||
export type Label = typeof Label
|
||||
export const CommunityPostApproval = 4550
|
||||
export type CommunityPostApproval = typeof CommunityPostApproval
|
||||
export const JobRequest = 5999
|
||||
export type JobRequest = typeof JobRequest
|
||||
export const JobResult = 6999
|
||||
export type JobResult = typeof JobResult
|
||||
export const JobFeedback = 7000
|
||||
export type JobFeedback = typeof JobFeedback
|
||||
export const ZapGoal = 9041
|
||||
export type ZapGoal = typeof ZapGoal
|
||||
export const ZapRequest = 9734
|
||||
export type ZapRequest = typeof ZapRequest
|
||||
export const Zap = 9735
|
||||
export type Zap = typeof Zap
|
||||
export const Highlights = 9802
|
||||
export type Highlights = typeof Highlights
|
||||
export const Mutelist = 10000
|
||||
export type Mutelist = typeof Mutelist
|
||||
export const Pinlist = 10001
|
||||
export type Pinlist = typeof Pinlist
|
||||
export const RelayList = 10002
|
||||
export type RelayList = typeof RelayList
|
||||
export const BookmarkList = 10003
|
||||
export type BookmarkList = typeof BookmarkList
|
||||
export const CommunitiesList = 10004
|
||||
export type CommunitiesList = typeof CommunitiesList
|
||||
export const PublicChatsList = 10005
|
||||
export type PublicChatsList = typeof PublicChatsList
|
||||
export const BlockedRelaysList = 10006
|
||||
export type BlockedRelaysList = typeof BlockedRelaysList
|
||||
export const SearchRelaysList = 10007
|
||||
export type SearchRelaysList = typeof SearchRelaysList
|
||||
export const InterestsList = 10015
|
||||
export type InterestsList = typeof InterestsList
|
||||
export const UserEmojiList = 10030
|
||||
export type UserEmojiList = typeof UserEmojiList
|
||||
export const DirectMessageRelaysList = 10050
|
||||
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
|
||||
export const FileServerPreference = 10096
|
||||
export type FileServerPreference = typeof FileServerPreference
|
||||
export const NWCWalletInfo = 13194
|
||||
export type NWCWalletInfo = typeof NWCWalletInfo
|
||||
export const LightningPubRPC = 21000
|
||||
export type LightningPubRPC = typeof LightningPubRPC
|
||||
export const ClientAuth = 22242
|
||||
export type ClientAuth = typeof ClientAuth
|
||||
export const NWCWalletRequest = 23194
|
||||
export type NWCWalletRequest = typeof NWCWalletRequest
|
||||
export const NWCWalletResponse = 23195
|
||||
export type NWCWalletResponse = typeof NWCWalletResponse
|
||||
export const NostrConnect = 24133
|
||||
export type NostrConnect = typeof NostrConnect
|
||||
export const HTTPAuth = 27235
|
||||
export type HTTPAuth = typeof HTTPAuth
|
||||
export const Followsets = 30000
|
||||
export type Followsets = typeof Followsets
|
||||
export const Genericlists = 30001
|
||||
export type Genericlists = typeof Genericlists
|
||||
export const Relaysets = 30002
|
||||
export type Relaysets = typeof Relaysets
|
||||
export const Bookmarksets = 30003
|
||||
export type Bookmarksets = typeof Bookmarksets
|
||||
export const Curationsets = 30004
|
||||
export type Curationsets = typeof Curationsets
|
||||
export const ProfileBadges = 30008
|
||||
export type ProfileBadges = typeof ProfileBadges
|
||||
export const BadgeDefinition = 30009
|
||||
export type BadgeDefinition = typeof BadgeDefinition
|
||||
export const Interestsets = 30015
|
||||
export type Interestsets = typeof Interestsets
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
|
||||
export const LongFormArticle = 30023
|
||||
export type LongFormArticle = typeof LongFormArticle
|
||||
export const DraftLong = 30024
|
||||
export type DraftLong = typeof DraftLong
|
||||
export const Emojisets = 30030
|
||||
export type Emojisets = typeof Emojisets
|
||||
export const Application = 30078
|
||||
export type Application = typeof Application
|
||||
export const LiveEvent = 30311
|
||||
export type LiveEvent = typeof LiveEvent
|
||||
export const UserStatuses = 30315
|
||||
export type UserStatuses = typeof UserStatuses
|
||||
export const ClassifiedListing = 30402
|
||||
export type ClassifiedListing = typeof ClassifiedListing
|
||||
export const DraftClassifiedListing = 30403
|
||||
export type DraftClassifiedListing = typeof DraftClassifiedListing
|
||||
export const Date = 31922
|
||||
export type Date = typeof Date
|
||||
export const Time = 31923
|
||||
export type Time = typeof Time
|
||||
export const Calendar = 31924
|
||||
export type Calendar = typeof Calendar
|
||||
export const CalendarEventRSVP = 31925
|
||||
export type CalendarEventRSVP = typeof CalendarEventRSVP
|
||||
export const Handlerrecommendation = 31989
|
||||
export type Handlerrecommendation = typeof Handlerrecommendation
|
||||
export const Handlerinformation = 31990
|
||||
export type Handlerinformation = typeof Handlerinformation
|
||||
export const CommunityDefinition = 34550
|
||||
export type CommunityDefinition = typeof CommunityDefinition
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
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()
|
||||
|
||||
24
nip04.ts
24
nip04.ts
@@ -1,44 +1,38 @@
|
||||
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { cbc } from '@noble/ciphers/aes'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @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> {
|
||||
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): 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 ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
|
||||
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): 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 ciphertext = base64.decode(ctb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
return utf8Decoder.decode(plaintext)
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
|
||||
|
||||
test('validate NIP05_REGEX', () => {
|
||||
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
|
||||
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
|
||||
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
|
||||
|
||||
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
|
||||
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
|
||||
|
||||
expect(isNip05('bob@bob.com.br')).toBeTrue()
|
||||
expect(isNip05('b&b@bob.com.br')).toBeFalse()
|
||||
})
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
const fetchStub = async (url: string) => ({
|
||||
status: 200,
|
||||
async json() {
|
||||
return {
|
||||
'https://compile-error.net/.well-known/nostr.json?name=_': {
|
||||
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
|
||||
},
|
||||
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
|
||||
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
|
||||
relays: {
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
|
||||
'wss://pyramid.fiatjaf.com',
|
||||
'wss://nos.lol',
|
||||
],
|
||||
},
|
||||
},
|
||||
}[url]
|
||||
},
|
||||
})
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
useFetchImplementation(fetchStub)
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
let p2 = await queryProfile('compile-error.net')
|
||||
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
|
||||
73
nip05.ts
73
nip05.ts
@@ -1,5 +1,7 @@
|
||||
import { ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type Nip05 = `${string}@${string}`
|
||||
|
||||
/**
|
||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||
*
|
||||
@@ -7,23 +9,31 @@ import { ProfilePointer } from './nip19.ts'
|
||||
* - 1: name (optional)
|
||||
* - 2: domain
|
||||
*/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
|
||||
|
||||
var _fetch: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
} catch (_) {
|
||||
null
|
||||
}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
export function useFetchImplementation(fetchImplementation: unknown) {
|
||||
_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
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||
const res = await _fetch(url, { redirect: 'manual' })
|
||||
if (res.status !== 200) {
|
||||
throw Error('Wrong response code')
|
||||
}
|
||||
const json = await res.json()
|
||||
return json.names
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
@@ -33,49 +43,24 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
|
||||
const match = fullname.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
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 url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await _fetch(url, { redirect: 'manual' })
|
||||
if (res.status !== 200) {
|
||||
throw Error('Wrong response code')
|
||||
}
|
||||
const json = await res.json()
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
const pubkey = json.names[name]
|
||||
return pubkey ? { pubkey, relays: json.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
|
||||
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
|
||||
const res = await queryProfile(nip05)
|
||||
return res ? res.pubkey === pubkey : false
|
||||
}
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
import {
|
||||
privateKeyFromSeedWords,
|
||||
accountFromSeedWords,
|
||||
extendedKeysFromSeedWords,
|
||||
accountFromExtendedKey,
|
||||
} from './nip06.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||
expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||
})
|
||||
|
||||
test('generate private and public key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
|
||||
})
|
||||
|
||||
test('generate extended keys from mnemonic', () => {
|
||||
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
const passphrase = ''
|
||||
const extendedAccountIndex = 0
|
||||
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
|
||||
mnemonic,
|
||||
passphrase,
|
||||
extendedAccountIndex,
|
||||
)
|
||||
|
||||
expect(privateExtendedKey).toBe(
|
||||
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
|
||||
)
|
||||
expect(publicExtendedKey).toBe(
|
||||
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
|
||||
)
|
||||
})
|
||||
|
||||
test('generate account from extended private key', () => {
|
||||
const xprv =
|
||||
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
|
||||
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
|
||||
|
||||
expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
|
||||
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||
})
|
||||
|
||||
test('generate account from extended public key', () => {
|
||||
const xpub =
|
||||
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
|
||||
const { publicKey } = accountFromExtendedKey(xpub)
|
||||
|
||||
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||
})
|
||||
|
||||
62
nip06.ts
62
nip06.ts
@@ -3,11 +3,67 @@ 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 {
|
||||
const DERIVATION_PATH = `m/44'/1237'`
|
||||
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return bytesToHex(privateKey)
|
||||
return privateKey
|
||||
}
|
||||
|
||||
export function accountFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string,
|
||||
accountIndex = 0,
|
||||
): {
|
||||
privateKey: Uint8Array
|
||||
publicKey: string
|
||||
} {
|
||||
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
|
||||
const publicKey = bytesToHex(seed.publicKey!.slice(1))
|
||||
const privateKey = seed.privateKey
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('could not derive key pair')
|
||||
}
|
||||
return { privateKey, publicKey }
|
||||
}
|
||||
|
||||
export function extendedKeysFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string,
|
||||
extendedAccountIndex = 0,
|
||||
): {
|
||||
privateExtendedKey: string
|
||||
publicExtendedKey: string
|
||||
} {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
|
||||
let privateExtendedKey = seed.privateExtendedKey
|
||||
let publicExtendedKey = seed.publicExtendedKey
|
||||
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
|
||||
return { privateExtendedKey, publicExtendedKey }
|
||||
}
|
||||
|
||||
export function accountFromExtendedKey(
|
||||
base58key: string,
|
||||
accountIndex = 0,
|
||||
): {
|
||||
privateKey?: Uint8Array
|
||||
publicKey: string
|
||||
} {
|
||||
let extendedKey = HDKey.fromExtendedKey(base58key)
|
||||
let version = base58key.slice(0, 4)
|
||||
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
|
||||
let publicKey = bytesToHex(child.publicKey!.slice(1))
|
||||
if (!publicKey) throw new Error('could not derive public key')
|
||||
if (version === 'xprv') {
|
||||
let privateKey = child.privateKey!
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return { privateKey, publicKey }
|
||||
}
|
||||
return { publicKey }
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
|
||||
14
nip07.ts
Normal file
14
nip07.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EventTemplate, VerifiedEvent } from './core.ts'
|
||||
|
||||
export interface WindowNostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
nip44?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
}
|
||||
185
nip10.test.ts
185
nip10.test.ts
@@ -5,20 +5,21 @@ 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'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
@@ -55,35 +56,82 @@ describe('parse NIP10-referenced events', () => {
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
root: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
test('modern', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('modern, inverted, author hint', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
'wss://banana.com',
|
||||
'root',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
},
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
test('1 event, relay hint from author', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
|
||||
[
|
||||
'e',
|
||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
'',
|
||||
'root',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
],
|
||||
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: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
root: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
test('many p 1 reply', () => {
|
||||
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'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
[
|
||||
'e',
|
||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
'wss://relay.mostr.pub',
|
||||
'reply',
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
124
nip10.ts
124
nip10.ts
@@ -1,7 +1,7 @@
|
||||
import type { Event } from './core.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
export function parse(event: Pick<Event, 'tags'>): {
|
||||
/**
|
||||
* Pointer to the root of the thread.
|
||||
*/
|
||||
@@ -13,52 +13,49 @@ export type NIP10Result = {
|
||||
reply: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointers to events which may or may not be in the reply chain.
|
||||
* Pointers to events that may or may not be in the reply chain.
|
||||
*/
|
||||
mentions: EventPointer[]
|
||||
|
||||
/**
|
||||
* Pointers to events that were directly quoted.
|
||||
*/
|
||||
quotes: 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 = {
|
||||
} {
|
||||
const result: ReturnType<typeof parse> = {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: [],
|
||||
quotes: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
let maybeParent: EventPointer | undefined
|
||||
let maybeRoot: EventPointer | undefined
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0; i--) {
|
||||
const tag = event.tags[i]
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
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 [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
|
||||
string,
|
||||
string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
author: eTagAuthor,
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
const isLastETag = eTagIndex === eTags.length - 1
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
@@ -74,18 +71,77 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFirstETag) {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLastETag) {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
if (!maybeParent) {
|
||||
maybeParent = eventPointer
|
||||
} else {
|
||||
maybeRoot = eventPointer
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (tag[0] === 'q' && tag[1]) {
|
||||
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
|
||||
result.quotes.push({
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
})
|
||||
}
|
||||
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
result.profiles.push({
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
|
||||
if (!result.root) {
|
||||
result.root = maybeRoot || maybeParent || result.reply
|
||||
}
|
||||
if (!result.reply) {
|
||||
result.reply = maybeParent || result.root
|
||||
}
|
||||
|
||||
// remove root and reply from mentions, inherit relay hints from authors if any
|
||||
;[result.reply, result.root].forEach(ref => {
|
||||
if (!ref) return
|
||||
|
||||
let idx = result.mentions.indexOf(ref)
|
||||
if (idx !== -1) {
|
||||
result.mentions.splice(idx, 1)
|
||||
}
|
||||
if (ref.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.mentions.forEach(ref => {
|
||||
if (ref!.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11.ts'
|
||||
|
||||
// TODO: replace with a mock
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
test('testing a relay', async () => {
|
||||
const info = await fetchRelayInformation('wss://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')
|
||||
const info = await fetchRelayInformation('wss://nos.lol')
|
||||
expect(info.name).toEqual('nos.lol')
|
||||
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
|
||||
expect(info.supported_nips).toContain(1)
|
||||
expect(info.supported_nips).toContain(11)
|
||||
expect(info.supported_nips).toContain(70)
|
||||
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
|
||||
})
|
||||
})
|
||||
|
||||
18
nip11.ts
18
nip11.ts
@@ -4,11 +4,11 @@ try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
export function useFetchImplementation(fetchImplementation: any): void {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function fetchRelayInformation(url: string) {
|
||||
export async function fetchRelayInformation(url: string): Promise<RelayInformation> {
|
||||
return (await (
|
||||
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||
headers: { Accept: 'application/nostr+json' },
|
||||
@@ -68,7 +68,7 @@ export interface BasicRelayInformation {
|
||||
* 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
|
||||
* @param max_subscriptions total number of subscriptions
|
||||
* that may be active on a single websocket connection to
|
||||
* this relay. It's possible that authenticated clients with
|
||||
* a (paid) relationship to the relay may have higher limits.
|
||||
@@ -101,12 +101,17 @@ export interface BasicRelayInformation {
|
||||
* authentication to happen before a new connection may
|
||||
* perform any other action. Even if set to False,
|
||||
* authentication may be required for specific actions.
|
||||
* @param restricted_writes: this relay requires some kind
|
||||
* of condition to be fulfilled in order to accept events
|
||||
* (not necessarily, but including
|
||||
* @param payment_required this relay requires payment
|
||||
* before a new connection may perform any action.
|
||||
* @param created_at_lower_limit: 'created_at' lower limit
|
||||
* @param created_at_upper_limit: 'created_at' upper limit
|
||||
*/
|
||||
export interface Limitations {
|
||||
max_message_length: number
|
||||
max_subscription: number
|
||||
max_subscriptions: number
|
||||
max_filters: number
|
||||
max_limit: number
|
||||
max_subid_length: number
|
||||
@@ -116,9 +121,12 @@ export interface Limitations {
|
||||
min_pow_difficulty: number
|
||||
auth_required: boolean
|
||||
payment_required: boolean
|
||||
created_at_lower_limit: number
|
||||
created_at_upper_limit: number
|
||||
restricted_writes: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
export interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
|
||||
@@ -2,9 +2,14 @@ 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)
|
||||
;[
|
||||
['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
|
||||
['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
|
||||
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
|
||||
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
|
||||
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
|
||||
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
|
||||
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
|
||||
})
|
||||
|
||||
test('mines POW for an event', async () => {
|
||||
|
||||
24
nip13.ts
24
nip13.ts
@@ -1,15 +1,19 @@
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { type UnsignedEvent, type Event } from './pure.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(hex: string): number {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
for (let i = 0; i < 64; i += 8) {
|
||||
const nibble = parseInt(hex.substring(i, i + 8), 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
count += 32
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
count += Math.clz32(nibble)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -20,8 +24,6 @@ export function getPow(hex: string): number {
|
||||
/**
|
||||
* 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
|
||||
@@ -41,7 +43,7 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
|
||||
|
||||
tag[1] = (++count).toString()
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.id = fastEventHash(event)
|
||||
|
||||
if (getPow(event.id) >= difficulty) {
|
||||
break
|
||||
@@ -50,3 +52,9 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export function fastEventHash(evt: UnsignedEvent): string {
|
||||
return bytesToHex(
|
||||
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
|
||||
)
|
||||
}
|
||||
|
||||
97
nip17.test.ts
Normal file
97
nip17.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { getPublicKey } from './pure.ts'
|
||||
import { decode } from './nip19.ts'
|
||||
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
|
||||
|
||||
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
|
||||
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
|
||||
|
||||
const recipients = [
|
||||
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
|
||||
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
|
||||
]
|
||||
const message = 'Hello, this is a direct message!'
|
||||
const conversationTitle = 'Private Group Conversation' // Optional
|
||||
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
|
||||
|
||||
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
|
||||
|
||||
test('wrapEvent', () => {
|
||||
const expected = {
|
||||
content: '',
|
||||
id: '',
|
||||
created_at: 1728537932,
|
||||
kind: 1059,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||
[Symbol('verified')]: true,
|
||||
}
|
||||
|
||||
expect(wrappedEvent.kind).toEqual(expected.kind)
|
||||
expect(wrappedEvent.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('wrapManyEvents', () => {
|
||||
const expected = [
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729581521,
|
||||
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729594619,
|
||||
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729560014,
|
||||
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
]
|
||||
|
||||
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
|
||||
|
||||
wrappedEvents.forEach((event, index) => {
|
||||
expect(event.kind).toEqual(expected[index].kind)
|
||||
expect(event.tags).toEqual(expected[index].tags)
|
||||
})
|
||||
})
|
||||
|
||||
test('unwrapEvent', () => {
|
||||
const expected = {
|
||||
kind: 14,
|
||||
content: 'Hello, this is a direct message!',
|
||||
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||
tags: [
|
||||
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
|
||||
['e', 'previousEventId123', '', 'reply'],
|
||||
['subject', 'Private Group Conversation'],
|
||||
],
|
||||
}
|
||||
const result = unwrapEvent(wrappedEvent, sk1)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.content).toEqual(expected.content)
|
||||
expect(result.pubkey).toEqual(expected.pubkey)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
77
nip17.ts
Normal file
77
nip17.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PrivateDirectMessage } from './kinds.ts'
|
||||
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
|
||||
import * as nip59 from './nip59.ts'
|
||||
|
||||
type Recipient = {
|
||||
publicKey: string
|
||||
relayUrl?: string
|
||||
}
|
||||
|
||||
type ReplyTo = {
|
||||
eventId: string
|
||||
relayUrl?: string
|
||||
}
|
||||
|
||||
function createEvent(
|
||||
recipients: Recipient | Recipient[],
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): EventTemplate {
|
||||
const baseEvent: EventTemplate = {
|
||||
created_at: Math.ceil(Date.now() / 1000),
|
||||
kind: PrivateDirectMessage,
|
||||
tags: [],
|
||||
content: message,
|
||||
}
|
||||
|
||||
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
|
||||
|
||||
recipientsArray.forEach(({ publicKey, relayUrl }) => {
|
||||
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
|
||||
})
|
||||
|
||||
if (replyTo) {
|
||||
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
|
||||
}
|
||||
|
||||
if (conversationTitle) {
|
||||
baseEvent.tags.push(['subject', conversationTitle])
|
||||
}
|
||||
|
||||
return baseEvent
|
||||
}
|
||||
|
||||
export function wrapEvent(
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipient: Recipient,
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): NostrEvent {
|
||||
const event = createEvent(recipient, message, conversationTitle, replyTo)
|
||||
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
|
||||
}
|
||||
|
||||
export function wrapManyEvents(
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipients: Recipient[],
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): NostrEvent[] {
|
||||
if (!recipients || recipients.length === 0) {
|
||||
throw new Error('At least one recipient is required.')
|
||||
}
|
||||
|
||||
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||
|
||||
// wrap the event for the sender and then for each recipient
|
||||
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
|
||||
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
|
||||
)
|
||||
}
|
||||
|
||||
export const unwrapEvent = nip59.unwrapEvent
|
||||
|
||||
export const unwrapManyEvents = nip59.unwrapManyEvents
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericRepost', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: 1617932114,
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
|
||||
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
|
||||
test('should create a generic reposted event', () => {
|
||||
const template = { created_at: 1617932115 }
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(GenericRepost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
['k', '30009'],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishRepostEvent', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
test('should create an event with empty content if the reposted event is protected', () => {
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [['-']],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
21
nip18.ts
21
nip18.ts
@@ -1,6 +1,6 @@
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
@@ -25,11 +25,20 @@ export function finishRepostEvent(
|
||||
relayUrl: string,
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
let kind: Repost | GenericRepost
|
||||
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
|
||||
if (reposted.kind === ShortTextNote) {
|
||||
kind = Repost
|
||||
} else {
|
||||
kind = GenericRepost
|
||||
tags.push(['k', String(reposted.kind)])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
kind,
|
||||
tags,
|
||||
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
@@ -37,7 +46,7 @@ export function finishRepostEvent(
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
if (![Repost, GenericRepost].includes(event.kind)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
165
nip19.test.ts
165
nip19.test.ts
@@ -1,17 +1,15 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
// prettier-ignore
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
neventEncode,
|
||||
NostrTypeGuard,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
nsecEncode
|
||||
} from './nip19.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generateSecretKey()
|
||||
@@ -38,7 +36,7 @@ test('encode and decode nprofile', () => {
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
@@ -67,7 +65,7 @@ test('encode and decode naddr', () => {
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
@@ -86,7 +84,7 @@ test('encode and decode nevent', () => {
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
const pointer = data
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
@@ -103,7 +101,7 @@ test('encode and decode nevent with kind 0', () => {
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
const pointer = data
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(0)
|
||||
@@ -121,7 +119,7 @@ test('encode and decode naddr with empty "d"', () => {
|
||||
expect(naddr).toMatch(/naddr\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.identifier).toEqual('')
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(3)
|
||||
@@ -133,7 +131,7 @@ test('decode naddr from habla.news', () => {
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
@@ -145,7 +143,7 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
@@ -153,11 +151,134 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
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)
|
||||
describe('NostrTypeGuard', () => {
|
||||
test('isNProfile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNProfile invalid nprofile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNProfile with invalid nprofile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNEvent', () => {
|
||||
const is = NostrTypeGuard.isNEvent(
|
||||
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNEvent with invalid nevent', () => {
|
||||
const is = NostrTypeGuard.isNEvent(
|
||||
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
|
||||
)
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNEvent with invalid nevent', () => {
|
||||
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNAddr', () => {
|
||||
const is = NostrTypeGuard.isNAddr(
|
||||
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNAddr with invalid nadress', () => {
|
||||
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNSec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNSec with invalid nsec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNSec with invalid nsec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNPub', () => {
|
||||
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNPub with invalid npub', () => {
|
||||
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNPub with invalid npub', () => {
|
||||
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNote', () => {
|
||||
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNote with invalid note', () => {
|
||||
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNote with invalid note', () => {
|
||||
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNcryptsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec(
|
||||
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec(
|
||||
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||
)
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
114
nip19.ts
114
nip19.ts
@@ -3,7 +3,25 @@ import { bech32 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
export type NProfile = `nprofile1${string}`
|
||||
export type NEvent = `nevent1${string}`
|
||||
export type NAddr = `naddr1${string}`
|
||||
export type NSec = `nsec1${string}`
|
||||
export type NPub = `npub1${string}`
|
||||
export type Note = `note1${string}`
|
||||
export type Ncryptsec = `ncryptsec1${string}`
|
||||
|
||||
export const NostrTypeGuard = {
|
||||
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
|
||||
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
|
||||
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
|
||||
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
|
||||
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
|
||||
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
|
||||
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
|
||||
}
|
||||
|
||||
export const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
@@ -43,29 +61,56 @@ export type AddressPointer = {
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
|
||||
try {
|
||||
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
|
||||
return decode(nip19code)
|
||||
} catch (_err) {
|
||||
return { type: 'invalid', data: null }
|
||||
}
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
export type DecodedNevent = {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
export type DecodedNprofile = {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
|
||||
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)
|
||||
export type DecodedNaddr = {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
|
||||
export type DecodedNsec = {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export type DecodedNpub = {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
|
||||
export type DecodedNote = {
|
||||
type: 'note'
|
||||
data: string
|
||||
}
|
||||
|
||||
export type DecodedResult = DecodedNevent | DecodedNprofile | DecodedNaddr | DecodedNpub | DecodedNsec | DecodedNote
|
||||
|
||||
export function decode(nip19: NEvent): DecodedNevent
|
||||
export function decode(nip19: NProfile): DecodedNprofile
|
||||
export function decode(nip19: NAddr): DecodedNaddr
|
||||
export function decode(nip19: NSec): DecodedNsec
|
||||
export function decode(nip19: NPub): DecodedNpub
|
||||
export function decode(nip19: Note): DecodedNote
|
||||
export function decode(code: string): DecodedResult
|
||||
export function decode(code: string): DecodedResult {
|
||||
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
@@ -119,16 +164,6 @@ export function decode(nip19: string): DecodeResult {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -158,15 +193,15 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
return result
|
||||
}
|
||||
|
||||
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
||||
export function nsecEncode(key: Uint8Array): NSec {
|
||||
return encodeBytes('nsec', key)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
export function npubEncode(hex: string): NPub {
|
||||
return encodeBytes('npub', hexToBytes(hex))
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
export function noteEncode(hex: string): Note {
|
||||
return encodeBytes('note', hexToBytes(hex))
|
||||
}
|
||||
|
||||
@@ -175,11 +210,11 @@ function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array):
|
||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||
}
|
||||
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
return encodeBech32(prefix, bytes)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
export function nprofileEncode(profile: ProfilePointer): NProfile {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
@@ -187,7 +222,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
export function neventEncode(event: EventPointer): NEvent {
|
||||
let kindArray
|
||||
if (event.kind !== undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
@@ -203,7 +238,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
export function naddrEncode(addr: AddressPointer): NAddr {
|
||||
let kind = new ArrayBuffer(4)
|
||||
new DataView(kind).setUint32(0, addr.kind, false)
|
||||
|
||||
@@ -216,13 +251,6 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
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[] = []
|
||||
|
||||
|
||||
30
nip21.ts
30
nip21.ts
@@ -1,7 +1,7 @@
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
import { AddressPointer, BECH32_REGEX, decode, EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
@@ -15,7 +15,31 @@ export interface NostrURI {
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
decoded:
|
||||
| {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
| {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
| {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
| {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
| {
|
||||
type: 'note'
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
|
||||
149
nip27.test.ts
149
nip27.test.ts
@@ -1,68 +1,115 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
import { parse } from './nip27.ts'
|
||||
import { NostrEvent } from './core.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...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,
|
||||
},
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||
{ type: 'text', text: ' check out my profile:' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '; and this cool image ' },
|
||||
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('second: parse content with 3 urls of different types', () => {
|
||||
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||
http://music.com/song.mp3
|
||||
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: ':' },
|
||||
{ type: 'relay', url: 'wss://oa.ao/' },
|
||||
{ type: 'text', text: "; this was a relay and now here's a video -> " },
|
||||
{ type: 'video', url: 'https://videos.com/video.mp4' },
|
||||
{ type: 'text', text: '! and some music:\n' },
|
||||
{ type: 'audio', url: 'http://music.com/song.mp3' },
|
||||
{ type: 'text', text: '\nand a regular link: ' },
|
||||
{ type: 'url', url: 'https://regular.com/page?ok=true' },
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
type: 'text',
|
||||
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
|
||||
},
|
||||
{ type: 'url', url: 'https://ok.com/' },
|
||||
{ type: 'text', text: '!' },
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
|
||||
with a video https://example.com/vid.webm and finally https://example.com/docs`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: 'Look at these profiles ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: ' ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||
relays: ['wss://qwieu.com'],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' check this event ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
|
||||
relays: ['wss://zjbdksa.aswjdkn'],
|
||||
author: undefined,
|
||||
kind: undefined,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: "\n here's an image " },
|
||||
{ type: 'image', url: 'https://example.com/pic.png' },
|
||||
{ type: 'text', text: ' and another profile ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '\n with a video ' },
|
||||
{ type: 'video', url: 'https://example.com/vid.webm' },
|
||||
{ type: 'text', text: ' and finally ' },
|
||||
{ type: 'url', url: 'https://example.com/docs' },
|
||||
])
|
||||
})
|
||||
|
||||
test('parse content with hashtags and emoji shortcodes', () => {
|
||||
const event: NostrEvent = {
|
||||
kind: 1,
|
||||
tags: [
|
||||
['emoji', 'star', 'https://example.com/star.png'],
|
||||
['emoji', 'alpaca', 'https://example.com/alpaca.png'],
|
||||
],
|
||||
content:
|
||||
'hey nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out :alpaca::alpaca: #alpaca at wss://alpaca.com! :star:',
|
||||
created_at: 1234567890,
|
||||
pubkey: 'dummy',
|
||||
id: 'dummy',
|
||||
sig: 'dummy',
|
||||
}
|
||||
const blocks = Array.from(parse(event))
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: 'hey ' },
|
||||
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||
{ type: 'text', text: ' check out ' },
|
||||
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
|
||||
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'hashtag', value: 'alpaca' },
|
||||
{ type: 'text', text: ' at ' },
|
||||
{ type: 'relay', url: 'wss://alpaca.com/' },
|
||||
{ type: 'text', text: '! ' },
|
||||
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
test('emoji shortcodes are treated as text if no event tags', () => {
|
||||
const blocks = Array.from(parse('hello :alpaca:'))
|
||||
|
||||
expect(blocks).toEqual([{ type: 'text', text: 'hello :alpaca:' }])
|
||||
})
|
||||
|
||||
251
nip27.ts
251
nip27.ts
@@ -1,63 +1,212 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
import { NostrEvent } from './core.ts'
|
||||
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.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
|
||||
export type Block =
|
||||
| {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: 'reference'
|
||||
pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'relay'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'image'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'audio'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'emoji'
|
||||
shortcode: string
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'hashtag'
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
const noCharacter = /\W/m
|
||||
const noURLCharacter = /\W |\W$|$|,| /m
|
||||
const MAX_HASHTAG_LENGTH = 42
|
||||
|
||||
for (const match of matches) {
|
||||
export function* parse(content: string | NostrEvent): Iterable<Block> {
|
||||
let emojis: { type: 'emoji'; shortcode: string; url: string }[] = []
|
||||
if (typeof content !== 'string') {
|
||||
for (let i = 0; i < content.tags.length; i++) {
|
||||
const tag = content.tags[i]
|
||||
if (tag[0] === 'emoji' && tag.length >= 3) {
|
||||
emojis.push({ type: 'emoji', shortcode: tag[1], url: tag[2] })
|
||||
}
|
||||
}
|
||||
content = content.content
|
||||
}
|
||||
|
||||
const max = content.length
|
||||
let prevIndex = 0
|
||||
let index = 0
|
||||
mainloop: while (index < max) {
|
||||
const u = content.indexOf(':', index)
|
||||
const h = content.indexOf('#', index)
|
||||
if (u === -1 && h === -1) {
|
||||
// reached end
|
||||
break mainloop
|
||||
}
|
||||
|
||||
if (u === -1 || (h >= 0 && h < u)) {
|
||||
// parse hashtag
|
||||
if (h === 0 || content[h - 1] === ' ') {
|
||||
const m = content.slice(h + 1, h + MAX_HASHTAG_LENGTH).match(noCharacter)
|
||||
const end = m ? h + 1 + m.index! : max
|
||||
yield { type: 'text', text: content.slice(prevIndex, h) }
|
||||
yield { type: 'hashtag', value: content.slice(h + 1, end) }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
}
|
||||
|
||||
// ignore this, it is nothing
|
||||
index = h + 1
|
||||
continue mainloop
|
||||
}
|
||||
|
||||
// otherwise parse things that have an ":"
|
||||
if (content.slice(u - 5, u) === 'nostr') {
|
||||
const m = content.slice(u + 60).match(noCharacter)
|
||||
const end = m ? u + 60 + m.index! : max
|
||||
try {
|
||||
const [uri, value] = match
|
||||
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
let { data, type } = decode(content.slice(u + 1, end))
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pointer = { pubkey: data } as ProfilePointer
|
||||
break
|
||||
case 'nsec':
|
||||
case 'note':
|
||||
// ignore this, treat it as not a valid uri
|
||||
index = end + 1
|
||||
continue
|
||||
default:
|
||||
pointer = data as any
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
|
||||
if (prevIndex !== u - 5) {
|
||||
yield { type: 'text', text: content.slice(prevIndex, u - 5) }
|
||||
}
|
||||
yield { type: 'reference', pointer }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid nostr uri
|
||||
index = u + 1
|
||||
continue mainloop
|
||||
}
|
||||
} else if (content.slice(u - 5, u) === 'https' || content.slice(u - 4, u) === 'http') {
|
||||
const m = content.slice(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 5 : 4
|
||||
try {
|
||||
let url = new URL(content.slice(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
|
||||
}
|
||||
|
||||
if (/\.(png|jpe?g|gif|webp|heic|svg)$/i.test(url.pathname)) {
|
||||
yield { type: 'image', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
}
|
||||
if (/\.(mp4|avi|webm|mkv|mov)$/i.test(url.pathname)) {
|
||||
yield { type: 'video', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
}
|
||||
if (/\.(mp3|aac|ogg|opus|wav|flac)$/i.test(url.pathname)) {
|
||||
yield { type: 'audio', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
}
|
||||
|
||||
yield { type: 'url', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue mainloop
|
||||
}
|
||||
} else if (content.slice(u - 3, u) === 'wss' || content.slice(u - 2, u) === 'ws') {
|
||||
const m = content.slice(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 3 : 2
|
||||
try {
|
||||
let url = new URL(content.slice(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid ws url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
|
||||
}
|
||||
yield { type: 'relay', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue mainloop
|
||||
}
|
||||
} else {
|
||||
// try to parse an emoji shortcode
|
||||
for (let e = 0; e < emojis.length; e++) {
|
||||
const emoji = emojis[e]
|
||||
if (
|
||||
content[u + emoji.shortcode.length + 1] === ':' &&
|
||||
content.slice(u + 1, u + emoji.shortcode.length + 1) === emoji.shortcode
|
||||
) {
|
||||
// found an emoji
|
||||
if (prevIndex !== u) {
|
||||
yield { type: 'text', text: content.slice(prevIndex, u) }
|
||||
}
|
||||
yield emoji
|
||||
index = u + emoji.shortcode.length + 2
|
||||
prevIndex = index
|
||||
continue mainloop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
})
|
||||
})
|
||||
// ignore this, it is nothing
|
||||
index = u + 1
|
||||
continue mainloop
|
||||
}
|
||||
}
|
||||
|
||||
if (prevIndex !== max) {
|
||||
yield { type: 'text', text: content.slice(prevIndex) }
|
||||
}
|
||||
}
|
||||
|
||||
10
nip28.ts
10
nip28.ts
@@ -1,5 +1,11 @@
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||
import {
|
||||
ChannelCreation,
|
||||
ChannelHideMessage,
|
||||
ChannelMessage,
|
||||
ChannelMetadata as KindChannelMetadata,
|
||||
ChannelMuteUser,
|
||||
} from './kinds.ts'
|
||||
|
||||
export interface ChannelMetadata {
|
||||
name: string
|
||||
@@ -78,7 +84,7 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMetadata,
|
||||
kind: KindChannelMetadata,
|
||||
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
|
||||
614
nip29.ts
Normal file
614
nip29.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
import { Subscription } from './abstract-relay.ts'
|
||||
import type { Event, EventTemplate } from './core.ts'
|
||||
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
|
||||
import { decode, NostrTypeGuard } from './nip19.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group.
|
||||
*/
|
||||
export type Group = {
|
||||
relay: string
|
||||
metadata: GroupMetadata
|
||||
admins?: GroupAdmin[]
|
||||
members?: GroupMember[]
|
||||
reference: GroupReference
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the metadata for a NIP29 group.
|
||||
*/
|
||||
export type GroupMetadata = {
|
||||
id: string
|
||||
pubkey: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
isPublic?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group reference.
|
||||
*/
|
||||
export type GroupReference = {
|
||||
id: string
|
||||
host: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group member.
|
||||
*/
|
||||
export type GroupMember = {
|
||||
pubkey: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group admin.
|
||||
*/
|
||||
export type GroupAdmin = {
|
||||
pubkey: string
|
||||
label?: string
|
||||
permissions: GroupAdminPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the permissions that a NIP29 group admin can have.
|
||||
*/
|
||||
export enum GroupAdminPermission {
|
||||
/** @deprecated use PutUser instead */
|
||||
AddUser = 'add-user',
|
||||
EditMetadata = 'edit-metadata',
|
||||
DeleteEvent = 'delete-event',
|
||||
RemoveUser = 'remove-user',
|
||||
/** @deprecated removed from NIP */
|
||||
AddPermission = 'add-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
RemovePermission = 'remove-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
EditGroupStatus = 'edit-group-status',
|
||||
PutUser = 'put-user',
|
||||
CreateGroup = 'create-group',
|
||||
DeleteGroup = 'delete-group',
|
||||
CreateInvite = 'create-invite',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a group metadata event template.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @returns An event template with the generated group metadata that can be signed later.
|
||||
*/
|
||||
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
group.metadata.name && tags.push(['name', group.metadata.name])
|
||||
group.metadata.picture && tags.push(['picture', group.metadata.picture])
|
||||
group.metadata.about && tags.push(['about', group.metadata.about])
|
||||
group.metadata.isPublic && tags.push(['public'])
|
||||
group.metadata.isOpen && tags.push(['open'])
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39000,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group metadata event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is valid.
|
||||
*/
|
||||
export function validateGroupMetadataEvent(event: Event): boolean {
|
||||
if (event.kind !== 39000) return false
|
||||
|
||||
if (!event.pubkey) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template for group admins.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @param admins - An array of group admins.
|
||||
* @returns The generated event template with the group admins that can be signed later.
|
||||
*/
|
||||
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
for (const admin of admins) {
|
||||
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39001,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group admins event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateGroupAdminsEvent(event: Event): boolean {
|
||||
if (event.kind !== 39001) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate permissions
|
||||
for (const [tag, _value, _label, ...permissions] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
for (let i = 0; i < permissions.length; i += 1) {
|
||||
if (typeof permissions[i] !== 'string') return false
|
||||
|
||||
// validate permission name from the GroupAdminPermission enum
|
||||
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template for a group with its members.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @param members - An array of group members.
|
||||
* @returns The generated event template with the group members that can be signed later.
|
||||
*/
|
||||
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
for (const member of members) {
|
||||
tags.push(['p', member.pubkey, member.label || ''])
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39002,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group members event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
|
||||
*/
|
||||
export function validateGroupMembersEvent(event: Event): boolean {
|
||||
if (event.kind !== 39002) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized relay URL based on the provided group reference.
|
||||
*
|
||||
* @param groupReference - The group reference object containing the host.
|
||||
* @returns The normalized relay URL.
|
||||
*/
|
||||
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
|
||||
return normalizeURL(groupReference.host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches relay information by group reference.
|
||||
*
|
||||
* @param groupReference The group reference.
|
||||
* @returns A promise that resolves to the relay information.
|
||||
*/
|
||||
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
|
||||
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
|
||||
return fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group metadata event from the specified pool.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
|
||||
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
|
||||
* @throws {Error} If the group is not found on the specified relay.
|
||||
*/
|
||||
export async function fetchGroupMetadataEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39000],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupMetadataEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group metadata event and returns the corresponding GroupMetadata object.
|
||||
*
|
||||
* @param event - The event to parse.
|
||||
* @returns The parsed GroupMetadata object.
|
||||
* @throws An error if the group metadata event is invalid.
|
||||
*/
|
||||
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
|
||||
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
|
||||
|
||||
const metadata: GroupMetadata = {
|
||||
id: '',
|
||||
pubkey: event.pubkey,
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'd':
|
||||
metadata.id = value
|
||||
break
|
||||
case 'name':
|
||||
metadata.name = value
|
||||
break
|
||||
case 'picture':
|
||||
metadata.picture = value
|
||||
break
|
||||
case 'about':
|
||||
metadata.about = value
|
||||
break
|
||||
case 'public':
|
||||
metadata.isPublic = true
|
||||
break
|
||||
case 'open':
|
||||
metadata.isOpen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group admins event from the specified pool.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
|
||||
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information.
|
||||
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
|
||||
* @throws {Error} If the group admins event is not found on the specified relay.
|
||||
*/
|
||||
export async function fetchGroupAdminsEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39001],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupAdminsEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group admins event and returns an array of GroupAdmin objects.
|
||||
*
|
||||
* @param event - The event to parse.
|
||||
* @returns An array of GroupAdmin objects.
|
||||
* @throws Throws an error if the group admins event is invalid.
|
||||
*/
|
||||
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
|
||||
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
|
||||
|
||||
const admins: GroupAdmin[] = []
|
||||
|
||||
for (const [tag, value, label, ...permissions] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
admins.push({
|
||||
pubkey: value,
|
||||
label,
|
||||
permissions: permissions as GroupAdminPermission[],
|
||||
})
|
||||
}
|
||||
|
||||
return admins
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group members event from the specified relay.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||
* @param {GroupReference} options.groupReference - The group reference object.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
|
||||
* @throws {Error} If the group members event is not found.
|
||||
*/
|
||||
export async function fetchGroupMembersEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupMembersEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39002],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupMembersEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group members event and returns an array of GroupMember objects.
|
||||
* @param event - The event to parse.
|
||||
* @returns An array of GroupMember objects.
|
||||
* @throws Throws an error if the group members event is invalid.
|
||||
*/
|
||||
export function parseGroupMembersEvent(event: Event): GroupMember[] {
|
||||
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
|
||||
|
||||
const members: GroupMember[] = []
|
||||
|
||||
for (const [tag, value, label] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
members.push({
|
||||
pubkey: value,
|
||||
label,
|
||||
})
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
||||
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
||||
* If the relay information is not provided, it will be fetched using the normalized relay URL.
|
||||
*
|
||||
* @param {Object} options - The options for loading the group.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
|
||||
* @param {GroupReference} options.groupReference - The reference of the group to load.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
|
||||
* @returns {Promise<Group>} A promise that resolves to the loaded group.
|
||||
*/
|
||||
export async function loadGroup({
|
||||
pool,
|
||||
groupReference,
|
||||
normalizedRelayURL,
|
||||
relayInformation,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Group> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const metadata = parseGroupMetadataEvent(metadataEvent)
|
||||
|
||||
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const admins = parseGroupAdminsEvent(adminsEvent)
|
||||
|
||||
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const members = parseGroupMembersEvent(membersEvent)
|
||||
|
||||
const group: Group = {
|
||||
relay: normalizedRelayURL,
|
||||
metadata,
|
||||
admins,
|
||||
members,
|
||||
reference: groupReference,
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a group from the specified pool using the provided group code.
|
||||
*
|
||||
* @param {AbstractSimplePool} pool - The pool to load the group from.
|
||||
* @param {string} code - The code representing the group.
|
||||
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
|
||||
* @throws {Error} - If the group code is invalid.
|
||||
*/
|
||||
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||
const groupReference = parseGroupCode(code)
|
||||
|
||||
if (!groupReference) throw new Error('invalid group code')
|
||||
|
||||
return loadGroup({ pool, groupReference })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group code and returns a GroupReference object.
|
||||
*
|
||||
* @param code The group code to parse.
|
||||
* @returns A GroupReference object if the code is valid, otherwise null.
|
||||
*/
|
||||
export function parseGroupCode(code: string): null | GroupReference {
|
||||
if (NostrTypeGuard.isNAddr(code)) {
|
||||
try {
|
||||
let { data } = decode(code)
|
||||
|
||||
let { relays, identifier } = data
|
||||
if (!relays || relays.length === 0) return null
|
||||
|
||||
let host = relays![0]
|
||||
if (host.startsWith('wss://')) {
|
||||
host = host.slice(6)
|
||||
}
|
||||
return { host, id: identifier }
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
} else if (code.split("'").length === 2) {
|
||||
let spl = code.split("'")
|
||||
return { host: spl[0], id: spl[1] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a group reference into a string.
|
||||
*
|
||||
* @param gr - The group reference to encode.
|
||||
* @returns The encoded group reference as a string.
|
||||
*/
|
||||
export function encodeGroupReference(gr: GroupReference): string {
|
||||
const { host, id } = gr
|
||||
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
|
||||
|
||||
return `${normalizedHost}'${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to relay groups metadata events and calls the provided event handler function
|
||||
* when an event is received.
|
||||
*
|
||||
* @param {Object} options - The options for subscribing to relay groups metadata events.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
|
||||
* @param {string} options.relayURL - The URL of the relay.
|
||||
* @param {Function} options.onError - The error handler function.
|
||||
* @param {Function} options.onEvent - The event handler function.
|
||||
* @param {Function} [options.onConnect] - The connect handler function.
|
||||
* @returns {Function} - A function to close the subscription
|
||||
*/
|
||||
export function subscribeRelayGroupsMetadataEvents({
|
||||
pool,
|
||||
relayURL,
|
||||
onError,
|
||||
onEvent,
|
||||
onConnect,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
relayURL: string
|
||||
onError: (err: Error) => void
|
||||
onEvent: (event: Event) => void
|
||||
onConnect?: () => void
|
||||
}): () => void {
|
||||
let sub: Subscription
|
||||
|
||||
const normalizedRelayURL = normalizeURL(relayURL)
|
||||
|
||||
fetchRelayInformation(normalizedRelayURL)
|
||||
.then(async info => {
|
||||
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
|
||||
|
||||
onConnect?.()
|
||||
|
||||
sub = abstractedRelay.prepareSubscription(
|
||||
[
|
||||
{
|
||||
kinds: [39000],
|
||||
limit: 50,
|
||||
authors: [info.pubkey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event: Event) {
|
||||
onEvent(event)
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
sub.close()
|
||||
|
||||
onError(err)
|
||||
})
|
||||
|
||||
return () => sub.close()
|
||||
}
|
||||
2
nip30.ts
2
nip30.ts
@@ -2,7 +2,7 @@
|
||||
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')
|
||||
export const regex = (): RegExp => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||
|
||||
/** Represents a Nostr custom emoji. */
|
||||
export interface CustomEmoji {
|
||||
|
||||
44
nip40.test.ts
Normal file
44
nip40.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, test, expect, jest } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
|
||||
|
||||
describe('getExpiration', () => {
|
||||
test('returns the expiration as a Date object', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = getExpiration(event)
|
||||
expect(result).toEqual(new Date(123000))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEventExpired', () => {
|
||||
test('returns true when the event has expired', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('returns false when the event has not expired', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 10
|
||||
const event = buildEvent({ tags: [['expiration', future.toString()]] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForExpire', () => {
|
||||
test('returns a promise that resolves when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = await waitForExpire(event)
|
||||
expect(result).toEqual(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExpire', () => {
|
||||
test('calls the callback when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const callback = jest.fn()
|
||||
onExpire(event, callback)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
49
nip40.ts
Normal file
49
nip40.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Event } from './core.ts'
|
||||
|
||||
/** Get the expiration of the event as a `Date` object, if any. */
|
||||
function getExpiration(event: Event): Date | undefined {
|
||||
const tag = event.tags.find(([name]) => name === 'expiration')
|
||||
if (tag) {
|
||||
return new Date(parseInt(tag[1]) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if the event has expired. */
|
||||
function isEventExpired(event: Event): boolean {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
return Date.now() > expiration.getTime()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that resolves when the event expires. */
|
||||
async function waitForExpire(event: Event): Promise<Event> {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
const diff = expiration.getTime() - Date.now()
|
||||
if (diff > 0) {
|
||||
await sleep(diff)
|
||||
return event
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
} else {
|
||||
throw new Error('Event has no expiration')
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the callback when the event expires. */
|
||||
function onExpire(event: Event, callback: (event: Event) => void): void {
|
||||
waitForExpire(event)
|
||||
.then(callback)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resolves when the given number of milliseconds have elapsed. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export { getExpiration, isEventExpired, waitForExpire, onExpire }
|
||||
@@ -1,14 +1,16 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
test('auth flow', async () => {
|
||||
const relay = await Relay.connect('wss://nostr.wine')
|
||||
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { default as vec } from './nip44.vectors.json' with { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
test('get_conversation_key', () => {
|
||||
for (const v of v2vec.valid.get_conversation_key) {
|
||||
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
}
|
||||
})
|
||||
@@ -15,7 +15,7 @@ test('get_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)
|
||||
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||
expect(ciphertext).toEqual(v.payload)
|
||||
@@ -39,6 +39,8 @@ test('decrypt', async () => {
|
||||
|
||||
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)/)
|
||||
expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
|
||||
/(Point is not on curve|Cannot find square root)/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
92
nip44.ts
92
nip44.ts
@@ -1,80 +1,76 @@
|
||||
import { chacha20 } from '@noble/ciphers/chacha'
|
||||
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||
import { equalBytes } from '@noble/ciphers/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||
import { hmac } from '@noble/hashes/hmac'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||
import { concatBytes, randomBytes } 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
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
utf8Encode: utf8ToBytes,
|
||||
utf8Decode(bytes: Uint8Array) {
|
||||
return decoder.decode(bytes)
|
||||
},
|
||||
const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
|
||||
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
|
||||
|
||||
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
},
|
||||
}
|
||||
|
||||
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||
ensureBytes(conversationKey, 32)
|
||||
ensureBytes(nonce, 32)
|
||||
function getMessageKeys(
|
||||
conversationKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
calcPaddedLen(len: number): number {
|
||||
function calcPaddedLen(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||
if (len <= 32) return 32
|
||||
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||
},
|
||||
}
|
||||
|
||||
writeU16BE(num: number) {
|
||||
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||
function writeU16BE(num: number): Uint8Array {
|
||||
if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize)
|
||||
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||
const arr = new Uint8Array(2)
|
||||
new DataView(arr.buffer).setUint16(0, num, false)
|
||||
return arr
|
||||
},
|
||||
}
|
||||
|
||||
pad(plaintext: string): Uint8Array {
|
||||
const unpadded = u.utf8Encode(plaintext)
|
||||
function pad(plaintext: string): Uint8Array {
|
||||
const unpadded = utf8Encoder.encode(plaintext)
|
||||
const unpaddedLen = unpadded.length
|
||||
const prefix = u.writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
const prefix = writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
return concatBytes(prefix, unpadded, suffix)
|
||||
},
|
||||
}
|
||||
|
||||
unpad(padded: Uint8Array): string {
|
||||
function 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 ||
|
||||
unpaddedLen < minPlaintextSize ||
|
||||
unpaddedLen > maxPlaintextSize ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||
padded.length !== 2 + calcPaddedLen(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return u.utf8Decode(unpadded)
|
||||
},
|
||||
return utf8Decoder.decode(unpadded)
|
||||
}
|
||||
|
||||
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||
function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
},
|
||||
}
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
@@ -82,7 +78,7 @@ const u = {
|
||||
// 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) {
|
||||
function decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
@@ -102,30 +98,30 @@ const u = {
|
||||
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)
|
||||
export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||
const padded = pad(plaintext)
|
||||
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
const mac = 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)
|
||||
export function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||
const { nonce, ciphertext, mac } = decodePayload(payload)
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||
const calculatedMac = hmacAad(hmac_key, ciphertext, nonce)
|
||||
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||
return u.unpad(padded)
|
||||
return unpad(padded)
|
||||
}
|
||||
|
||||
export const v2 = {
|
||||
utils: u,
|
||||
utils: {
|
||||
getConversationKey,
|
||||
calcPaddedLen,
|
||||
},
|
||||
encrypt,
|
||||
decrypt,
|
||||
}
|
||||
|
||||
export default { v2 }
|
||||
|
||||
543
nip46.ts
Normal file
543
nip46.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||
import { NIP05_REGEX } from './nip05.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||
import { Signer } from './signer.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export type BunkerPointer = {
|
||||
relays: string[]
|
||||
pubkey: string
|
||||
secret: null | string
|
||||
}
|
||||
|
||||
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
|
||||
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
|
||||
bunkerPointer.relays.forEach(relay => {
|
||||
bunkerURL.searchParams.append('relay', relay)
|
||||
})
|
||||
if (bunkerPointer.secret) {
|
||||
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
|
||||
}
|
||||
return bunkerURL.toString()
|
||||
}
|
||||
|
||||
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||
and returns a BunkerPointer -- or null in case of error */
|
||||
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||
let match = input.match(BUNKER_REGEX)
|
||||
if (match) {
|
||||
try {
|
||||
const pubkey = match[1]
|
||||
const qs = new URLSearchParams(match[2])
|
||||
return {
|
||||
pubkey,
|
||||
relays: qs.getAll('relay'),
|
||||
secret: qs.get('secret'),
|
||||
}
|
||||
} catch (_err) {
|
||||
/* just move to the next case */
|
||||
}
|
||||
}
|
||||
|
||||
return queryBunkerProfile(input)
|
||||
}
|
||||
|
||||
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
|
||||
const match = nip05.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||
|
||||
let pubkey = res.names[name]
|
||||
let relays = res.nip46[pubkey] || []
|
||||
|
||||
return { pubkey, relays, secret: null }
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type NostrConnectParams = {
|
||||
clientPubkey: string
|
||||
relays: string[]
|
||||
secret: string
|
||||
perms?: string[]
|
||||
name?: string
|
||||
url?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export type ParsedNostrConnectURI = {
|
||||
protocol: 'nostrconnect'
|
||||
clientPubkey: string
|
||||
params: {
|
||||
relays: string[]
|
||||
secret: string
|
||||
perms?: string[]
|
||||
name?: string
|
||||
url?: string
|
||||
image?: string
|
||||
}
|
||||
originalString: string
|
||||
}
|
||||
|
||||
export function createNostrConnectURI(params: NostrConnectParams): string {
|
||||
if (!params.clientPubkey) {
|
||||
throw new Error('clientPubkey is required.')
|
||||
}
|
||||
if (!params.relays || params.relays.length === 0) {
|
||||
throw new Error('At least one relay is required.')
|
||||
}
|
||||
if (!params.secret) {
|
||||
throw new Error('secret is required.')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
params.relays.forEach(relay => {
|
||||
queryParams.append('relay', relay)
|
||||
})
|
||||
|
||||
queryParams.append('secret', params.secret)
|
||||
|
||||
if (params.perms && params.perms.length > 0) {
|
||||
queryParams.append('perms', params.perms.join(','))
|
||||
}
|
||||
if (params.name) {
|
||||
queryParams.append('name', params.name)
|
||||
}
|
||||
if (params.url) {
|
||||
queryParams.append('url', params.url)
|
||||
}
|
||||
if (params.image) {
|
||||
queryParams.append('image', params.image)
|
||||
}
|
||||
|
||||
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
|
||||
if (!uri.startsWith('nostrconnect://')) {
|
||||
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".')
|
||||
}
|
||||
|
||||
const [protocolAndPubkey, queryString] = uri.split('?')
|
||||
if (!protocolAndPubkey || !queryString) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing query string.')
|
||||
}
|
||||
|
||||
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length)
|
||||
if (!clientPubkey) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(queryString)
|
||||
|
||||
const relays = queryParams.getAll('relay')
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.')
|
||||
}
|
||||
|
||||
const secret = queryParams.get('secret')
|
||||
if (!secret) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.')
|
||||
}
|
||||
|
||||
const permsString = queryParams.get('perms')
|
||||
const perms = permsString ? permsString.split(',') : undefined
|
||||
|
||||
const name = queryParams.get('name') || undefined
|
||||
const url = queryParams.get('url') || undefined
|
||||
const image = queryParams.get('image') || undefined
|
||||
|
||||
return {
|
||||
protocol: 'nostrconnect',
|
||||
clientPubkey,
|
||||
params: {
|
||||
relays,
|
||||
secret,
|
||||
perms,
|
||||
name,
|
||||
url,
|
||||
image,
|
||||
},
|
||||
originalString: uri,
|
||||
}
|
||||
}
|
||||
|
||||
export type BunkerSignerParams = {
|
||||
pool?: AbstractSimplePool
|
||||
onauth?: (url: string) => void
|
||||
}
|
||||
|
||||
export class BunkerSigner implements Signer {
|
||||
private params: BunkerSignerParams
|
||||
private pool: AbstractSimplePool
|
||||
private subCloser: SubCloser | undefined
|
||||
private isOpen: boolean
|
||||
private serial: number
|
||||
private idPrefix: string
|
||||
private listeners: {
|
||||
[id: string]: {
|
||||
resolve: (_: string) => void
|
||||
reject: (_: string) => void
|
||||
}
|
||||
}
|
||||
private waitingForAuth: { [id: string]: boolean }
|
||||
private secretKey: Uint8Array
|
||||
// If the client initiates the connection, the two variables below can be filled in later.
|
||||
private conversationKey!: Uint8Array
|
||||
public bp!: BunkerPointer
|
||||
|
||||
private cachedPubKey: string | undefined
|
||||
|
||||
/**
|
||||
* Creates a new instance of the Nip46 class.
|
||||
* @param relays - An array of relay addresses.
|
||||
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||
* @param secretKey - An optional key pair.
|
||||
*/
|
||||
private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
|
||||
this.params = params
|
||||
this.pool = params.pool || new SimplePool()
|
||||
this.secretKey = clientSecretKey
|
||||
this.isOpen = false
|
||||
this.idPrefix = Math.random().toString(36).substring(7)
|
||||
this.serial = 0
|
||||
this.listeners = {}
|
||||
this.waitingForAuth = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Factory Method 1] Creates a Signer using bunker information (bunker:// URL or NIP-05).
|
||||
* This method is used when the public key of the bunker is known in advance.
|
||||
*/
|
||||
public static fromBunker(
|
||||
clientSecretKey: Uint8Array,
|
||||
bp: BunkerPointer,
|
||||
params: BunkerSignerParams = {},
|
||||
): BunkerSigner {
|
||||
if (bp.relays.length === 0) {
|
||||
throw new Error('No relays specified for this bunker')
|
||||
}
|
||||
|
||||
const signer = new BunkerSigner(clientSecretKey, params)
|
||||
|
||||
signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
|
||||
signer.bp = bp
|
||||
|
||||
signer.setupSubscription(params)
|
||||
return signer
|
||||
}
|
||||
|
||||
/**
|
||||
* [Factory Method 2] Creates a Signer using a nostrconnect:// URI generated by the client.
|
||||
* In this method, the bunker initiates the connection by scanning the URI.
|
||||
*/
|
||||
public static async fromURI(
|
||||
clientSecretKey: Uint8Array,
|
||||
connectionURI: string,
|
||||
params: BunkerSignerParams = {},
|
||||
maxWait: number = 300_000,
|
||||
): Promise<BunkerSigner> {
|
||||
const signer = new BunkerSigner(clientSecretKey, params)
|
||||
const parsedURI = parseNostrConnectURI(connectionURI)
|
||||
const clientPubkey = getPublicKey(clientSecretKey)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
sub.close()
|
||||
reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`))
|
||||
}, maxWait)
|
||||
|
||||
const sub = signer.pool.subscribe(
|
||||
parsedURI.params.relays,
|
||||
{ kinds: [NostrConnect], '#p': [clientPubkey] },
|
||||
{
|
||||
onevent: async (event: NostrEvent) => {
|
||||
try {
|
||||
const tempConvKey = getConversationKey(clientSecretKey, event.pubkey)
|
||||
const decryptedContent = decrypt(event.content, tempConvKey)
|
||||
|
||||
const response = JSON.parse(decryptedContent)
|
||||
|
||||
if (response.result === parsedURI.params.secret) {
|
||||
clearTimeout(timer)
|
||||
sub.close()
|
||||
|
||||
signer.bp = {
|
||||
pubkey: event.pubkey,
|
||||
relays: parsedURI.params.relays,
|
||||
secret: parsedURI.params.secret,
|
||||
}
|
||||
signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey)
|
||||
signer.setupSubscription(params)
|
||||
resolve(signer)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to process potential connection event', e)
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('Subscription closed before connection was established.'))
|
||||
},
|
||||
maxWait,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private setupSubscription(params: BunkerSignerParams) {
|
||||
const listeners = this.listeners
|
||||
const waitingForAuth = this.waitingForAuth
|
||||
const convKey = this.conversationKey
|
||||
|
||||
this.subCloser = this.pool.subscribe(
|
||||
this.bp.relays,
|
||||
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
|
||||
{
|
||||
onevent: async (event: NostrEvent) => {
|
||||
const o = JSON.parse(decrypt(event.content, convKey))
|
||||
const { id, result, error } = o
|
||||
|
||||
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||
delete waitingForAuth[id]
|
||||
|
||||
if (params.onauth) {
|
||||
params.onauth(error)
|
||||
} else {
|
||||
console.warn(
|
||||
`nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let handler = listeners[id]
|
||||
if (handler) {
|
||||
if (error) handler.reject(error)
|
||||
else if (result) handler.resolve(result)
|
||||
delete listeners[id]
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.subCloser = undefined
|
||||
},
|
||||
},
|
||||
)
|
||||
this.isOpen = true
|
||||
}
|
||||
|
||||
// closes the subscription -- this object can't be used anymore after this
|
||||
async close() {
|
||||
this.isOpen = false
|
||||
this.subCloser!.close()
|
||||
}
|
||||
|
||||
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||
if (!this.subCloser) this.setupSubscription(this.params)
|
||||
|
||||
this.serial++
|
||||
const id = `${this.idPrefix}-${this.serial}`
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
|
||||
|
||||
// the request event
|
||||
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||
{
|
||||
kind: NostrConnect,
|
||||
tags: [['p', this.bp.pubkey]],
|
||||
content: encryptedContent,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.secretKey,
|
||||
)
|
||||
|
||||
// setup callback listener
|
||||
this.listeners[id] = { resolve, reject }
|
||||
this.waitingForAuth[id] = true
|
||||
|
||||
// publish the event
|
||||
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
* The promise will be rejected if the response is not "pong".
|
||||
*/
|
||||
async ping(): Promise<void> {
|
||||
let resp = await this.sendRequest('ping', [])
|
||||
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "get_public_key" method on the bunker.
|
||||
* (before we would return the public key hardcoded in the bunker parameters, but
|
||||
* that is not correct as that may be the bunker pubkey and the actual signer
|
||||
* pubkey may be different.)
|
||||
*/
|
||||
async getPublicKey(): Promise<string> {
|
||||
if (!this.cachedPubKey) {
|
||||
this.cachedPubKey = await this.sendRequest('get_public_key', [])
|
||||
}
|
||||
return this.cachedPubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event using the remote private key.
|
||||
* @param event - The event to sign.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||||
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||
let signed: NostrEvent = JSON.parse(resp)
|
||||
if (verifyEvent(signed)) {
|
||||
return signed
|
||||
} else {
|
||||
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
|
||||
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account with the specified username, domain, and optional email.
|
||||
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||
* @param username - The username for the account.
|
||||
* @param domain - The domain for the account.
|
||||
* @param email - The optional email for the account.
|
||||
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
|
||||
this will default to generating a random key.
|
||||
* @throws Error if the email is present but invalid.
|
||||
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||
*/
|
||||
export async function createAccount(
|
||||
bunker: BunkerProfile,
|
||||
params: BunkerSignerParams,
|
||||
username: string,
|
||||
domain: string,
|
||||
email?: string,
|
||||
localSecretKey: Uint8Array = generateSecretKey(),
|
||||
): Promise<BunkerSigner> {
|
||||
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||
|
||||
let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
|
||||
|
||||
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||
|
||||
// once we get the newly created pubkey back, we hijack this signer instance
|
||||
// and turn it into the main instance for this newly created pubkey
|
||||
rpc.bp.pubkey = pubkey
|
||||
await rpc.connect()
|
||||
|
||||
return rpc
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches info on available providers that announce themselves using NIP-89 events.
|
||||
* @returns A promise that resolves to an array of available bunker objects.
|
||||
*/
|
||||
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [Handlerinformation],
|
||||
'#k': [NostrConnect.toString()],
|
||||
})
|
||||
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// validate bunkers by checking their NIP-05 and pubkey
|
||||
// map to a more useful object
|
||||
const validatedBunkers = await Promise.all(
|
||||
events.map(async (event, i) => {
|
||||
try {
|
||||
const content = JSON.parse(event.content)
|
||||
|
||||
// skip duplicates
|
||||
try {
|
||||
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
const bp = await queryBunkerProfile(content.nip05)
|
||||
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
|
||||
return {
|
||||
bunkerPointer: bp,
|
||||
nip05: content.nip05,
|
||||
domain: content.nip05.split('@')[1],
|
||||
name: content.name || content.display_name,
|
||||
picture: content.picture,
|
||||
about: content.about,
|
||||
website: content.website,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
|
||||
}
|
||||
|
||||
export type BunkerProfile = {
|
||||
bunkerPointer: BunkerPointer
|
||||
domain: string
|
||||
nip05: string
|
||||
name: string
|
||||
picture: string
|
||||
about: string
|
||||
website: string
|
||||
local: boolean
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
|
||||
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 has double slash', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||
|
||||
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||
expect(relay).toBe('wss://relay.damus.io')
|
||||
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
})
|
||||
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
22
nip47.ts
22
nip47.ts
@@ -1,10 +1,16 @@
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { type VerifiedEvent, 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
|
||||
interface NWCConnection {
|
||||
pubkey: string
|
||||
relay: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export function parseConnectionString(connectionString: string): NWCConnection {
|
||||
const { host, pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname || host
|
||||
const relay = searchParams.get('relay')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
@@ -15,14 +21,18 @@ export function parseConnectionString(connectionString: string) {
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||
export async function makeNwcRequestEvent(
|
||||
pubkey: string,
|
||||
secretKey: Uint8Array,
|
||||
invoice: string,
|
||||
): Promise<VerifiedEvent> {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const encryptedContent = encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: NWCWalletRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
|
||||
95
nip49.test.ts
Normal file
95
nip49.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { decrypt, encrypt } from './nip49.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('encrypt and decrypt', () => {
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
|
||||
let sec = hexToBytes(secret)
|
||||
let there = encrypt(sec, password, logn, ksb)
|
||||
let back = decrypt(there, password)
|
||||
let again = decrypt(ncryptsec, password)
|
||||
expect(back).toEqual(again)
|
||||
expect(again).toEqual(sec)
|
||||
}
|
||||
})
|
||||
|
||||
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
2,
|
||||
0x01,
|
||||
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
3,
|
||||
0x02,
|
||||
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
|
||||
],
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
7,
|
||||
0x00,
|
||||
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
8,
|
||||
0x01,
|
||||
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x02,
|
||||
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
4,
|
||||
0x00,
|
||||
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
5,
|
||||
0x01,
|
||||
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
|
||||
],
|
||||
[
|
||||
'ÅΩẛ̣',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
|
||||
],
|
||||
[
|
||||
'ÅΩṩ',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
|
||||
],
|
||||
]
|
||||
50
nip49.ts
Normal file
50
nip49.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { scrypt } from '@noble/hashes/scrypt'
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
export function encrypt(
|
||||
sec: Uint8Array,
|
||||
password: string,
|
||||
logn: number = 16,
|
||||
ksb: 0x00 | 0x01 | 0x02 = 0x02,
|
||||
): Ncryptsec {
|
||||
let salt = randomBytes(16)
|
||||
let n = 2 ** logn
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let nonce = randomBytes(24)
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let ciphertext = xc2p1.encrypt(sec)
|
||||
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
|
||||
return encodeBytes('ncryptsec', b)
|
||||
}
|
||||
|
||||
export function decrypt(ncryptsec: string, password: string): Uint8Array {
|
||||
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
|
||||
if (prefix !== 'ncryptsec') {
|
||||
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
|
||||
}
|
||||
let b = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
let version = b[0]
|
||||
if (version !== 0x02) {
|
||||
throw new Error(`invalid version ${version}, expected 0x02`)
|
||||
}
|
||||
|
||||
let logn = b[1]
|
||||
let n = 2 ** logn
|
||||
|
||||
let salt = b.slice(2, 2 + 16)
|
||||
let nonce = b.slice(2 + 16, 2 + 16 + 24)
|
||||
let ksb = b[2 + 16 + 24]
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let ciphertext = b.slice(2 + 16 + 24 + 1)
|
||||
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let sec = xc2p1.decrypt(ciphertext)
|
||||
|
||||
return sec
|
||||
}
|
||||
42
nip54.test.ts
Normal file
42
nip54.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { normalizeIdentifier } from './nip54.ts'
|
||||
|
||||
describe('normalizeIdentifier', () => {
|
||||
test('converts to lowercase', () => {
|
||||
expect(normalizeIdentifier('HELLO')).toBe('hello')
|
||||
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
|
||||
})
|
||||
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeIdentifier(' hello ')).toBe('hello')
|
||||
expect(normalizeIdentifier('\thello\n')).toBe('hello')
|
||||
})
|
||||
|
||||
test('normalizes Unicode to NFKC form', () => {
|
||||
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
|
||||
expect(normalizeIdentifier('café')).toBe('café')
|
||||
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
|
||||
})
|
||||
|
||||
test('replaces non-alphanumeric characters with hyphens', () => {
|
||||
expect(normalizeIdentifier('hello world')).toBe('hello-world')
|
||||
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
|
||||
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
|
||||
})
|
||||
|
||||
test('preserves numbers', () => {
|
||||
expect(normalizeIdentifier('user123')).toBe('user123')
|
||||
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
|
||||
})
|
||||
|
||||
test('handles multiple consecutive special characters', () => {
|
||||
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
|
||||
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
|
||||
})
|
||||
|
||||
test('handles Unicode letters from different scripts', () => {
|
||||
expect(normalizeIdentifier('привет')).toBe('привет')
|
||||
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
|
||||
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
|
||||
})
|
||||
})
|
||||
19
nip54.ts
Normal file
19
nip54.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function normalizeIdentifier(name: string): string {
|
||||
// Trim and lowercase
|
||||
name = name.trim().toLowerCase()
|
||||
|
||||
// Normalize Unicode to NFKC form
|
||||
name = name.normalize('NFKC')
|
||||
|
||||
// Convert to array of characters and map each one
|
||||
return Array.from(name)
|
||||
.map(char => {
|
||||
// Check if character is letter or number using Unicode ranges
|
||||
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
|
||||
return char
|
||||
}
|
||||
|
||||
return '-'
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
166
nip55.test.ts
Normal file
166
nip55.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import * as nip55 from './nip55.js'
|
||||
|
||||
// Function to parse the NostrSigner URI
|
||||
function parseNostrSignerUri(uri: string) {
|
||||
const [base, query] = uri.split('?')
|
||||
const basePart = base.replace('nostrsigner:', '')
|
||||
|
||||
let jsonObject = null
|
||||
if (basePart) {
|
||||
try {
|
||||
jsonObject = JSON.parse(decodeURIComponent(basePart))
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse base JSON:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const urlSearchParams = new URLSearchParams(query)
|
||||
const queryParams = Object.fromEntries(urlSearchParams.entries())
|
||||
if (queryParams.permissions) {
|
||||
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
|
||||
}
|
||||
|
||||
return {
|
||||
base: jsonObject,
|
||||
...queryParams,
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
test('Get Public Key URI', () => {
|
||||
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.getPublicKeyUri({
|
||||
permissions,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'get_public_key')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
|
||||
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
|
||||
})
|
||||
|
||||
test('Sign Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.signEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('base.content', 'test')
|
||||
expect(jsonObject).toHaveProperty('type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-04 URI', () => {
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.encryptNip04Uri({
|
||||
callbackUrl,
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-04 URI', () => {
|
||||
const uri = nip55.decryptNip04Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-44 URI', () => {
|
||||
const uri = nip55.encryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-44 URI', () => {
|
||||
const uri = nip55.decryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Decrypt Zap Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.decryptZapEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
returnType: 'event',
|
||||
compressionType: 'gzip',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'event')
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
123
nip55.ts
Normal file
123
nip55.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
type BaseParams = {
|
||||
callbackUrl?: string
|
||||
returnType?: 'signature' | 'event'
|
||||
compressionType?: 'none' | 'gzip'
|
||||
}
|
||||
|
||||
type PermissionsParams = BaseParams & {
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
}
|
||||
|
||||
type EventUriParams = BaseParams & {
|
||||
eventJson: Record<string, unknown>
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type EncryptDecryptParams = BaseParams & {
|
||||
pubKey: string
|
||||
content: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type UriParams = BaseParams & {
|
||||
base: string
|
||||
type: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
pubKey?: string
|
||||
plainText?: string
|
||||
encryptedText?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
function encodeParams(params: Record<string, unknown>): string {
|
||||
return new URLSearchParams(params as Record<string, string>).toString()
|
||||
}
|
||||
|
||||
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
|
||||
}
|
||||
|
||||
function buildUri({
|
||||
base,
|
||||
type,
|
||||
callbackUrl,
|
||||
returnType = 'signature',
|
||||
compressionType = 'none',
|
||||
...params
|
||||
}: UriParams): string {
|
||||
const baseParams = {
|
||||
type,
|
||||
compressionType,
|
||||
returnType,
|
||||
callbackUrl,
|
||||
id: params.id,
|
||||
current_user: params.currentUser,
|
||||
permissions:
|
||||
params.permissions && params.permissions.length > 0
|
||||
? encodeURIComponent(JSON.stringify(params.permissions))
|
||||
: undefined,
|
||||
pubKey: params.pubKey,
|
||||
plainText: params.plainText,
|
||||
encryptedText: params.encryptedText,
|
||||
appName: params.appName,
|
||||
}
|
||||
|
||||
const filteredParams = filterUndefined(baseParams)
|
||||
return `${base}?${encodeParams(filteredParams)}`
|
||||
}
|
||||
|
||||
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
|
||||
return buildUri({
|
||||
base: 'nostrsigner:',
|
||||
type,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
|
||||
return buildDefaultUri('get_public_key', { permissions, ...params })
|
||||
}
|
||||
|
||||
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'sign_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, plainText: params.content })
|
||||
}
|
||||
|
||||
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, encryptedText: params.content })
|
||||
}
|
||||
|
||||
export function encryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip04_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip04_decrypt', params)
|
||||
}
|
||||
|
||||
export function encryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip44_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip44_decrypt', params)
|
||||
}
|
||||
|
||||
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'decrypt_zap_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
125
nip57.test.ts
125
nip57.test.ts
@@ -1,105 +1,7 @@
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
import { describe, test, expect } 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'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
@@ -317,3 +219,26 @@ describe('makeZapReceipt', () => {
|
||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||
})
|
||||
})
|
||||
|
||||
test('parses the amount from bolt11 invoices', () => {
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc4u1p5zcarnpp5djng98r73nxu66nxp6gndjkw24q7rdzgp7p80lt0gk4z3h3krkssdq9tfpygcqzzsxqzjcsp58hz3v5qefdm70g5fnm2cn6q9thzpu6m4f5wjqurhur5xzmf9vl3s9qxpqysgq9v6qv86xaruzeak9jjyz54fygrkn526z7xhm0llh8wl44gcgh0rznhjqdswd4cjurzdgh0pgzrfj4sd7f3mf89jd6kadse008ex7kxgqqa5xrk',
|
||||
),
|
||||
).toEqual(400)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc8400u1p5zcaz5pp5ltvyhtg4ed7sd8jurj28ugmavezkmqsadpe3t9npufpcrd0uet0scqzyssp5l3hz4ayt5ee0p83ma4a96l2rruhx33eyycewldu2ffa5pk2qx7jq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdq8w3jhxaqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll8qkt3np4rqyqqqqlgqqqqqeqqjqhuhjk5u9r850ncxngne7cfp9s08s2nm6c2rkz7jhl8gjmlx0fga5tlncgeuh4avlsrkq6ljyyhgq8rrxprga03esqhd0gf5455x6tdcqahhw9q',
|
||||
),
|
||||
).toEqual(840000)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc210n1p5zcuaxpp52nn778cfk46md4ld0hdj2juuzvfrsrdaf4ek2k0yeensae07x2cqdq9tfpygcqzzsxqzjcsp5768c4k79jtnq92pgppan8rjnujcpcqhnqwqwk3lm5dfr7e0k2a7s9qxpqysgqt8lnh9l7ple27t73x7gty570ltas2s33uahc7egke5tdmhxr3ezn590wf2utxyt7d3afnk2lxc2u0enc6n53ck4mxwpmzpxa7ws05aqp0c5x3r',
|
||||
),
|
||||
).toEqual(21)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc899640n1p5zcuavpp5w72fqrf09286lq33vw364qryrq5nw60z4dhdx56f8w05xkx4massdq9tfpygcqzzsxqzjcsp5qrqn4kpvem5jwpl63kj5pfdlqxg2plaffz0prz7vaqjy29uc66us9qxpqysgqlhzzqmn2jxd2476404krm8nvrarymwq7nj2zecl92xug54ek0mfntdxvxwslf756m8kq0r7jtpantm52fmewc72r5lfmd85505jnemgqw5j0pc',
|
||||
),
|
||||
).toEqual(89964)
|
||||
})
|
||||
|
||||
107
nip57.ts
107
nip57.ts
@@ -1,7 +1,8 @@
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { NostrEvent, validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -17,13 +18,13 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else 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
|
||||
}
|
||||
@@ -41,35 +42,44 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapRequest({
|
||||
profile,
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
type ProfileZap = {
|
||||
pubkey: string
|
||||
amount: number
|
||||
comment: string
|
||||
comment?: string
|
||||
relays: string[]
|
||||
}): EventTemplate {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
}
|
||||
|
||||
type EventZap = {
|
||||
event: NostrEvent
|
||||
amount: number
|
||||
comment?: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export function makeZapRequest(params: ProfileZap | EventZap): EventTemplate {
|
||||
let zr: EventTemplate = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
content: params.comment || '',
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays],
|
||||
['p', 'pubkey' in params ? params.pubkey : params.event.pubkey],
|
||||
['amount', params.amount.toString()],
|
||||
['relays', ...params.relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
zr.tags.push(['e', event])
|
||||
if ('event' in params) {
|
||||
zr.tags.push(['e', params.event.id])
|
||||
if (isReplaceableKind(params.event.kind)) {
|
||||
const a = ['a', `${params.event.kind}:${params.event.pubkey}:`]
|
||||
zr.tags.push(a)
|
||||
} else if (isAddressableKind(params.event.kind)) {
|
||||
let d = params.event.tags.find(([t, v]) => t === 'd' && v)
|
||||
if (!d) throw new Error('d tag not found or is empty')
|
||||
const a = ['a', `${params.event.kind}:${params.event.pubkey}:${d[1]}`]
|
||||
zr.tags.push(a)
|
||||
}
|
||||
zr.tags.push(['k', params.event.kind.toString()])
|
||||
}
|
||||
|
||||
return zr
|
||||
@@ -128,3 +138,52 @@ export function makeZapReceipt({
|
||||
|
||||
return zap
|
||||
}
|
||||
|
||||
export function getSatoshisAmountFromBolt11(bolt11: string): number {
|
||||
if (bolt11.length < 50) {
|
||||
return 0
|
||||
}
|
||||
bolt11 = bolt11.substring(0, 50)
|
||||
const idx = bolt11.lastIndexOf('1')
|
||||
if (idx === -1) {
|
||||
return 0
|
||||
}
|
||||
const hrp = bolt11.substring(0, idx)
|
||||
if (!hrp.startsWith('lnbc')) {
|
||||
return 0
|
||||
}
|
||||
const amount = hrp.substring(4) // equivalent to strings.CutPrefix
|
||||
|
||||
if (amount.length < 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// if last character is a digit, then the amount can just be interpreted as BTC
|
||||
const char = amount[amount.length - 1]
|
||||
const digit = char.charCodeAt(0) - '0'.charCodeAt(0)
|
||||
const isDigit = digit >= 0 && digit <= 9
|
||||
|
||||
let cutPoint = amount.length - 1
|
||||
if (isDigit) {
|
||||
cutPoint++
|
||||
}
|
||||
|
||||
if (cutPoint < 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const num = parseInt(amount.substring(0, cutPoint))
|
||||
|
||||
switch (char) {
|
||||
case 'm':
|
||||
return num * 100000
|
||||
case 'u':
|
||||
return num * 100
|
||||
case 'n':
|
||||
return num / 10
|
||||
case 'p':
|
||||
return num / 10000
|
||||
default:
|
||||
return num * 100000000
|
||||
}
|
||||
}
|
||||
|
||||
357
nip58.test.ts
Normal file
357
nip58.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { EventTemplate } from './core.ts'
|
||||
import {
|
||||
BadgeAward as BadgeAwardKind,
|
||||
BadgeDefinition as BadgeDefinitionKind,
|
||||
ProfileBadges as ProfileBadgesKind,
|
||||
} from './kinds.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
import {
|
||||
BadgeAward,
|
||||
BadgeDefinition,
|
||||
ProfileBadges,
|
||||
generateBadgeAwardEventTemplate,
|
||||
generateBadgeDefinitionEventTemplate,
|
||||
generateProfileBadgesEventTemplate,
|
||||
validateBadgeAwardEvent,
|
||||
validateBadgeDefinitionEvent,
|
||||
validateProfileBadgesEvent,
|
||||
} from './nip58.ts'
|
||||
|
||||
test('BadgeDefinition has required property "d"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
expect(badge.d).toEqual('badge-id')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "name"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
name: 'Badge Name',
|
||||
}
|
||||
expect(badge.name).toEqual('Badge Name')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "description"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
description: 'Badge Description',
|
||||
}
|
||||
expect(badge.description).toEqual('Badge Description')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "image"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
image: ['https://example.com/badge.png', '1024x1024'],
|
||||
}
|
||||
expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024'])
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "thumbs"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
thumbs: [
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
expect(badge.thumbs).toEqual([
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
])
|
||||
})
|
||||
|
||||
test('BadgeAward has required property "a"', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
expect(badgeAward.a).toEqual('badge-definition-address')
|
||||
})
|
||||
|
||||
test('BadgeAward has required property "p"', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
expect(badgeAward.p).toEqual([
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('ProfileBadges has required property "d"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
expect(profileBadges.d).toEqual('profile_badges')
|
||||
})
|
||||
|
||||
test('ProfileBadges has required property "badges"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
expect(profileBadges.badges).toEqual([])
|
||||
})
|
||||
|
||||
test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address',
|
||||
e: ['badge-award-event-id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(profileBadges.badges[0].a).toEqual('badge-definition-address')
|
||||
expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id'])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
name: 'Badge Name',
|
||||
description: 'Badge Description',
|
||||
image: ['https://example.com/badge.png', '1024x1024'],
|
||||
thumbs: [
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||
})
|
||||
|
||||
test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeDefinitionEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeDefinitionEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags: [
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeAwardEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeAwardEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'profile_badges']])
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address',
|
||||
e: ['badge-award-event-id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address'],
|
||||
['e', 'badge-award-event-id'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address1',
|
||||
e: ['badge-award-event-id1', 'badge-award-event-id2'],
|
||||
},
|
||||
{
|
||||
a: 'badge-definition-address2',
|
||||
e: ['badge-award-event-id3'],
|
||||
},
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address1'],
|
||||
['e', 'badge-award-event-id1', 'badge-award-event-id2'],
|
||||
['a', 'badge-definition-address2'],
|
||||
['e', 'badge-award-event-id3'],
|
||||
])
|
||||
})
|
||||
|
||||
test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags: [
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address'],
|
||||
['e', 'badge-award-event-id'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateProfileBadgesEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateProfileBadgesEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
245
nip58.ts
Normal file
245
nip58.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import {
|
||||
BadgeAward as BadgeAwardKind,
|
||||
BadgeDefinition as BadgeDefinitionKind,
|
||||
ProfileBadges as ProfileBadgesKind,
|
||||
} from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents the structure for defining a badge within the Nostr network.
|
||||
* This structure is used to create templates for badge definition events,
|
||||
* facilitating the recognition and awarding of badges to users for various achievements.
|
||||
*/
|
||||
export type BadgeDefinition = {
|
||||
/**
|
||||
* A unique identifier for the badge. This is used to distinguish badges
|
||||
* from one another and should be unique across all badge definitions.
|
||||
* Typically, this could be a short, descriptive string.
|
||||
*/
|
||||
d: string
|
||||
|
||||
/**
|
||||
* An optional short name for the badge. This provides a human-readable
|
||||
* title for the badge, making it easier to recognize and refer to.
|
||||
*/
|
||||
name?: string
|
||||
|
||||
/**
|
||||
* An optional description for the badge. This field can be used to
|
||||
* provide more detailed information about the badge, such as the criteria
|
||||
* for its awarding or its significance.
|
||||
*/
|
||||
description?: string
|
||||
|
||||
/**
|
||||
* An optional image URL and dimensions for the badge. The first element
|
||||
* of the tuple is the URL pointing to a high-resolution image representing
|
||||
* the badge, and the second element specifies the image's dimensions in
|
||||
* the format "widthxheight". The recommended dimensions are 1024x1024 pixels.
|
||||
*/
|
||||
image?: [string, string]
|
||||
|
||||
/**
|
||||
* An optional list of thumbnail images for the badge. Each element in the
|
||||
* array is a tuple, where the first element is the URL pointing to a thumbnail
|
||||
* version of the badge image, and the second element specifies the thumbnail's
|
||||
* dimensions in the format "widthxheight". Multiple thumbnails can be provided
|
||||
* to support different display sizes.
|
||||
*/
|
||||
thumbs?: Array<[string, string]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the structure for awarding a badge to one or more recipients
|
||||
* within the Nostr network. This structure is used to create templates for
|
||||
* badge award events, which are immutable and signify the recognition of
|
||||
* individuals' achievements or contributions.
|
||||
*/
|
||||
export type BadgeAward = {
|
||||
/**
|
||||
* A reference to the Badge Definition event. This is typically composed
|
||||
* of the event ID of the badge definition. It establishes a clear linkage
|
||||
* between the badge being awarded and its original definition, ensuring
|
||||
* that recipients are awarded the correct badge.
|
||||
*/
|
||||
a: string
|
||||
|
||||
/**
|
||||
* An array of p tags, each containing a pubkey and its associated relays.
|
||||
*/
|
||||
p: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the collection of badges a user chooses to display on their profile.
|
||||
* This structure is crucial for applications that allow users to showcase achievements
|
||||
* or recognitions in the form of badges, following the specifications of NIP-58.
|
||||
*/
|
||||
export type ProfileBadges = {
|
||||
/**
|
||||
* A unique identifier for the profile badges collection. According to NIP-58,
|
||||
* this should be set to "profile_badges" to differentiate it from other event types.
|
||||
*/
|
||||
d: 'profile_badges'
|
||||
|
||||
/**
|
||||
* A list of badges that the user has elected to display on their profile. Each item
|
||||
* in the array represents a specific badge, including references to both its definition
|
||||
* and the award event.
|
||||
*/
|
||||
badges: Array<{
|
||||
/**
|
||||
* The event address of the badge definition. This is a reference to the specific badge
|
||||
* being displayed, linking back to the badge's original definition event. It allows
|
||||
* clients to fetch and display the badge's details, such as its name, description,
|
||||
* and image.
|
||||
*/
|
||||
a: string
|
||||
|
||||
/**
|
||||
* The event id of the badge award with corresponding relays. This references the event
|
||||
* in which the badge was awarded to the user. It is crucial for verifying the
|
||||
* authenticity of the badge display, ensuring that the user was indeed awarded the
|
||||
* badge they are choosing to display.
|
||||
*/
|
||||
e: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided BadgeDefinition.
|
||||
*
|
||||
* @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateBadgeDefinitionEventTemplate({
|
||||
d,
|
||||
description,
|
||||
image,
|
||||
name,
|
||||
thumbs,
|
||||
}: BadgeDefinition): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['d', d]]
|
||||
|
||||
// Append optional tags
|
||||
name && tags.push(['name', name])
|
||||
description && tags.push(['description', description])
|
||||
image && tags.push(['image', ...image])
|
||||
if (thumbs) {
|
||||
for (const thumb of thumbs) {
|
||||
tags.push(['thumb', ...thumb])
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a badge definition event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid badge definition event.
|
||||
*/
|
||||
export function validateBadgeDefinitionEvent(event: Event): boolean {
|
||||
if (event.kind !== BadgeDefinitionKind) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided BadgeAward.
|
||||
*
|
||||
* @param {BadgeAward} badgeAward - The BadgeAward object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['a', a]]
|
||||
for (const _p of p) {
|
||||
tags.push(['p', ..._p])
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a badge award event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid badge award event.
|
||||
*/
|
||||
export function validateBadgeAwardEvent(event: Event): boolean {
|
||||
if (event.kind !== BadgeAwardKind) return false
|
||||
|
||||
const requiredTags = ['a', 'p'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided ProfileBadges.
|
||||
*
|
||||
* @param {ProfileBadges} profileBadges - The ProfileBadges object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['d', 'profile_badges']]
|
||||
|
||||
// Append optional tags
|
||||
for (const badge of badges) {
|
||||
tags.push(['a', badge.a], ['e', ...badge.e])
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a profile badges event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid profile badges event.
|
||||
*/
|
||||
export function validateProfileBadgesEvent(event: Event): boolean {
|
||||
if (event.kind !== ProfileBadgesKind) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
113
nip59.test.ts
Normal file
113
nip59.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
|
||||
import { decode } from './nip19.ts'
|
||||
import { NostrEvent, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { GiftWrap } from './kinds.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
|
||||
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
|
||||
const recipientPublicKey = getPublicKey(recipientPrivateKey)
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'Are you going to the party tonight?',
|
||||
}
|
||||
|
||||
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||
|
||||
test('wrapEvent', () => {
|
||||
const expected = {
|
||||
content: '',
|
||||
id: '',
|
||||
created_at: 1728537932,
|
||||
kind: 1059,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||
[Symbol('verified')]: true,
|
||||
}
|
||||
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('wrapManyEvent', () => {
|
||||
const expected = [
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729581521,
|
||||
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729594619,
|
||||
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
]
|
||||
|
||||
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
|
||||
|
||||
wrappedEvents.forEach((event, index) => {
|
||||
expect(event.kind).toEqual(expected[index].kind)
|
||||
expect(event.tags).toEqual(expected[index].tags)
|
||||
})
|
||||
})
|
||||
|
||||
test('unwrapEvent', () => {
|
||||
const expected = {
|
||||
kind: 1,
|
||||
content: 'Are you going to the party tonight?',
|
||||
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||
tags: [],
|
||||
}
|
||||
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.content).toEqual(expected.content)
|
||||
expect(result.pubkey).toEqual(expected.pubkey)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('getWrappedEvents and unwrapManyEvents', async () => {
|
||||
const expected = [
|
||||
{
|
||||
created_at: 1729721879,
|
||||
content: 'Hello!',
|
||||
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||
kind: 14,
|
||||
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
|
||||
},
|
||||
{
|
||||
created_at: 1729722025,
|
||||
content: 'How are you?',
|
||||
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||
kind: 14,
|
||||
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
|
||||
},
|
||||
]
|
||||
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
|
||||
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
|
||||
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
|
||||
|
||||
const pool = new SimplePool()
|
||||
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
|
||||
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
|
||||
|
||||
unwrappedEvents.forEach((event, index) => {
|
||||
expect(event).toEqual(expected[index])
|
||||
})
|
||||
})
|
||||
107
nip59.ts
Normal file
107
nip59.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
|
||||
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Seal, GiftWrap } from './kinds.ts'
|
||||
|
||||
type Rumor = UnsignedEvent & { id: string }
|
||||
|
||||
const TWO_DAYS = 2 * 24 * 60 * 60
|
||||
|
||||
const now = () => Math.round(Date.now() / 1000)
|
||||
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
|
||||
|
||||
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
|
||||
|
||||
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
|
||||
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
|
||||
|
||||
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
|
||||
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
|
||||
|
||||
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
|
||||
const rumor = {
|
||||
created_at: now(),
|
||||
content: '',
|
||||
tags: [],
|
||||
...event,
|
||||
pubkey: getPublicKey(privateKey),
|
||||
} as any
|
||||
|
||||
rumor.id = getEventHash(rumor)
|
||||
|
||||
return rumor as Rumor
|
||||
}
|
||||
|
||||
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Seal,
|
||||
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
|
||||
created_at: randomNow(),
|
||||
tags: [],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
|
||||
const randomKey = generateSecretKey()
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: GiftWrap,
|
||||
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
|
||||
created_at: randomNow(),
|
||||
tags: [['p', recipientPublicKey]],
|
||||
},
|
||||
randomKey,
|
||||
) as NostrEvent
|
||||
}
|
||||
|
||||
export function wrapEvent(
|
||||
event: Partial<UnsignedEvent>,
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipientPublicKey: string,
|
||||
): NostrEvent {
|
||||
const rumor = createRumor(event, senderPrivateKey)
|
||||
|
||||
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
|
||||
return createWrap(seal, recipientPublicKey)
|
||||
}
|
||||
|
||||
export function wrapManyEvents(
|
||||
event: Partial<UnsignedEvent>,
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipientsPublicKeys: string[],
|
||||
): NostrEvent[] {
|
||||
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
|
||||
throw new Error('At least one recipient is required.')
|
||||
}
|
||||
|
||||
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||
|
||||
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
|
||||
|
||||
recipientsPublicKeys.forEach(recipientPublicKey => {
|
||||
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
|
||||
})
|
||||
|
||||
return wrappeds
|
||||
}
|
||||
|
||||
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
|
||||
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
|
||||
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
|
||||
}
|
||||
|
||||
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
|
||||
let unwrappedEvents: Rumor[] = []
|
||||
|
||||
wrappedEvents.forEach(e => {
|
||||
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
|
||||
})
|
||||
|
||||
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
return unwrappedEvents
|
||||
}
|
||||
203
nip75.test.ts
Normal file
203
nip75.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { ZapGoal } from './kinds.ts'
|
||||
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('Goal Type', () => {
|
||||
it('should create a proper Goal object', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(goal.content).toBe('Fundraising for a new project')
|
||||
expect(goal.amount).toBe('100000000')
|
||||
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
|
||||
expect(goal.closedAt).toBe(1671150419)
|
||||
expect(goal.image).toBe('https://example.com/goal-image.jpg')
|
||||
expect(goal.summary).toBe('Help us reach our fundraising goal!')
|
||||
expect(goal.r).toBe('https://example.com/additional-info')
|
||||
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
|
||||
expect(goal.zapTags).toEqual([
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateGoalEventTemplate', () => {
|
||||
it('should generate an EventTemplate for a fundraising goal', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate that is valid', () => {
|
||||
const sk = generateSecretKey()
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapGoalEvent', () => {
|
||||
it('should validate a proper Goal event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should not validate an event with an incorrect kind', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // Incorrect kind
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "amount" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
// Missing "amount" tag
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "relays" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
// Missing "relays" tag
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
115
nip75.ts
Normal file
115
nip75.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { ZapGoal } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
|
||||
* This type is used to structure the information needed to create a goal event (`kind:9041`).
|
||||
*/
|
||||
export type Goal = {
|
||||
/**
|
||||
* A human-readable description of the fundraising goal.
|
||||
* This content should provide clear information about the purpose of the fundraising.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The target amount for the fundraising goal in milisats.
|
||||
* This defines the financial target that the fundraiser aims to reach.
|
||||
*/
|
||||
amount: string
|
||||
|
||||
/**
|
||||
* A list of relays where the zaps towards this goal will be sent to and tallied from.
|
||||
* Each relay is represented by its WebSocket URL.
|
||||
*/
|
||||
relays: string[]
|
||||
|
||||
/**
|
||||
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
|
||||
* Zaps published after this timestamp should not count towards the goal progress.
|
||||
* If not provided, the goal remains open indefinitely or until manually closed.
|
||||
*/
|
||||
closedAt?: number
|
||||
|
||||
/**
|
||||
* An optional URL to an image related to the goal.
|
||||
* This can be used to visually represent the goal on client interfaces.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* An optional brief description or summary of the goal.
|
||||
* This can provide a quick overview of the goal, separate from the detailed `content`.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
|
||||
* This is a single URL, as per NIP-75 specifications for linking additional resources.
|
||||
*/
|
||||
r?: string
|
||||
|
||||
/**
|
||||
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
|
||||
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
|
||||
*/
|
||||
a?: string
|
||||
|
||||
/**
|
||||
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
|
||||
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
|
||||
*/
|
||||
zapTags?: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
|
||||
* This function is tailored to fit the structure of EventTemplate as defined in the library.
|
||||
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
|
||||
* @returns An EventTemplate object structured for creating a Nostr event.
|
||||
*/
|
||||
export function generateGoalEventTemplate({
|
||||
amount,
|
||||
content,
|
||||
relays,
|
||||
a,
|
||||
closedAt,
|
||||
image,
|
||||
r,
|
||||
summary,
|
||||
zapTags,
|
||||
}: Goal): EventTemplate {
|
||||
const tags: string[][] = [
|
||||
['amount', amount],
|
||||
['relays', ...relays],
|
||||
]
|
||||
|
||||
// Append optional tags based on the presence of optional properties in zapGoal
|
||||
closedAt && tags.push(['closed_at', closedAt.toString()])
|
||||
image && tags.push(['image', image])
|
||||
summary && tags.push(['summary', summary])
|
||||
r && tags.push(['r', r])
|
||||
a && tags.push(['a', a])
|
||||
zapTags && tags.push(...zapTags)
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
export function validateZapGoalEvent(event: Event): boolean {
|
||||
if (event.kind !== ZapGoal) return false
|
||||
|
||||
const requiredTags = ['amount', 'relays'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
114
nip77.test.ts
Normal file
114
nip77.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { NegentropySync, NegentropyStorageVector } from './nip77.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
import { NostrEvent } from './core.ts'
|
||||
|
||||
// const RELAY = 'ws://127.0.0.1:10547'
|
||||
const RELAY = 'wss://relay.damus.io'
|
||||
|
||||
describe('NegentropySync', () => {
|
||||
test('syncs events from ' + RELAY, async () => {
|
||||
const relay = await Relay.connect(RELAY)
|
||||
|
||||
const storage = new NegentropyStorageVector()
|
||||
storage.seal()
|
||||
const filter = {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [30617, 30618],
|
||||
}
|
||||
|
||||
let ids1: string[] = []
|
||||
const done1 = Promise.withResolvers<void>()
|
||||
const sync1 = new NegentropySync(relay, storage, filter, {
|
||||
onneed: (id: string) => {
|
||||
ids1.push(id)
|
||||
},
|
||||
onclose: err => {
|
||||
expect(err).toBeUndefined()
|
||||
done1.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
await sync1.start()
|
||||
await done1.promise
|
||||
|
||||
expect(ids1.length).toBeGreaterThan(10)
|
||||
|
||||
sync1.close()
|
||||
|
||||
// fetch events
|
||||
const events1: NostrEvent[] = []
|
||||
const fetched = Promise.withResolvers()
|
||||
const sub = relay.subscribe([{ ids: ids1 }], {
|
||||
onevent(evt) {
|
||||
events1.push(evt)
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
fetched.resolve()
|
||||
},
|
||||
})
|
||||
await fetched.promise
|
||||
expect(events1.map(evt => evt.id).sort()).toEqual(ids1.sort())
|
||||
|
||||
// Second sync with local events
|
||||
await relay.connect()
|
||||
|
||||
const storage2 = new NegentropyStorageVector()
|
||||
for (const evt of events1) {
|
||||
storage2.insert(evt.created_at, evt.id)
|
||||
}
|
||||
storage2.seal()
|
||||
|
||||
let ids2: string[] = []
|
||||
let done2 = Promise.withResolvers()
|
||||
const sync2 = new NegentropySync(relay, storage2, filter, {
|
||||
onneed: (id: string) => {
|
||||
ids2.push(id)
|
||||
},
|
||||
onclose: err => {
|
||||
expect(err).toBeUndefined()
|
||||
done2.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
await sync2.start()
|
||||
await done2.promise
|
||||
|
||||
expect(ids2.length).toBe(0)
|
||||
|
||||
sync2.close()
|
||||
|
||||
// third sync with 4 events removed
|
||||
const storage3 = new NegentropyStorageVector()
|
||||
|
||||
// shuffle
|
||||
ids1.sort(() => Math.random() - 0.5)
|
||||
const removedEvents = ids1.slice(0, 1 + Math.floor(Math.random() * ids1.length - 1))
|
||||
for (const evt of events1) {
|
||||
if (!removedEvents.includes(evt.id)) {
|
||||
storage3.insert(evt.created_at, evt.id)
|
||||
}
|
||||
}
|
||||
storage3.seal()
|
||||
|
||||
let ids3: string[] = []
|
||||
const done3 = Promise.withResolvers()
|
||||
const sync3 = new NegentropySync(relay, storage3, filter, {
|
||||
onneed: (id: string) => {
|
||||
ids3.push(id)
|
||||
},
|
||||
onclose: err => {
|
||||
expect(err).toBeUndefined()
|
||||
done3.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
await sync3.start()
|
||||
await done3.promise
|
||||
|
||||
expect(ids3.sort()).toEqual(removedEvents.sort())
|
||||
|
||||
sync3.close()
|
||||
})
|
||||
})
|
||||
607
nip77.ts
Normal file
607
nip77.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'
|
||||
import { Filter } from './filter.ts'
|
||||
import { AbstractRelay, Subscription } from './relay.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
// Negentropy implementation by Doug Hoyte
|
||||
const PROTOCOL_VERSION = 0x61 // Version 1
|
||||
const ID_SIZE = 32
|
||||
const FINGERPRINT_SIZE = 16
|
||||
|
||||
const Mode = {
|
||||
Skip: 0,
|
||||
Fingerprint: 1,
|
||||
IdList: 2,
|
||||
}
|
||||
|
||||
class WrappedBuffer {
|
||||
_raw: Uint8Array
|
||||
length: number
|
||||
|
||||
constructor(buffer?: Uint8Array | number) {
|
||||
if (typeof buffer === 'number') {
|
||||
this._raw = new Uint8Array(buffer)
|
||||
this.length = 0
|
||||
} else if (buffer instanceof Uint8Array) {
|
||||
this._raw = new Uint8Array(buffer)
|
||||
this.length = buffer.length
|
||||
} else {
|
||||
this._raw = new Uint8Array(512)
|
||||
this.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
unwrap(): Uint8Array {
|
||||
return this._raw.subarray(0, this.length)
|
||||
}
|
||||
|
||||
get capacity(): number {
|
||||
return this._raw.byteLength
|
||||
}
|
||||
|
||||
extend(buf: Uint8Array | WrappedBuffer): void {
|
||||
if (buf instanceof WrappedBuffer) buf = buf.unwrap()
|
||||
if (typeof buf.length !== 'number') throw Error('bad length')
|
||||
const targetSize = buf.length + this.length
|
||||
if (this.capacity < targetSize) {
|
||||
const oldRaw = this._raw
|
||||
const newCapacity = Math.max(this.capacity * 2, targetSize)
|
||||
this._raw = new Uint8Array(newCapacity)
|
||||
this._raw.set(oldRaw)
|
||||
}
|
||||
|
||||
this._raw.set(buf, this.length)
|
||||
this.length += buf.length
|
||||
}
|
||||
|
||||
shift(): number {
|
||||
const first = this._raw[0]
|
||||
this._raw = this._raw.subarray(1)
|
||||
this.length--
|
||||
return first
|
||||
}
|
||||
|
||||
shiftN(n: number = 1): Uint8Array {
|
||||
const firstSubarray = this._raw.subarray(0, n)
|
||||
this._raw = this._raw.subarray(n)
|
||||
this.length -= n
|
||||
return firstSubarray
|
||||
}
|
||||
}
|
||||
|
||||
function decodeVarInt(buf: WrappedBuffer): number {
|
||||
let res = 0
|
||||
|
||||
while (1) {
|
||||
if (buf.length === 0) throw Error('parse ends prematurely')
|
||||
let byte = buf.shift()
|
||||
res = (res << 7) | (byte & 127)
|
||||
if ((byte & 128) === 0) break
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
function encodeVarInt(n: number): WrappedBuffer {
|
||||
if (n === 0) return new WrappedBuffer(new Uint8Array([0]))
|
||||
|
||||
let o: number[] = []
|
||||
|
||||
while (n !== 0) {
|
||||
o.push(n & 127)
|
||||
n >>>= 7
|
||||
}
|
||||
|
||||
o.reverse()
|
||||
|
||||
for (let i = 0; i < o.length - 1; i++) o[i] |= 128
|
||||
|
||||
return new WrappedBuffer(new Uint8Array(o))
|
||||
}
|
||||
|
||||
function getByte(buf: WrappedBuffer): number {
|
||||
return getBytes(buf, 1)[0]
|
||||
}
|
||||
|
||||
function getBytes(buf: WrappedBuffer, n: number): Uint8Array {
|
||||
if (buf.length < n) throw Error('parse ends prematurely')
|
||||
return buf.shiftN(n)
|
||||
}
|
||||
|
||||
class Accumulator {
|
||||
buf!: Uint8Array
|
||||
|
||||
constructor() {
|
||||
this.setToZero()
|
||||
}
|
||||
|
||||
setToZero(): void {
|
||||
this.buf = new Uint8Array(ID_SIZE)
|
||||
}
|
||||
|
||||
add(otherBuf: Uint8Array): void {
|
||||
let currCarry = 0,
|
||||
nextCarry = 0
|
||||
let p = new DataView(this.buf.buffer)
|
||||
let po = new DataView(otherBuf.buffer)
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let offset = i * 4
|
||||
let orig = p.getUint32(offset, true)
|
||||
let otherV = po.getUint32(offset, true)
|
||||
|
||||
let next = orig
|
||||
|
||||
next += currCarry
|
||||
next += otherV
|
||||
if (next > 0xffffffff) nextCarry = 1
|
||||
|
||||
p.setUint32(offset, next & 0xffffffff, true)
|
||||
currCarry = nextCarry
|
||||
nextCarry = 0
|
||||
}
|
||||
}
|
||||
|
||||
negate(): void {
|
||||
let p = new DataView(this.buf.buffer)
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let offset = i * 4
|
||||
p.setUint32(offset, ~p.getUint32(offset, true))
|
||||
}
|
||||
|
||||
let one = new Uint8Array(ID_SIZE)
|
||||
one[0] = 1
|
||||
this.add(one)
|
||||
}
|
||||
|
||||
getFingerprint(n: number): Uint8Array {
|
||||
let input = new WrappedBuffer()
|
||||
input.extend(this.buf)
|
||||
input.extend(encodeVarInt(n))
|
||||
|
||||
let hash = sha256(input.unwrap())
|
||||
return hash.subarray(0, FINGERPRINT_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
export class NegentropyStorageVector {
|
||||
items: { timestamp: number; id: Uint8Array }[]
|
||||
sealed: boolean
|
||||
|
||||
constructor() {
|
||||
this.items = []
|
||||
this.sealed = false
|
||||
}
|
||||
|
||||
insert(timestamp: number, id: string): void {
|
||||
if (this.sealed) throw Error('already sealed')
|
||||
const idb = hexToBytes(id)
|
||||
if (idb.byteLength !== ID_SIZE) throw Error('bad id size for added item')
|
||||
this.items.push({ timestamp, id: idb })
|
||||
}
|
||||
|
||||
seal(): void {
|
||||
if (this.sealed) throw Error('already sealed')
|
||||
this.sealed = true
|
||||
|
||||
this.items.sort(itemCompare)
|
||||
|
||||
for (let i = 1; i < this.items.length; i++) {
|
||||
if (itemCompare(this.items[i - 1], this.items[i]) === 0) throw Error('duplicate item inserted')
|
||||
}
|
||||
}
|
||||
|
||||
unseal(): void {
|
||||
this.sealed = false
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this._checkSealed()
|
||||
return this.items.length
|
||||
}
|
||||
|
||||
getItem(i: number): { timestamp: number; id: Uint8Array } {
|
||||
this._checkSealed()
|
||||
if (i >= this.items.length) throw Error('out of range')
|
||||
return this.items[i]
|
||||
}
|
||||
|
||||
iterate(begin: number, end: number, cb: (item: { timestamp: number; id: Uint8Array }, i: number) => boolean): void {
|
||||
this._checkSealed()
|
||||
this._checkBounds(begin, end)
|
||||
|
||||
for (let i = begin; i < end; ++i) {
|
||||
if (!cb(this.items[i], i)) break
|
||||
}
|
||||
}
|
||||
|
||||
findLowerBound(begin: number, end: number, bound: { timestamp: number; id: Uint8Array }): number {
|
||||
this._checkSealed()
|
||||
this._checkBounds(begin, end)
|
||||
|
||||
return this._binarySearch(this.items, begin, end, a => itemCompare(a, bound) < 0)
|
||||
}
|
||||
|
||||
fingerprint(begin: number, end: number): Uint8Array {
|
||||
let out = new Accumulator()
|
||||
out.setToZero()
|
||||
|
||||
this.iterate(begin, end, item => {
|
||||
out.add(item.id)
|
||||
return true
|
||||
})
|
||||
|
||||
return out.getFingerprint(end - begin)
|
||||
}
|
||||
|
||||
_checkSealed(): void {
|
||||
if (!this.sealed) throw Error('not sealed')
|
||||
}
|
||||
|
||||
_checkBounds(begin: number, end: number): void {
|
||||
if (begin > end || end > this.items.length) throw Error('bad range')
|
||||
}
|
||||
|
||||
_binarySearch(
|
||||
arr: { timestamp: number; id: Uint8Array }[],
|
||||
first: number,
|
||||
last: number,
|
||||
cmp: (a: { timestamp: number; id: Uint8Array }) => boolean,
|
||||
): number {
|
||||
let count = last - first
|
||||
|
||||
while (count > 0) {
|
||||
let it = first
|
||||
let step = Math.floor(count / 2)
|
||||
it += step
|
||||
|
||||
if (cmp(arr[it])) {
|
||||
first = ++it
|
||||
count -= step + 1
|
||||
} else {
|
||||
count = step
|
||||
}
|
||||
}
|
||||
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
export class Negentropy {
|
||||
storage: NegentropyStorageVector
|
||||
frameSizeLimit: number
|
||||
lastTimestampIn: number
|
||||
lastTimestampOut: number
|
||||
|
||||
constructor(storage: NegentropyStorageVector, frameSizeLimit: number = 60_000) {
|
||||
if (frameSizeLimit < 4096) throw Error('frameSizeLimit too small')
|
||||
|
||||
this.storage = storage
|
||||
this.frameSizeLimit = frameSizeLimit
|
||||
|
||||
this.lastTimestampIn = 0
|
||||
this.lastTimestampOut = 0
|
||||
}
|
||||
|
||||
_bound(timestamp: number, id?: Uint8Array): { timestamp: number; id: Uint8Array } {
|
||||
return { timestamp, id: id || new Uint8Array(0) }
|
||||
}
|
||||
|
||||
initiate(): string {
|
||||
let output = new WrappedBuffer()
|
||||
output.extend(new Uint8Array([PROTOCOL_VERSION]))
|
||||
this.splitRange(0, this.storage.size(), this._bound(Number.MAX_VALUE), output)
|
||||
return bytesToHex(output.unwrap())
|
||||
}
|
||||
|
||||
reconcile(queryMsg: string, onhave?: (id: string) => void, onneed?: (id: string) => void): string | null {
|
||||
const query = new WrappedBuffer(hexToBytes(queryMsg))
|
||||
|
||||
this.lastTimestampIn = this.lastTimestampOut = 0 // reset for each message
|
||||
|
||||
let fullOutput = new WrappedBuffer()
|
||||
fullOutput.extend(new Uint8Array([PROTOCOL_VERSION]))
|
||||
|
||||
let protocolVersion = getByte(query)
|
||||
if (protocolVersion < 0x60 || protocolVersion > 0x6f) throw Error('invalid negentropy protocol version byte')
|
||||
if (protocolVersion !== PROTOCOL_VERSION) {
|
||||
throw Error('unsupported negentropy protocol version requested: ' + (protocolVersion - 0x60))
|
||||
}
|
||||
|
||||
let storageSize = this.storage.size()
|
||||
let prevBound = this._bound(0)
|
||||
let prevIndex = 0
|
||||
let skip = false
|
||||
|
||||
while (query.length !== 0) {
|
||||
let o = new WrappedBuffer()
|
||||
|
||||
let doSkip = () => {
|
||||
if (skip) {
|
||||
skip = false
|
||||
o.extend(this.encodeBound(prevBound))
|
||||
o.extend(encodeVarInt(Mode.Skip))
|
||||
}
|
||||
}
|
||||
|
||||
let currBound = this.decodeBound(query)
|
||||
let mode = decodeVarInt(query)
|
||||
|
||||
let lower = prevIndex
|
||||
let upper = this.storage.findLowerBound(prevIndex, storageSize, currBound)
|
||||
|
||||
if (mode === Mode.Skip) {
|
||||
skip = true
|
||||
} else if (mode === Mode.Fingerprint) {
|
||||
let theirFingerprint = getBytes(query, FINGERPRINT_SIZE)
|
||||
let ourFingerprint = this.storage.fingerprint(lower, upper)
|
||||
|
||||
if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) {
|
||||
doSkip()
|
||||
this.splitRange(lower, upper, currBound, o)
|
||||
} else {
|
||||
skip = true
|
||||
}
|
||||
} else if (mode === Mode.IdList) {
|
||||
let numIds = decodeVarInt(query)
|
||||
|
||||
let theirElems: { [key: string]: Uint8Array } = {} // stringified Uint8Array -> original Uint8Array (or hex)
|
||||
for (let i = 0; i < numIds; i++) {
|
||||
let e = getBytes(query, ID_SIZE)
|
||||
theirElems[bytesToHex(e)] = e
|
||||
}
|
||||
|
||||
skip = true
|
||||
this.storage.iterate(lower, upper, item => {
|
||||
let k = item.id
|
||||
const id = bytesToHex(k)
|
||||
|
||||
if (!theirElems[id]) {
|
||||
// ID exists on our side, but not their side
|
||||
onhave?.(id)
|
||||
} else {
|
||||
// ID exists on both sides
|
||||
delete theirElems[bytesToHex(k)]
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (onneed) {
|
||||
for (let v of Object.values(theirElems)) {
|
||||
// ID exists on their side, but not our side
|
||||
onneed(bytesToHex(v))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw Error('unexpected mode')
|
||||
}
|
||||
|
||||
if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) {
|
||||
// frameSizeLimit exceeded: stop range processing and return a fingerprint for the remaining range
|
||||
let remainingFingerprint = this.storage.fingerprint(upper, storageSize)
|
||||
|
||||
fullOutput.extend(this.encodeBound(this._bound(Number.MAX_VALUE)))
|
||||
fullOutput.extend(encodeVarInt(Mode.Fingerprint))
|
||||
fullOutput.extend(remainingFingerprint)
|
||||
break
|
||||
} else {
|
||||
fullOutput.extend(o)
|
||||
}
|
||||
|
||||
prevIndex = upper
|
||||
prevBound = currBound
|
||||
}
|
||||
|
||||
return fullOutput.length === 1 ? null : bytesToHex(fullOutput.unwrap())
|
||||
}
|
||||
|
||||
splitRange(lower: number, upper: number, upperBound: { timestamp: number; id: Uint8Array }, o: WrappedBuffer) {
|
||||
let numElems = upper - lower
|
||||
let buckets = 16
|
||||
|
||||
if (numElems < buckets * 2) {
|
||||
o.extend(this.encodeBound(upperBound))
|
||||
o.extend(encodeVarInt(Mode.IdList))
|
||||
|
||||
o.extend(encodeVarInt(numElems))
|
||||
this.storage.iterate(lower, upper, item => {
|
||||
o.extend(item.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
let itemsPerBucket = Math.floor(numElems / buckets)
|
||||
let bucketsWithExtra = numElems % buckets
|
||||
let curr = lower
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
let bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0)
|
||||
let ourFingerprint = this.storage.fingerprint(curr, curr + bucketSize)
|
||||
curr += bucketSize
|
||||
|
||||
let nextBound: { timestamp: number; id: Uint8Array }
|
||||
|
||||
if (curr === upper) {
|
||||
nextBound = upperBound
|
||||
} else {
|
||||
let prevItem: { timestamp: number; id: Uint8Array } | undefined
|
||||
let currItem: { timestamp: number; id: Uint8Array } | undefined
|
||||
|
||||
this.storage.iterate(curr - 1, curr + 1, (item, index) => {
|
||||
if (index === curr - 1) prevItem = item
|
||||
else currItem = item
|
||||
return true
|
||||
})
|
||||
|
||||
nextBound = this.getMinimalBound(prevItem!, currItem!)
|
||||
}
|
||||
|
||||
o.extend(this.encodeBound(nextBound))
|
||||
o.extend(encodeVarInt(Mode.Fingerprint))
|
||||
o.extend(ourFingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exceededFrameSizeLimit(n: number): boolean {
|
||||
return n > this.frameSizeLimit - 200
|
||||
}
|
||||
|
||||
// Decoding
|
||||
decodeTimestampIn(encoded: WrappedBuffer): number {
|
||||
let timestamp = decodeVarInt(encoded)
|
||||
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1
|
||||
if (this.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
|
||||
this.lastTimestampIn = Number.MAX_VALUE
|
||||
return Number.MAX_VALUE
|
||||
}
|
||||
timestamp += this.lastTimestampIn
|
||||
this.lastTimestampIn = timestamp
|
||||
return timestamp
|
||||
}
|
||||
|
||||
decodeBound(encoded: WrappedBuffer): { timestamp: number; id: Uint8Array } {
|
||||
let timestamp = this.decodeTimestampIn(encoded)
|
||||
let len = decodeVarInt(encoded)
|
||||
if (len > ID_SIZE) throw Error('bound key too long')
|
||||
let id = getBytes(encoded, len)
|
||||
return { timestamp, id }
|
||||
}
|
||||
|
||||
// Encoding
|
||||
encodeTimestampOut(timestamp: number): WrappedBuffer {
|
||||
if (timestamp === Number.MAX_VALUE) {
|
||||
this.lastTimestampOut = Number.MAX_VALUE
|
||||
return encodeVarInt(0)
|
||||
}
|
||||
|
||||
let temp = timestamp
|
||||
timestamp -= this.lastTimestampOut
|
||||
this.lastTimestampOut = temp
|
||||
return encodeVarInt(timestamp + 1)
|
||||
}
|
||||
|
||||
encodeBound(key: { timestamp: number; id: Uint8Array }): WrappedBuffer {
|
||||
let output = new WrappedBuffer()
|
||||
|
||||
output.extend(this.encodeTimestampOut(key.timestamp))
|
||||
output.extend(encodeVarInt(key.id.length))
|
||||
output.extend(key.id)
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
getMinimalBound(
|
||||
prev: { timestamp: number; id: Uint8Array },
|
||||
curr: { timestamp: number; id: Uint8Array },
|
||||
): { timestamp: number; id: Uint8Array } {
|
||||
if (curr.timestamp !== prev.timestamp) {
|
||||
return this._bound(curr.timestamp)
|
||||
} else {
|
||||
let sharedPrefixBytes = 0
|
||||
let currKey = curr.id
|
||||
let prevKey = prev.id
|
||||
|
||||
for (let i = 0; i < ID_SIZE; i++) {
|
||||
if (currKey[i] !== prevKey[i]) break
|
||||
sharedPrefixBytes++
|
||||
}
|
||||
|
||||
return this._bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareUint8Array(a: Uint8Array, b: Uint8Array): number {
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
if (a[i] < b[i]) return -1
|
||||
if (a[i] > b[i]) return 1
|
||||
}
|
||||
|
||||
if (a.byteLength > b.byteLength) return 1
|
||||
if (a.byteLength < b.byteLength) return -1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function itemCompare(a: { timestamp: number; id: Uint8Array }, b: { timestamp: number; id: Uint8Array }): number {
|
||||
if (a.timestamp === b.timestamp) {
|
||||
return compareUint8Array(a.id, b.id)
|
||||
}
|
||||
|
||||
return a.timestamp - b.timestamp
|
||||
}
|
||||
|
||||
export class NegentropySync {
|
||||
relay: AbstractRelay
|
||||
storage: NegentropyStorageVector
|
||||
private neg: Negentropy
|
||||
private filter: Filter
|
||||
private subscription: Subscription
|
||||
private onhave?: (id: string) => void
|
||||
private onneed?: (id: string) => void
|
||||
|
||||
constructor(
|
||||
relay: AbstractRelay,
|
||||
storage: NegentropyStorageVector,
|
||||
filter: Filter,
|
||||
params: {
|
||||
label?: string
|
||||
onhave?: (id: string) => void
|
||||
onneed?: (id: string) => void
|
||||
onclose?: (errReason?: string) => void
|
||||
} = {},
|
||||
) {
|
||||
this.relay = relay
|
||||
this.storage = storage
|
||||
this.neg = new Negentropy(storage)
|
||||
this.onhave = params.onhave
|
||||
this.onneed = params.onneed
|
||||
this.filter = filter
|
||||
|
||||
// we prepare a subscription with an empty filter, but it will not be used
|
||||
this.subscription = this.relay.prepareSubscription([{}], { label: params.label || 'negentropy' })
|
||||
this.subscription.oncustom = (data: string[]) => {
|
||||
switch (data[0]) {
|
||||
case 'NEG-MSG': {
|
||||
if (data.length < 3) {
|
||||
console.warn(`got invalid NEG-MSG from ${this.relay.url}: ${data}`)
|
||||
}
|
||||
try {
|
||||
const response = this.neg.reconcile(data[2], this.onhave, this.onneed)
|
||||
if (response) {
|
||||
this.relay.send(`["NEG-MSG", "${this.subscription.id}", "${response}"]`)
|
||||
} else {
|
||||
this.close()
|
||||
params.onclose?.()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('negentropy reconcile error:', error)
|
||||
params?.onclose?.(`reconcile error: ${error}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'NEG-CLOSE': {
|
||||
const reason = data[2]
|
||||
console.warn('negentropy error:', reason)
|
||||
params.onclose?.(reason)
|
||||
break
|
||||
}
|
||||
case 'NEG-ERR': {
|
||||
params.onclose?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const initMsg = this.neg.initiate()
|
||||
this.relay.send(`["NEG-OPEN","${this.subscription.id}",${JSON.stringify(this.filter)},"${initMsg}"]`)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.relay.send(`["NEG-CLOSE","${this.subscription.id}"]`)
|
||||
this.subscription.close()
|
||||
}
|
||||
}
|
||||
390
nip94.test.ts
Normal file
390
nip94.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
it('should generate the correct event template', () => {
|
||||
const fileMetadataObject: FileMetadataObject = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||
}
|
||||
|
||||
const expectedEventTemplate: EventTemplate = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: expect.any(Number),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateEventTemplate(fileMetadataObject)
|
||||
|
||||
expect(eventTemplate).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if kind is not FileMetadataKind', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // not FileMetadataKind
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if content is empty', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // empty
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if required tags are missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithoutUrl: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
// missing url
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutM: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
// missing m
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutX: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
// missing x
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutOx: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
// missing ox
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithoutUrl)).toBe(false)
|
||||
expect(validateEvent(eventWithoutM)).toBe(false)
|
||||
expect(validateEvent(eventWithoutX)).toBe(false)
|
||||
expect(validateEvent(eventWithoutOx)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if size is not a number', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', 'abc'], // not a number
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if dim is not a valid dimension string', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithInvalidDim: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', 'abc'], // invalid dim
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithInvalidDim)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
it('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const parsedEvent = parseEvent(event)
|
||||
|
||||
expect(parsedEvent).toEqual({
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the event is invalid', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // invalid
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||
})
|
||||
})
|
||||
211
nip94.ts
Normal file
211
nip94.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Type definition for File Metadata as specified in NIP-94.
|
||||
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
|
||||
*/
|
||||
export type FileMetadataObject = {
|
||||
/**
|
||||
* A description or caption for the file content.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The URL to download the file.
|
||||
*/
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The MIME type of the file, in lowercase.
|
||||
*/
|
||||
m: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the file.
|
||||
*/
|
||||
x: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
|
||||
*/
|
||||
ox: string
|
||||
|
||||
/**
|
||||
* Optional: The size of the file in bytes.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
|
||||
*/
|
||||
dim?: string
|
||||
|
||||
/**
|
||||
* Optional: The URI to the magnet file.
|
||||
*/
|
||||
magnet?: string
|
||||
|
||||
/**
|
||||
* Optional: The torrent infohash.
|
||||
*/
|
||||
i?: string
|
||||
|
||||
/**
|
||||
* Optional: The blurhash string to show while the file is being loaded by the client.
|
||||
*/
|
||||
blurhash?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
|
||||
*/
|
||||
thumb?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of a preview image with the same dimensions as the original file.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* Optional: A text excerpt or summary of the file's content.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* Optional: A description for accessibility, providing context or a brief description of the file.
|
||||
*/
|
||||
alt?: string
|
||||
|
||||
/**
|
||||
* Optional: fallback URLs in case url fails.
|
||||
*/
|
||||
fallback?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a file metadata object.
|
||||
*
|
||||
* @param fileMetadata - The file metadata object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: fileMetadata.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', fileMetadata.url],
|
||||
['m', fileMetadata.m],
|
||||
['x', fileMetadata.x],
|
||||
['ox', fileMetadata.ox],
|
||||
],
|
||||
}
|
||||
|
||||
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
|
||||
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
|
||||
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
|
||||
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
|
||||
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
|
||||
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
|
||||
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
|
||||
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
|
||||
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid file metadata event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (event.kind !== FileMetadataKind) return false
|
||||
|
||||
if (!event.content) return false
|
||||
|
||||
const requiredTags = ['url', 'm', 'x', 'ox'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate optional size tag
|
||||
const sizeTag = event.tags.find(([t]) => t == 'size')
|
||||
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
|
||||
|
||||
// validate optional dim tag
|
||||
const dimTag = event.tags.find(([t]) => t == 'dim')
|
||||
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a file metadata object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The file metadata object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): FileMetadataObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const fileMetadata: FileMetadataObject = {
|
||||
content: event.content,
|
||||
url: '',
|
||||
m: '',
|
||||
x: '',
|
||||
ox: '',
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'url':
|
||||
fileMetadata.url = value
|
||||
break
|
||||
case 'm':
|
||||
fileMetadata.m = value
|
||||
break
|
||||
case 'x':
|
||||
fileMetadata.x = value
|
||||
break
|
||||
case 'ox':
|
||||
fileMetadata.ox = value
|
||||
break
|
||||
case 'size':
|
||||
fileMetadata.size = value
|
||||
break
|
||||
case 'dim':
|
||||
fileMetadata.dim = value
|
||||
break
|
||||
case 'magnet':
|
||||
fileMetadata.magnet = value
|
||||
break
|
||||
case 'i':
|
||||
fileMetadata.i = value
|
||||
break
|
||||
case 'blurhash':
|
||||
fileMetadata.blurhash = value
|
||||
break
|
||||
case 'thumb':
|
||||
fileMetadata.thumb = value
|
||||
break
|
||||
case 'image':
|
||||
fileMetadata.image = value
|
||||
break
|
||||
case 'summary':
|
||||
fileMetadata.summary = value
|
||||
break
|
||||
case 'alt':
|
||||
fileMetadata.alt = value
|
||||
break
|
||||
case 'fallback':
|
||||
fileMetadata.fallback ??= []
|
||||
fileMetadata.fallback.push(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
397
nip98.test.ts
397
nip98.test.ts
@@ -1,76 +1,83 @@
|
||||
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'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const sk = generateSecretKey()
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import {
|
||||
getToken,
|
||||
hashPayload,
|
||||
unpackEventFromToken,
|
||||
validateEvent,
|
||||
validateEventKind,
|
||||
validateEventMethodTag,
|
||||
validateEventPayloadTag,
|
||||
validateEventTimestamp,
|
||||
validateEventUrlTag,
|
||||
validateToken,
|
||||
} from './nip98.ts'
|
||||
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('returns without authorization scheme for GET', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
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([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
test('returns token without authorization scheme for POST', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
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([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
test('returns token WITH authorization scheme for POST', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
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([
|
||||
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
||||
test('returns token with a valid payload tag when payload is present', async () => {
|
||||
const sk = generateSecretKey()
|
||||
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 payloadHash = hashPayload(payload)
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
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([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
||||
})
|
||||
|
||||
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))
|
||||
test('returns true for valid token without authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
expect(isTokenValid).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)
|
||||
test('returns true for valid token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).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('throws an error for invalid token', async () => {
|
||||
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).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('throws an error for missing token', async () => {
|
||||
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).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))
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.kind = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).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))
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.created_at = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).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)
|
||||
test('throws an error for invalid url', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
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)
|
||||
test('throws an error for invalid method', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||
|
||||
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)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('returns true for valid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventTimestamp', () => {
|
||||
test('returns true for valid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventKind', () => {
|
||||
test('returns true for valid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventUrlTag', () => {
|
||||
test('returns true for valid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventMethodTag', () => {
|
||||
test('returns true for valid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventPayloadTag', () => {
|
||||
test('returns true for valid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for missing payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPayload', () => {
|
||||
test('returns hash for valid payload', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
|
||||
test('returns hash for empty payload', async () => {
|
||||
const payload = {}
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
})
|
||||
|
||||
141
nip98.ts
141
nip98.ts
@@ -1,17 +1,13 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
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.
|
||||
*
|
||||
@@ -37,7 +33,7 @@ export async function getToken(
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
||||
event.tags.push(['payload', hashPayload(payload)])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks an event from a token.
|
||||
*
|
||||
* @param token - The token to unpack.
|
||||
* @returns A promise that resolves to the unpacked event.
|
||||
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||
*/
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<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')
|
||||
/**
|
||||
* Validates the timestamp of an event.
|
||||
* @param event - The event object to validate.
|
||||
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||
*/
|
||||
export function validateEventTimestamp(event: Event): boolean {
|
||||
if (!event.created_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the kind of an event.
|
||||
* @param event The event to validate.
|
||||
* @returns A boolean indicating whether the event kind is valid.
|
||||
*/
|
||||
export function validateEventKind(event: Event): boolean {
|
||||
return event.kind === HTTPAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given URL matches the URL tag of the event.
|
||||
* @param event - The event object.
|
||||
* @param url - The URL to validate.
|
||||
* @returns A boolean indicating whether the URL is valid or not.
|
||||
*/
|
||||
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
|
||||
if (!urlTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return urlTag.length > 0 && urlTag[1] === url
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given event has a method tag that matches the specified method.
|
||||
* @param event - The event to validate.
|
||||
* @param method - The method to match against the method tag.
|
||||
* @returns A boolean indicating whether the event has a matching method tag.
|
||||
*/
|
||||
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
|
||||
if (!methodTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the hash of a payload.
|
||||
* @param payload - The payload to be hashed.
|
||||
* @returns The hash value as a string.
|
||||
*/
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the event payload tag against the provided payload.
|
||||
* @param event The event object.
|
||||
* @param payload The payload to validate.
|
||||
* @returns A boolean indicating whether the payload tag is valid.
|
||||
*/
|
||||
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
|
||||
if (!payloadTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payloadHash = hashPayload(payload)
|
||||
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Nostr event for the NIP-98 flow.
|
||||
*
|
||||
* @param event - The Nostr event to validate.
|
||||
* @param url - The URL associated with the event.
|
||||
* @param method - The HTTP method associated with the event.
|
||||
* @param body - The request body associated with the event (optional).
|
||||
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||
* @throws An error if the event is invalid.
|
||||
*/
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== HTTPAuth) {
|
||||
|
||||
if (!validateEventKind(event)) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
if (!validateEventTimestamp(event)) {
|
||||
throw new Error('Invalid nostr event, created_at timestamp 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) {
|
||||
if (!validateEventUrlTag(event, 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()) {
|
||||
if (!validateEventMethodTag(event, method)) {
|
||||
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')
|
||||
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||
if (!validateEventPayloadTag(event, body)) {
|
||||
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Event } from './core.ts'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid classified listing event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when the "d" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "title" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
// Missing 'title' tag
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "summary" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
// Missing 'summary' tag
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
// Missing 'published_at' tag
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "location" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
// Missing 'location' tag
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
// Missing 'price' tag
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', 'not-a-valid-timestamp'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid price', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', 'not-a-valid-price', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid currency', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'not-a-valid-currency'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['a', 'extra1'],
|
||||
['a', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const event2: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['e', 'extra1'],
|
||||
['e', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
expect(validateEvent(event2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
test('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const expectedListing = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
e: ['value1', 'value2'],
|
||||
a: ['value1', 'value2'],
|
||||
},
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
expect(parseEvent(event)).toEqual(expectedListing)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
test('should generate the correct event template for a classified listing', () => {
|
||||
const listing: ClassifiedListingObject = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
extra1: 'value1',
|
||||
extra2: 'value2',
|
||||
},
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
const expectedEventTemplate = {
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['published_at', '1296962229'],
|
||||
['summary', 'Sample Summary'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['extra1', 'value1'],
|
||||
['extra2', 'value2'],
|
||||
],
|
||||
created_at: expect.any(Number),
|
||||
}
|
||||
|
||||
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents the details of a price.
|
||||
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||
* @example { amount: '100', currency: 'EUR' }
|
||||
*/
|
||||
export type PriceDetails = {
|
||||
/**
|
||||
* The amount of the price.
|
||||
*/
|
||||
amount: string
|
||||
/**
|
||||
* The currency of the price in 3-letter ISO 4217 format.
|
||||
* @example 'USD'
|
||||
*/
|
||||
currency: string
|
||||
/**
|
||||
* The optional frequency of payment.
|
||||
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||
*/
|
||||
frequency?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a classified listing object.
|
||||
*/
|
||||
export type ClassifiedListingObject = {
|
||||
/**
|
||||
* Whether the listing is a draft or not.
|
||||
*/
|
||||
isDraft: boolean
|
||||
/**
|
||||
* A title of the listing.
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* A short summary or tagline.
|
||||
*/
|
||||
summary: string
|
||||
/**
|
||||
* A description in Markdown format.
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Timestamp in unix seconds of when the listing was published.
|
||||
*/
|
||||
publishedAt: string
|
||||
/**
|
||||
* Location of the listing.
|
||||
* @example 'NYC'
|
||||
*/
|
||||
location: string
|
||||
/**
|
||||
* Price details.
|
||||
*/
|
||||
price: PriceDetails
|
||||
/**
|
||||
* Images of the listing with optional dimensions.
|
||||
*/
|
||||
images: Array<{
|
||||
url: string
|
||||
dimensions?: string
|
||||
}>
|
||||
/**
|
||||
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||
*/
|
||||
hashtags: string[]
|
||||
/**
|
||||
* Other standard tags.
|
||||
* @example "g", a geohash for more precise location
|
||||
*/
|
||||
additionalTags: Record<string, string | string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid classified listing event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||
|
||||
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||
const requiredTagCount = requiredTags.length
|
||||
const tagCounts: Record<string, number> = {}
|
||||
|
||||
if (event.tags.length < requiredTagCount) return false
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag.length < 2) return false
|
||||
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'published_at') {
|
||||
const timestamp = parseInt(tagValues[0])
|
||||
if (isNaN(timestamp)) return false
|
||||
} else if (tagName == 'price') {
|
||||
if (tagValues.length < 2) return false
|
||||
|
||||
const price = parseInt(tagValues[0])
|
||||
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (requiredTags.includes(tagName)) {
|
||||
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a classified listing object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The classified listing object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const listing: ClassifiedListingObject = {
|
||||
isDraft: event.kind === DraftClassifiedListing,
|
||||
title: '',
|
||||
summary: '',
|
||||
content: event.content,
|
||||
publishedAt: '',
|
||||
location: '',
|
||||
price: {
|
||||
amount: '',
|
||||
currency: '',
|
||||
},
|
||||
images: [],
|
||||
hashtags: [],
|
||||
additionalTags: {},
|
||||
}
|
||||
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'title') {
|
||||
listing.title = tagValues[0]
|
||||
} else if (tagName == 'summary') {
|
||||
listing.summary = tagValues[0]
|
||||
} else if (tagName == 'published_at') {
|
||||
listing.publishedAt = tagValues[0]
|
||||
} else if (tagName == 'location') {
|
||||
listing.location = tagValues[0]
|
||||
} else if (tagName == 'price') {
|
||||
listing.price.amount = tagValues[0]
|
||||
listing.price.currency = tagValues[1]
|
||||
|
||||
if (tagValues.length == 3) {
|
||||
listing.price.frequency = tagValues[2]
|
||||
}
|
||||
} else if (tagName == 'image') {
|
||||
listing.images.push({
|
||||
url: tagValues[0],
|
||||
dimensions: tagValues?.[1] ?? undefined,
|
||||
})
|
||||
} else if (tagName == 't') {
|
||||
listing.hashtags.push(tagValues[0])
|
||||
} else if (tagName == 'e' || tagName == 'a') {
|
||||
listing.additionalTags[tagName] = [...tagValues]
|
||||
}
|
||||
}
|
||||
|
||||
return listing
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a classified listing object.
|
||||
*
|
||||
* @param listing - The classified listing object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||
['title', listing.title],
|
||||
['published_at', listing.publishedAt],
|
||||
['summary', listing.summary],
|
||||
['location', listing.location],
|
||||
priceTag,
|
||||
]
|
||||
|
||||
for (let i = 0; i < listing.images.length; i++) {
|
||||
const image = listing.images[i]
|
||||
const imageTag = ['image', image.url]
|
||||
if (image.dimensions) imageTag.push(image.dimensions)
|
||||
|
||||
tags.push(imageTag)
|
||||
}
|
||||
|
||||
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||
const t = listing.hashtags[i]
|
||||
|
||||
tags.push(['t', t])
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const val = value[i]
|
||||
|
||||
tags.push([key, val])
|
||||
}
|
||||
} else {
|
||||
tags.push([key, value])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||
content: listing.content,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
55
nipb7.test.ts
Normal file
55
nipb7.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { BlossomClient } from './nipb7.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from './utils.ts'
|
||||
import { PlainKeySigner } from './signer.ts'
|
||||
import { generateSecretKey } from './pure.ts'
|
||||
|
||||
test('blossom', async () => {
|
||||
const BLOSSOM_SERVER = 'blossom.primal.net'
|
||||
const TEST_CONTENT = 'hello world'
|
||||
const TEST_BLOB = new Blob([TEST_CONTENT], { type: 'text/plain' })
|
||||
|
||||
const expectedHash = bytesToHex(sha256(new TextEncoder().encode(TEST_CONTENT)))
|
||||
|
||||
const signer = new PlainKeySigner(generateSecretKey())
|
||||
const client = new BlossomClient(BLOSSOM_SERVER, signer)
|
||||
expect(client).toBeDefined()
|
||||
|
||||
// check for non-existent file should throw
|
||||
const invalidHash = expectedHash.slice(0, 62) + 'ba'
|
||||
let hasThrown = false
|
||||
try {
|
||||
await client.check(invalidHash)
|
||||
} catch (err) {
|
||||
hasThrown = true
|
||||
}
|
||||
expect(hasThrown).toBeTrue()
|
||||
|
||||
// upload hello world blob
|
||||
const descriptor = await client.uploadBlob(TEST_BLOB, 'text/plain')
|
||||
expect(descriptor).toBeDefined()
|
||||
expect(descriptor.sha256).toBe(expectedHash)
|
||||
expect(descriptor.size).toBe(TEST_CONTENT.length)
|
||||
expect(descriptor.type).toBe('text/plain')
|
||||
expect(descriptor.url).toContain(expectedHash)
|
||||
expect(descriptor.uploaded).toBeGreaterThan(0)
|
||||
await client.check(expectedHash)
|
||||
|
||||
// download and verify
|
||||
const downloadedBuffer = await client.download(expectedHash)
|
||||
const downloadedContent = new TextDecoder().decode(downloadedBuffer)
|
||||
expect(downloadedContent).toBe(TEST_CONTENT)
|
||||
|
||||
// list blobs should include our uploaded file
|
||||
const blobs = await client.list()
|
||||
|
||||
expect(Array.isArray(blobs)).toBe(true)
|
||||
const ourBlob = blobs.find(blob => blob.sha256 === expectedHash)
|
||||
expect(ourBlob).toBeDefined()
|
||||
expect(ourBlob?.type).toBe('text/plain')
|
||||
expect(ourBlob?.size).toBe(TEST_CONTENT.length)
|
||||
|
||||
// delete
|
||||
await client.delete(expectedHash)
|
||||
})
|
||||
203
nipb7.ts
Normal file
203
nipb7.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { EventTemplate } from './core.ts'
|
||||
import { Signer } from './signer.ts'
|
||||
import { bytesToHex } from './utils.ts'
|
||||
|
||||
export type BlobDescriptor = {
|
||||
url: string
|
||||
sha256: string
|
||||
size: number
|
||||
type: string
|
||||
uploaded: number
|
||||
}
|
||||
|
||||
export class BlossomClient {
|
||||
private mediaserver: string
|
||||
private signer: Signer
|
||||
|
||||
constructor(mediaserver: string, signer: Signer) {
|
||||
if (!mediaserver.startsWith('http')) {
|
||||
mediaserver = 'https://' + mediaserver
|
||||
}
|
||||
this.mediaserver = mediaserver.replace(/\/$/, '') + '/'
|
||||
this.signer = signer
|
||||
}
|
||||
|
||||
private async httpCall(
|
||||
method: string,
|
||||
url: string,
|
||||
contentType?: string,
|
||||
addAuthorization?: () => Promise<string>,
|
||||
body?: File | Blob,
|
||||
result?: any,
|
||||
): Promise<any> {
|
||||
const headers: { [_: string]: string } = {}
|
||||
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType
|
||||
}
|
||||
|
||||
if (addAuthorization) {
|
||||
const auth = await addAuthorization()
|
||||
if (auth) {
|
||||
headers['Authorization'] = auth
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(this.mediaserver + url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (response.status >= 300) {
|
||||
const reason = response.headers.get('X-Reason') || response.statusText
|
||||
throw new Error(`${url} returned an error (${response.status}): ${reason}`)
|
||||
}
|
||||
|
||||
if (result !== null && response.headers.get('content-type')?.includes('application/json')) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const event: EventTemplate = {
|
||||
created_at: now,
|
||||
kind: 24242,
|
||||
content: 'blossom stuff',
|
||||
tags: [['expiration', String(now + 60)]],
|
||||
}
|
||||
|
||||
if (modify) {
|
||||
modify(event)
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await this.signer.signEvent(event)
|
||||
const eventJson = JSON.stringify(signedEvent)
|
||||
return 'Nostr ' + btoa(eventJson)
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private isValid32ByteHex(hash: string): boolean {
|
||||
return /^[a-f0-9]{64}$/i.test(hash)
|
||||
}
|
||||
|
||||
async check(hash: string): Promise<void> {
|
||||
if (!this.isValid32ByteHex(hash)) {
|
||||
throw new Error(`${hash} is not a valid 32-byte hex string`)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.httpCall('HEAD', hash)
|
||||
} catch (error) {
|
||||
throw new Error(`failed to check for ${hash}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBlob(file: File | Blob, contentType?: string): Promise<BlobDescriptor> {
|
||||
const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
|
||||
const actualContentType = contentType || file.type || 'application/octet-stream'
|
||||
|
||||
const bd = await this.httpCall(
|
||||
'PUT',
|
||||
'upload',
|
||||
actualContentType,
|
||||
() =>
|
||||
this.authorizationHeader(evt => {
|
||||
evt.tags.push(['t', 'upload'])
|
||||
evt.tags.push(['x', hash])
|
||||
}),
|
||||
file,
|
||||
{},
|
||||
)
|
||||
|
||||
return bd
|
||||
}
|
||||
|
||||
async uploadFile(file: File): Promise<BlobDescriptor> {
|
||||
return this.uploadBlob(file, file.type)
|
||||
}
|
||||
|
||||
async download(hash: string): Promise<ArrayBuffer> {
|
||||
if (!this.isValid32ByteHex(hash)) {
|
||||
throw new Error(`${hash} is not a valid 32-byte hex string`)
|
||||
}
|
||||
|
||||
const authHeader = await this.authorizationHeader(evt => {
|
||||
evt.tags.push(['t', 'get'])
|
||||
evt.tags.push(['x', hash])
|
||||
})
|
||||
|
||||
const response = await fetch(this.mediaserver + hash, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
|
||||
async downloadAsBlob(hash: string): Promise<Blob> {
|
||||
const arrayBuffer = await this.download(hash)
|
||||
return new Blob([arrayBuffer])
|
||||
}
|
||||
|
||||
async list(): Promise<BlobDescriptor[]> {
|
||||
const pubkey = await this.signer.getPublicKey()
|
||||
|
||||
if (!this.isValid32ByteHex(pubkey)) {
|
||||
throw new Error(`pubkey ${pubkey} is not valid`)
|
||||
}
|
||||
|
||||
try {
|
||||
const bds = await this.httpCall(
|
||||
'GET',
|
||||
`list/${pubkey}`,
|
||||
undefined,
|
||||
() =>
|
||||
this.authorizationHeader(evt => {
|
||||
evt.tags.push(['t', 'list'])
|
||||
}),
|
||||
undefined,
|
||||
[],
|
||||
)
|
||||
return bds
|
||||
} catch (error) {
|
||||
throw new Error(`failed to list blobs: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(hash: string): Promise<void> {
|
||||
if (!this.isValid32ByteHex(hash)) {
|
||||
throw new Error(`${hash} is not a valid 32-byte hex string`)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.httpCall(
|
||||
'DELETE',
|
||||
hash,
|
||||
undefined,
|
||||
() =>
|
||||
this.authorizationHeader(evt => {
|
||||
evt.tags.push(['t', 'delete'])
|
||||
evt.tags.push(['x', hash])
|
||||
}),
|
||||
undefined,
|
||||
null,
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(`failed to delete ${hash}: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
package.json
95
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "2.1.2",
|
||||
"version": "2.19.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,6 +20,11 @@
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./core": {
|
||||
"import": "./lib/esm/core.js",
|
||||
"require": "./lib/cjs/core.js",
|
||||
"types": "./lib/types/core.d.ts"
|
||||
},
|
||||
"./pure": {
|
||||
"import": "./lib/esm/pure.js",
|
||||
"require": "./lib/cjs/pure.js",
|
||||
@@ -70,11 +75,6 @@
|
||||
"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",
|
||||
@@ -85,6 +85,9 @@
|
||||
"require": "./lib/cjs/nip06.js",
|
||||
"types": "./lib/types/nip06.d.ts"
|
||||
},
|
||||
"./nip07": {
|
||||
"types": "./lib/types/nip07.d.ts"
|
||||
},
|
||||
"./nip10": {
|
||||
"import": "./lib/esm/nip10.js",
|
||||
"require": "./lib/cjs/nip10.js",
|
||||
@@ -100,6 +103,11 @@
|
||||
"require": "./lib/cjs/nip13.js",
|
||||
"types": "./lib/types/nip13.d.ts"
|
||||
},
|
||||
"./nip17": {
|
||||
"import": "./lib/esm/nip17.js",
|
||||
"require": "./lib/cjs/nip17.js",
|
||||
"types": "./lib/types/nip17.d.ts"
|
||||
},
|
||||
"./nip18": {
|
||||
"import": "./lib/esm/nip18.js",
|
||||
"require": "./lib/cjs/nip18.js",
|
||||
@@ -130,6 +138,11 @@
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip29": {
|
||||
"import": "./lib/esm/nip29.js",
|
||||
"require": "./lib/cjs/nip29.js",
|
||||
"types": "./lib/types/nip29.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
@@ -145,21 +158,76 @@
|
||||
"require": "./lib/cjs/nip42.js",
|
||||
"types": "./lib/types/nip42.d.ts"
|
||||
},
|
||||
"./nip44": {
|
||||
"import": "./lib/esm/nip44.js",
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip46": {
|
||||
"import": "./lib/esm/nip46.js",
|
||||
"require": "./lib/cjs/nip46.js",
|
||||
"types": "./lib/types/nip46.d.ts"
|
||||
},
|
||||
"./nip49": {
|
||||
"import": "./lib/esm/nip49.js",
|
||||
"require": "./lib/cjs/nip49.js",
|
||||
"types": "./lib/types/nip49.d.ts"
|
||||
},
|
||||
"./nip54": {
|
||||
"import": "./lib/esm/nip54.js",
|
||||
"require": "./lib/cjs/nip54.js",
|
||||
"types": "./lib/types/nip54.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
"types": "./lib/types/nip57.d.ts"
|
||||
},
|
||||
"./nip59": {
|
||||
"import": "./lib/esm/nip59.js",
|
||||
"require": "./lib/cjs/nip59.js",
|
||||
"types": "./lib/types/nip59.d.ts"
|
||||
},
|
||||
"./nip58": {
|
||||
"import": "./lib/esm/nip58.js",
|
||||
"require": "./lib/cjs/nip58.js",
|
||||
"types": "./lib/types/nip58.d.ts"
|
||||
},
|
||||
"./nip75": {
|
||||
"import": "./lib/esm/nip75.js",
|
||||
"require": "./lib/cjs/nip75.js",
|
||||
"types": "./lib/types/nip75.d.ts"
|
||||
},
|
||||
"./nip94": {
|
||||
"import": "./lib/esm/nip94.js",
|
||||
"require": "./lib/cjs/nip94.js",
|
||||
"types": "./lib/types/nip94.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
"types": "./lib/types/nip98.d.ts"
|
||||
},
|
||||
"./nip99": {
|
||||
"import": "./lib/esm/nip99.js",
|
||||
"require": "./lib/cjs/nip99.js",
|
||||
"types": "./lib/types/nip99.d.ts"
|
||||
},
|
||||
"./nipb7": {
|
||||
"import": "./lib/esm/nipb7.js",
|
||||
"require": "./lib/cjs/nipb7.js",
|
||||
"types": "./lib/types/nipb7.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
"types": "./lib/types/fakejson.d.ts"
|
||||
},
|
||||
"./signer": {
|
||||
"import": "./lib/esm/signer.js",
|
||||
"require": "./lib/cjs/signer.js",
|
||||
"types": "./lib/types/signer.d.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"import": "./lib/esm/utils.js",
|
||||
"require": "./lib/cjs/utils.js",
|
||||
@@ -168,14 +236,13 @@
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"mitata": "^0.1.6",
|
||||
"nostr-wasm": "v0.1.0"
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
@@ -199,18 +266,16 @@
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4"
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build && just emit-types"
|
||||
"prepublish": "just build"
|
||||
}
|
||||
}
|
||||
|
||||
350
pool.test.ts
350
pool.test.ts
@@ -1,31 +1,30 @@
|
||||
import { test, expect, afterAll } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { finalizeEvent, type Event } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { SimplePool, useWebSocketImplementation } from './pool.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
let pool = new SimplePool()
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
||||
let pool: SimplePool
|
||||
let mockRelays: MockRelay[]
|
||||
let relayURLs: string[]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
beforeEach(() => {
|
||||
pool = new SimplePool()
|
||||
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
|
||||
relayURLs = mockRelays.map(mr => mr.url)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
pool.close(relayURLs)
|
||||
})
|
||||
|
||||
test('removing duplicates when subscribing', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
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),
|
||||
@@ -36,8 +35,21 @@ test('removing duplicates when subscribing', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event)
|
||||
@@ -47,16 +59,24 @@ test('same with double subs', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
},
|
||||
)
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
@@ -70,18 +90,99 @@ test('same with double subs', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('subscribe many map', async () => {
|
||||
let priv = hexToBytes('8ea002840d413ccdd5be98df5dd89d799eaa566355ede83ca0bbdbb4b145e0d3')
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let received: Event[] = []
|
||||
let event1 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test1',
|
||||
kind: 20001,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
let event2 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 20002,
|
||||
tags: [['t', 'biloba']],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
let event3 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test3',
|
||||
kind: 20003,
|
||||
tags: [['t', 'biloba']],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
const [relayA, relayB, relayC] = relayURLs
|
||||
|
||||
pool.subscribeMap(
|
||||
[
|
||||
{ url: relayA, filter: { authors: [pub], kinds: [20001] } },
|
||||
{ url: relayB, filter: { authors: [pub], kinds: [20002] } },
|
||||
{ url: relayC, filter: { kinds: [20003], '#t': ['biloba'] } },
|
||||
],
|
||||
{
|
||||
onevent(event: Event) {
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// publish the first
|
||||
await Promise.all(pool.publish([relayA, relayB], event1))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event1)
|
||||
|
||||
// publish the second
|
||||
await pool.publish([relayB], event2)[0]
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[1]).toEqual(event2)
|
||||
|
||||
// publish a events that shouldn't match our filters
|
||||
await Promise.all([
|
||||
...pool.publish([relayA, relayB], event3),
|
||||
...pool.publish([relayA, relayB, relayC], event1),
|
||||
pool.publish([relayA, relayB, relayC], event2),
|
||||
])
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
|
||||
// publsih the third
|
||||
await pool.publish([relayC], event3)[0]
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(3)
|
||||
expect(received[2]).toEqual(event3)
|
||||
})
|
||||
|
||||
test('query a bunch of events and cancel on eose', async () => {
|
||||
let events = new Set<string>()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeManyEose(
|
||||
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||
[{ kinds: [0, 1], limit: 40 }],
|
||||
relayURLs,
|
||||
{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 },
|
||||
{
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
@@ -90,27 +191,208 @@ test('query a bunch of events and cancel on eose', async () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('querySync()', async () => {
|
||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||
|
||||
let events = await pool.querySync(relayURLs, {
|
||||
authors: authors,
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toBeGreaterThan(2)
|
||||
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'],
|
||||
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||
|
||||
let event = await pool.get(relayURLs, {
|
||||
ids: [ids[0]],
|
||||
})
|
||||
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
||||
expect(event).toHaveProperty('id', ids[0])
|
||||
})
|
||||
|
||||
test('ping-pong timeout in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
pool = new SimplePool({ enablePing: true })
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
})
|
||||
|
||||
test('reconnect on disconnect in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
pool = new SimplePool({ enablePing: true, enableReconnect: true })
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
})
|
||||
|
||||
test('reconnect with filter update in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
pool = new SimplePool({
|
||||
enablePing: true,
|
||||
enableReconnect: filters => {
|
||||
return filters.map(f => ({ ...f, since: newSince }))
|
||||
},
|
||||
})
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
|
||||
expect(sub.filters[0].since).toBe(0)
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
|
||||
// check if filter was updated
|
||||
expect(sub.filters[0].since).toBe(newSince)
|
||||
})
|
||||
|
||||
test('track relays when publishing', async () => {
|
||||
let event1 = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
)
|
||||
let event2 = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
)
|
||||
|
||||
pool.trackRelays = true
|
||||
await Promise.all(pool.publish(relayURLs, event1))
|
||||
expect(pool.seenOn.get(event1.id)).toBeDefined()
|
||||
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
|
||||
|
||||
pool.trackRelays = false
|
||||
await Promise.all(pool.publish(relayURLs, event2))
|
||||
expect(pool.seenOn.get(event2.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
18
pool.ts
18
pool.ts
@@ -1,9 +1,21 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
import { AbstractSimplePool, type AbstractPoolConstructorOptions } from './abstract-pool.ts'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
try {
|
||||
_WebSocket = WebSocket
|
||||
} catch {}
|
||||
|
||||
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
_WebSocket = websocketImplementation
|
||||
}
|
||||
|
||||
export class SimplePool extends AbstractSimplePool {
|
||||
constructor() {
|
||||
super({ verifyEvent })
|
||||
constructor(options?: Pick<AbstractPoolConstructorOptions, 'enablePing' | 'enableReconnect'>) {
|
||||
super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
293
pure.test.ts
Normal file
293
pure.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)
|
||||
})
|
||||
})
|
||||
4
pure.ts
4
pure.ts
@@ -1,6 +1,6 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
@@ -50,7 +50,7 @@ export function getEventHash(event: UnsignedEvent): string {
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const i = new JS()
|
||||
const i: JS = new JS()
|
||||
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
|
||||
428
relay.test.ts
428
relay.test.ts
@@ -1,121 +1,401 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Server } from 'mock-socket'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay, useWebSocketImplementation } from './relay.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
|
||||
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
let relay = new Relay('wss://relay.nostr.bg')
|
||||
|
||||
afterEach(() => {
|
||||
relay.close()
|
||||
})
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
test('connectivity', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity, with Relay.connect()', async () => {
|
||||
const relay = await Relay.connect('wss://public.relaying.io')
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
expect(relay.connected).toBeTrue()
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
test('querying', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
const kind = 0
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let resolveEvent: () => void
|
||||
let resolveEose: () => void
|
||||
|
||||
const evented = new Promise<void>(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const eosed = new Promise<void>(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
||||
kinds: [0],
|
||||
authors: mockRelay.authors,
|
||||
kinds: [kind],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
||||
expect(event).toHaveProperty('kind', 0)
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(mockRelay.authors).toContain(event.pubkey)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await eosed
|
||||
await evented
|
||||
}, 10000)
|
||||
test('listening and publishing and closing', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
test('listening and publishing and closing', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const pk = getPublicKey(sk)
|
||||
const kind = 23571
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
let resolveEose: (_: void) => void
|
||||
let resolveEvent: (_: void) => void
|
||||
let resolveClose: (_: void) => void
|
||||
let eventReceived: NostrEvent | undefined
|
||||
|
||||
const eosed = new Promise(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
const evented = new Promise(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const closed = new Promise(resolve => {
|
||||
resolveClose = resolve
|
||||
})
|
||||
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [23571],
|
||||
kinds: [kind],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
eventReceived = event
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
expect(event).toHaveProperty('content', 'content')
|
||||
|
||||
sub.close() // close the subscription and will trigger onclose()
|
||||
},
|
||||
onclose() {
|
||||
resolveClose()
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await eosed
|
||||
|
||||
let event = finalizeEvent(
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 23571,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind,
|
||||
content: 'content',
|
||||
created_at: 0,
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
await evented
|
||||
sub.close()
|
||||
await closed
|
||||
|
||||
expect(eventReceived).toBeDefined()
|
||||
expect(eventReceived).toHaveProperty('pubkey', pk)
|
||||
expect(eventReceived).toHaveProperty('kind', 23571)
|
||||
expect(eventReceived).toHaveProperty('content', 'nostr-tools test suite')
|
||||
})
|
||||
|
||||
test('publish timeout', async () => {
|
||||
const url = 'wss://relay.example.com'
|
||||
new Server(url)
|
||||
|
||||
const relay = new Relay(url)
|
||||
relay.publishTimeout = 100
|
||||
await relay.connect()
|
||||
|
||||
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
|
||||
|
||||
expect(
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow('publish timed out')
|
||||
})
|
||||
|
||||
test('ping-pong timeout (with native ping)', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
let pingCalled = false
|
||||
|
||||
// mock a native ping/pong mechanism
|
||||
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
|
||||
pingCalled = true
|
||||
if (!mockRelay.unresponsive) {
|
||||
this.dispatchEvent(new Event('pong'))
|
||||
}
|
||||
}
|
||||
;(MockWebSocketClient.prototype as any).once = function (
|
||||
this: any,
|
||||
event: string,
|
||||
listener: (...args: any[]) => void,
|
||||
) {
|
||||
if (event === 'pong') {
|
||||
const onceListener = (...args: any[]) => {
|
||||
this.removeEventListener(event, onceListener)
|
||||
listener.apply(this, args)
|
||||
}
|
||||
this.addEventListener('pong', onceListener)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(pingCalled).toBeTrue()
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
} finally {
|
||||
delete (MockWebSocketClient.prototype as any).ping
|
||||
delete (MockWebSocketClient.prototype as any).once
|
||||
}
|
||||
})
|
||||
|
||||
test('ping-pong timeout (no-ping browser environment)', async () => {
|
||||
// spy on send to ensure the fallback dummy REQ is used, since MockWebSocketClient has no ping
|
||||
const originalSend = MockWebSocketClient.prototype.send
|
||||
let dummyReqSent = false
|
||||
|
||||
try {
|
||||
MockWebSocketClient.prototype.send = function (message: string) {
|
||||
if (message.includes('REQ') && message.includes('a'.repeat(64))) {
|
||||
dummyReqSent = true
|
||||
}
|
||||
originalSend.call(this, message)
|
||||
}
|
||||
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(dummyReqSent).toBeTrue()
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
} finally {
|
||||
MockWebSocketClient.prototype.send = originalSend
|
||||
}
|
||||
})
|
||||
|
||||
test('ping-pong listeners are cleaned up', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
let listenerCount = 0
|
||||
|
||||
// mock a native ping/pong mechanism
|
||||
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
|
||||
if (!mockRelay.unresponsive) {
|
||||
this.dispatchEvent(new Event('pong'))
|
||||
}
|
||||
}
|
||||
|
||||
const originalAddEventListener = MockWebSocketClient.prototype.addEventListener
|
||||
MockWebSocketClient.prototype.addEventListener = function (event, listener, options) {
|
||||
if (event === 'pong') {
|
||||
listenerCount++
|
||||
}
|
||||
// @ts-ignore
|
||||
return originalAddEventListener.call(this, event, listener, options)
|
||||
}
|
||||
|
||||
const originalRemoveEventListener = MockWebSocketClient.prototype.removeEventListener
|
||||
MockWebSocketClient.prototype.removeEventListener = function (event, listener) {
|
||||
if (event === 'pong') {
|
||||
listenerCount--
|
||||
}
|
||||
// @ts-ignore
|
||||
return originalRemoveEventListener.call(this, event, listener)
|
||||
}
|
||||
|
||||
// the check in pingpong() is for .once() so we must mock it
|
||||
;(MockWebSocketClient.prototype as any).once = function (
|
||||
this: any,
|
||||
event: string,
|
||||
listener: (...args: any[]) => void,
|
||||
) {
|
||||
const onceListener = (...args: any[]) => {
|
||||
this.removeEventListener(event, onceListener)
|
||||
listener.apply(this, args)
|
||||
}
|
||||
this.addEventListener(event, onceListener)
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
await relay.connect()
|
||||
await new Promise(resolve => setTimeout(resolve, 175))
|
||||
|
||||
expect(listenerCount).toBeLessThan(2)
|
||||
|
||||
relay.close()
|
||||
} finally {
|
||||
delete (MockWebSocketClient.prototype as any).ping
|
||||
delete (MockWebSocketClient.prototype as any).once
|
||||
MockWebSocketClient.prototype.addEventListener = originalAddEventListener
|
||||
MockWebSocketClient.prototype.removeEventListener = originalRemoveEventListener
|
||||
}
|
||||
})
|
||||
|
||||
test('reconnect on disconnect', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100] // short backoff for testing
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1) // should not have closed again
|
||||
})
|
||||
|
||||
test('reconnect with filter update', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
const relay = new Relay(mockRelay.url, {
|
||||
enablePing: true,
|
||||
enableReconnect: filters => {
|
||||
return filters.map(f => ({ ...f, since: newSince }))
|
||||
},
|
||||
})
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
|
||||
expect(sub.filters[0].since).toBe(0)
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
|
||||
// check if filter was updated
|
||||
expect(sub.filters[0].since).toBe(newSince)
|
||||
})
|
||||
|
||||
32
relay.ts
32
relay.ts
@@ -1,23 +1,33 @@
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
/* global WebSocket */
|
||||
|
||||
/**
|
||||
* @deprecated use Relay.connect() instead.
|
||||
*/
|
||||
export function relayConnect(url: string): Promise<Relay> {
|
||||
return Relay.connect(url)
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractRelay, type AbstractRelayConstructorOptions } from './abstract-relay.ts'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
try {
|
||||
_WebSocket = WebSocket
|
||||
} catch {}
|
||||
|
||||
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
_WebSocket = websocketImplementation
|
||||
}
|
||||
|
||||
export class Relay extends AbstractRelay {
|
||||
constructor(url: string) {
|
||||
super(url, { verifyEvent })
|
||||
constructor(url: string, options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>) {
|
||||
super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options })
|
||||
}
|
||||
|
||||
static async connect(url: string) {
|
||||
const relay = new Relay(url)
|
||||
static async connect(
|
||||
url: string,
|
||||
options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>,
|
||||
): Promise<Relay> {
|
||||
const relay = new Relay(url, options)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayRecord = Record<string, { read: boolean; write: boolean }>
|
||||
|
||||
export * from './abstract-relay.ts'
|
||||
|
||||
23
signer.ts
Normal file
23
signer.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { EventTemplate, VerifiedEvent } from './core.ts'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
|
||||
export interface Signer {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
}
|
||||
|
||||
export class PlainKeySigner implements Signer {
|
||||
private secretKey: Uint8Array
|
||||
|
||||
constructor(secretKey: Uint8Array) {
|
||||
this.secretKey = secretKey
|
||||
}
|
||||
|
||||
async getPublicKey(): Promise<string> {
|
||||
return getPublicKey(this.secretKey)
|
||||
}
|
||||
|
||||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||||
return finalizeEvent(event, this.secretKey)
|
||||
}
|
||||
}
|
||||
111
test-helpers.ts
111
test-helpers.ts
@@ -1,6 +1,10 @@
|
||||
import type { Event } from './pure.ts'
|
||||
import { Server, WebSocket } from 'mock-socket'
|
||||
|
||||
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
|
||||
export const MockWebSocketClient = WebSocket
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
@@ -13,3 +17,106 @@ export function buildEvent(params: Partial<Event>): Event {
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
let serial = 0
|
||||
|
||||
export class MockRelay {
|
||||
private _server: Server
|
||||
|
||||
public url: string
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
public unresponsive: boolean = false
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
this.url = url ?? `wss://random.mock.relay/${serial}`
|
||||
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||
this.preloadedEvents = this.secretKeys.map(sk =>
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
content: 'autogenerated by relay',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['t', 'auto']],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
this._server = new Server(this.url)
|
||||
this._server.on('connection', (conn: any) => {
|
||||
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||
|
||||
conn.on('message', (message: string) => {
|
||||
if (this.unresponsive) return
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data[0]) {
|
||||
case 'REQ': {
|
||||
let subId = data[1]
|
||||
let filters = data.slice(2)
|
||||
subs[subId] = { conn, filters }
|
||||
|
||||
this.preloadedEvents.forEach(event => {
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
|
||||
filters.forEach((filter: Filter) => {
|
||||
const kinds = filter.kinds?.length ? filter.kinds : [1]
|
||||
|
||||
kinds.forEach(kind => {
|
||||
this.secretKeys.forEach(sk => {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: 'kind-aware autogenerated by relay',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['t', 'auto']],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
conn.send(JSON.stringify(['EOSE', subId]))
|
||||
|
||||
break
|
||||
}
|
||||
case 'CLOSE': {
|
||||
let subId = data[1]
|
||||
delete subs[subId]
|
||||
|
||||
break
|
||||
}
|
||||
case 'EVENT': {
|
||||
let event = data[1]
|
||||
|
||||
conn.send(JSON.stringify(['OK', event.id, 'true']))
|
||||
|
||||
for (let subId in subs) {
|
||||
const { filters, conn: listener } = subs[subId]
|
||||
|
||||
if (matchFilters(filters, event)) {
|
||||
listener.send(JSON.stringify(['EVENT', subId, event]))
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get authors() {
|
||||
return this.secretKeys.map(getPublicKey)
|
||||
}
|
||||
|
||||
get ids() {
|
||||
return this.preloadedEvents.map(evt => evt.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"module": "NodeNext",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "NodeNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||
import {
|
||||
Queue,
|
||||
insertEventIntoAscendingList,
|
||||
insertEventIntoDescendingList,
|
||||
binarySearch,
|
||||
normalizeURL,
|
||||
} from './utils.ts'
|
||||
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
@@ -263,3 +269,43 @@ test('binary search', () => {
|
||||
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])
|
||||
})
|
||||
|
||||
describe('normalizeURL', () => {
|
||||
test('normalizes wss:// URLs', () => {
|
||||
expect(normalizeURL('wss://example.com')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('wss://example.com/')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('wss://example.com//path')).toBe('wss://example.com/path')
|
||||
expect(normalizeURL('wss://example.com:443')).toBe('wss://example.com/')
|
||||
})
|
||||
|
||||
test('normalizes https:// URLs', () => {
|
||||
expect(normalizeURL('https://example.com')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('https://example.com/')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('http://example.com//path')).toBe('ws://example.com/path')
|
||||
})
|
||||
|
||||
test('normalizes ws:// URLs', () => {
|
||||
expect(normalizeURL('ws://example.com')).toBe('ws://example.com/')
|
||||
expect(normalizeURL('ws://example.com/')).toBe('ws://example.com/')
|
||||
expect(normalizeURL('ws://example.com//path')).toBe('ws://example.com/path')
|
||||
expect(normalizeURL('ws://example.com:80')).toBe('ws://example.com/')
|
||||
})
|
||||
|
||||
test('adds wss:// to URLs without scheme', () => {
|
||||
expect(normalizeURL('example.com')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('example.com/')).toBe('wss://example.com/')
|
||||
expect(normalizeURL('example.com//path')).toBe('wss://example.com/path')
|
||||
})
|
||||
|
||||
test('handles query parameters', () => {
|
||||
expect(normalizeURL('wss://example.com?z=1&a=2')).toBe('wss://example.com/?a=2&z=1')
|
||||
})
|
||||
|
||||
test('removes hash', () => {
|
||||
expect(normalizeURL('wss://example.com#hash')).toBe('wss://example.com/')
|
||||
})
|
||||
|
||||
test('throws on invalid URL', () => {
|
||||
expect(() => normalizeURL('http://')).toThrow('Invalid URL: http://')
|
||||
})
|
||||
})
|
||||
|
||||
20
utils.ts
20
utils.ts
@@ -1,19 +1,28 @@
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder: TextEncoder = new TextEncoder()
|
||||
|
||||
export { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
try {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
if (p.protocol === 'http:') p.protocol = 'ws:'
|
||||
else if (p.protocol === 'https:') p.protocol = 'wss:'
|
||||
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()
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid URL: ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], 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
|
||||
@@ -25,7 +34,7 @@ export function insertEventIntoDescendingList(sortedArray: Event[], event: Event
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], 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
|
||||
@@ -110,6 +119,9 @@ export class Queue<V> {
|
||||
|
||||
const target = this.first
|
||||
this.first = target.next
|
||||
if (this.first) {
|
||||
this.first.prev = null // fix: clean up prev pointer
|
||||
}
|
||||
|
||||
return target.value
|
||||
}
|
||||
|
||||
4
wasm.ts
4
wasm.ts
@@ -1,6 +1,6 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core.ts'
|
||||
|
||||
let nw: NostrWasm
|
||||
|
||||
@@ -30,7 +30,7 @@ class Wasm implements Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
const i = new Wasm()
|
||||
const i: Wasm = new Wasm()
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
|
||||
Reference in New Issue
Block a user