mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498c1603b0 | ||
|
|
4cfc67e294 | ||
|
|
da51418f04 | ||
|
|
75df47421f | ||
|
|
1cfe705baf | ||
|
|
566437fe2e | ||
|
|
5d6c2b9e5d | ||
|
|
a43f2a708c | ||
|
|
f727058a3a | ||
|
|
1de54838d3 | ||
|
|
703c29a311 | ||
|
|
ddf1064da9 | ||
|
|
f719d99a11 | ||
|
|
6152238d65 | ||
|
|
9ac1b63994 | ||
|
|
1890c91ae3 | ||
|
|
7067b47cd4 | ||
|
|
397931f847 | ||
|
|
5d795c291f | ||
|
|
7adbd30799 | ||
|
|
83b6dd7ec3 | ||
|
|
d61cc6c9bf | ||
|
|
d7dad8e204 | ||
|
|
daaa2ef0a1 | ||
|
|
7f11c0c618 | ||
|
|
a4ae964ee6 | ||
|
|
1f7378ca49 | ||
|
|
d155bcdcda | ||
|
|
919d29363e | ||
|
|
ef12a451be | ||
|
|
a9acdada19 | ||
|
|
bf3818e434 | ||
|
|
b7389be5c7 | ||
|
|
7552a36ff2 | ||
|
|
1b31a27d89 | ||
|
|
0cc3c02d84 | ||
|
|
8625d45152 | ||
|
|
8f03116687 | ||
|
|
e6d1808fda | ||
|
|
9648de3470 | ||
|
|
fe87529646 | ||
|
|
1908e1ee0d | ||
|
|
2571db9afc | ||
|
|
f77b9eab10 | ||
|
|
71b412657f | ||
|
|
8840c4d8e2 | ||
|
|
804403f574 | ||
|
|
965ebdb6d1 | ||
|
|
c54fd95b3e | ||
|
|
7a6c0754ad | ||
|
|
9e4911160a | ||
|
|
73c6630cf7 | ||
|
|
88703e9ea2 | ||
|
|
07d208308f | ||
|
|
f56f2ae709 | ||
|
|
a0cb2eecae | ||
|
|
2a7fd83be8 | ||
|
|
1ebe098805 | ||
|
|
3bfb50e267 | ||
|
|
420a6910e9 | ||
|
|
7a640092d0 | ||
|
|
3d541e537e | ||
|
|
1357642575 | ||
|
|
d16f3f77c3 | ||
|
|
0108e3b605 | ||
|
|
2ac69278ce | ||
|
|
bf31f2eba3 | ||
|
|
39cfc5c09e | ||
|
|
3d767beeb9 | ||
|
|
36e0de2a68 | ||
|
|
9cd4f16e45 | ||
|
|
6a07e7c1cc | ||
|
|
1939c46eaa | ||
|
|
93538d2373 | ||
|
|
19b3faea17 | ||
|
|
867aa11d12 | ||
|
|
4fcf925387 | ||
|
|
40c5337ef0 | ||
|
|
350d8ec3b6 | ||
|
|
c5f3c8052e | ||
|
|
dc04d1eb85 | ||
|
|
a2a15567b7 | ||
|
|
318e3f8c88 | ||
|
|
894ffff1f0 | ||
|
|
ce11a5fc89 | ||
|
|
5e85bbc2ed | ||
|
|
eb0a9093f2 | ||
|
|
c73268c4e2 | ||
|
|
6874f58c0a | ||
|
|
e899cc32b7 | ||
|
|
de72172583 | ||
|
|
073dcaafd6 | ||
|
|
8e932f0c5a | ||
|
|
f9a048679f | ||
|
|
6db8b94275 | ||
|
|
13bc2ad5a8 | ||
|
|
55f032d0a4 | ||
|
|
c890e29290 | ||
|
|
c18f050468 | ||
|
|
401b9c7864 | ||
|
|
c175f6c804 | ||
|
|
41265a19f5 | ||
|
|
d88761907a | ||
|
|
8325d4351e | ||
|
|
62bf592d72 | ||
|
|
54f3bedf38 | ||
|
|
34e0ad8c41 | ||
|
|
e9eac28bab | ||
|
|
85035d61f2 | ||
|
|
cf46560619 | ||
|
|
e7aa23cb1d | ||
|
|
5977d68ec2 | ||
|
|
48767d382d | ||
|
|
718032022c | ||
|
|
2a70bb18ff | ||
|
|
9effe807d1 | ||
|
|
899c2bd0dc | ||
|
|
918d514a25 | ||
|
|
48cb9046c4 | ||
|
|
864dd28b26 | ||
|
|
fa085367c9 | ||
|
|
350951b88e | ||
|
|
c6133f7160 | ||
|
|
470512bbeb | ||
|
|
c3acb82464 | ||
|
|
fc23d05764 | ||
|
|
8296ce897c | ||
|
|
3ca78c0e13 | ||
|
|
837a05e54d | ||
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 |
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
@@ -34,23 +35,22 @@
|
||||
|
||||
"rules": {
|
||||
"accessor-pairs": 2,
|
||||
"arrow-spacing": [2, {"before": true, "after": true}],
|
||||
"arrow-spacing": [2, { "before": true, "after": true }],
|
||||
"block-spacing": [2, "always"],
|
||||
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
|
||||
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
|
||||
"comma-dangle": 0,
|
||||
"comma-spacing": [2, {"before": false, "after": true}],
|
||||
"comma-spacing": [2, { "before": false, "after": true }],
|
||||
"comma-style": [2, "last"],
|
||||
"constructor-super": 2,
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"generator-star-spacing": [2, {"before": true, "after": true}],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
|
||||
"keyword-spacing": [2, {"before": true, "after": true}],
|
||||
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||
"keyword-spacing": [2, { "before": true, "after": true }],
|
||||
"new-cap": 0,
|
||||
"new-parens": 0,
|
||||
"no-array-constructor": 2,
|
||||
@@ -82,12 +82,12 @@
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-iterator": 2,
|
||||
"no-label-var": 2,
|
||||
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
|
||||
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
|
||||
"no-lone-blocks": 2,
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-multiple-empty-lines": [2, {"max": 2}],
|
||||
"no-multiple-empty-lines": [2, { "max": 2 }],
|
||||
"no-native-reassign": 2,
|
||||
"no-negated-in-lhs": 2,
|
||||
"no-new": 0,
|
||||
@@ -115,34 +115,23 @@
|
||||
"no-undef": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
|
||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||
"no-unreachable": 2,
|
||||
"no-unused-vars": [
|
||||
2,
|
||||
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
|
||||
],
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
"one-var": [0, {"initialized": "never"}],
|
||||
"operator-linebreak": [
|
||||
2,
|
||||
"after",
|
||||
{"overrides": {"?": "before", ":": "before"}}
|
||||
],
|
||||
"one-var": [0, { "initialized": "never" }],
|
||||
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
|
||||
"padded-blocks": [2, "never"],
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
{"avoidEscape": true, "allowTemplateLiterals": true}
|
||||
],
|
||||
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
|
||||
"semi": [2, "never"],
|
||||
"semi-spacing": [2, {"before": false, "after": true}],
|
||||
"semi-spacing": [2, { "before": false, "after": true }],
|
||||
"space-before-blocks": [2, "always"],
|
||||
"space-before-function-paren": 0,
|
||||
"space-in-parens": [2, "never"],
|
||||
"space-infix-ops": 2,
|
||||
"space-unary-ops": [2, {"words": true, "nonwords": false}],
|
||||
"space-unary-ops": [2, { "words": true, "nonwords": false }],
|
||||
"spaced-comment": 0,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
"use-isnan": 2,
|
||||
@@ -150,13 +139,5 @@
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": [0]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts"],
|
||||
"env": { "jest/globals": true },
|
||||
"plugins": ["jest"],
|
||||
"extends": ["plugin:jest/recommended"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
23
.github/workflows/npm-publish.yml
vendored
23
.github/workflows/npm-publish.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: publish npm package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just build
|
||||
- run: just test
|
||||
- run: just emit-types
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
greater-version-only: true
|
||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -10,9 +10,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: bun i
|
||||
- run: just test
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: bun i
|
||||
- run: just lint
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ package-lock.json
|
||||
.envrc
|
||||
lib
|
||||
test.html
|
||||
bench.js
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
semi: false
|
||||
arrowParens: avoid
|
||||
bracketSpacing: true
|
||||
insertPragma: false
|
||||
printWidth: 80
|
||||
printWidth: 120
|
||||
proseWrap: preserve
|
||||
semi: false
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
jsxBracketSameLine: false
|
||||
bracketSpacing: false
|
||||
|
||||
290
README.md
290
README.md
@@ -1,4 +1,4 @@
|
||||
# nostr-tools
|
||||
#  nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
@@ -12,114 +12,81 @@ This package is only providing lower-level functionality. If you want an easy-to
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
```
|
||||
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey() // `sk` is a hex string
|
||||
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
```
|
||||
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import {
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
getSignature,
|
||||
getEventHash,
|
||||
getPublicKey
|
||||
} from 'nostr-tools'
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
|
||||
let event = {
|
||||
let event = finalizeEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
pubkey: getPublicKey(privateKey)
|
||||
}
|
||||
}, sk)
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
|
||||
let ok = validateEvent(event)
|
||||
let veryOk = verifySignature(event)
|
||||
let isGood = verifyEvent(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
|
||||
```js
|
||||
import {
|
||||
relayInit,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
getSignature
|
||||
} from 'nostr-tools'
|
||||
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const relay = relayInit('wss://relay.example.com')
|
||||
relay.on('connect', () => {
|
||||
console.log(`connected to ${relay.url}`)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
console.log(`failed to connect to ${relay.url}`)
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
const relay = await Relay.connect('wss://relay.example.com')
|
||||
console.log(`connected to ${relay.url}`)
|
||||
|
||||
// let's query for an event that exists
|
||||
let sub = relay.sub([
|
||||
const sub = relay.subscribe([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
}
|
||||
])
|
||||
sub.on('event', event => {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generatePrivateKey()
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
let sub = relay.sub([
|
||||
relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk]
|
||||
}
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
authors: [pk],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
})
|
||||
|
||||
let event = {
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
pubkey: pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello world'
|
||||
content: 'hello world',
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, sk)
|
||||
|
||||
let pub = relay.publish(event)
|
||||
pub.on('ok', () => {
|
||||
console.log(`${relay.url} has accepted our event`)
|
||||
})
|
||||
pub.on('failed', reason => {
|
||||
console.log(`failed to publish to ${relay.url}: ${reason}`)
|
||||
})
|
||||
|
||||
let events = await relay.list([{kinds: [0, 1]}])
|
||||
let event = await relay.get({
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
||||
})
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
|
||||
relay.close()
|
||||
```
|
||||
@@ -133,54 +100,48 @@ import 'websocket-polyfill'
|
||||
### Interacting with multiple relays
|
||||
|
||||
```js
|
||||
import {SimplePool} from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
|
||||
let sub = pool.sub(
|
||||
let h = pool.subscribeMany(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: [
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
sub.on('event', event => {
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
})
|
||||
|
||||
let pubs = pool.publish(relays, newEvent)
|
||||
pubs.on('ok', () => {
|
||||
// this may be called multiple times, once for every relay that accepts the event
|
||||
// ...
|
||||
})
|
||||
|
||||
let events = await pool.list(relays, [{kinds: [0, 1]}])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
||||
})
|
||||
|
||||
let relaysForEvent = pool.seenOn(
|
||||
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
},
|
||||
oneose() {
|
||||
h.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
// relaysForEvent will be an array of URLs from relays a given event was seen on
|
||||
|
||||
await Promise.any(pool.publish(relays, newEvent))
|
||||
console.log('published to at least one relay!')
|
||||
|
||||
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
```
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
|
||||
```js
|
||||
import {parseReferences} from 'nostr-tools'
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
let {text, profile, event, address} = references[i]
|
||||
let { text, profile, event, address } = references[i]
|
||||
let augmentedReference = profile
|
||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||
: event
|
||||
@@ -195,7 +156,7 @@ for (let i = 0; i < references.length; i++) {
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import {nip05} from 'nostr-tools'
|
||||
import { nip05 } from 'nostr-tools'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
@@ -204,7 +165,7 @@ console.log(profile.relays)
|
||||
// prints: [wss://relay.damus.io]
|
||||
```
|
||||
|
||||
To use this on Node.js you first must install `node-fetch@2` and call something like this:
|
||||
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||
|
||||
```js
|
||||
nip05.useFetchImplementation(require('node-fetch'))
|
||||
@@ -213,117 +174,102 @@ nip05.useFetchImplementation(require('node-fetch'))
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
|
||||
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey()
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
let {type, data} = nip19.decode(nsec)
|
||||
let { type, data } = nip19.decode(nsec)
|
||||
assert(type === 'nsec')
|
||||
assert(data === sk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
let {type, data} = nip19.decode(npub)
|
||||
let { type, data } = nip19.decode(npub)
|
||||
assert(type === 'npub')
|
||||
assert(data === pk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
||||
let {type, data} = nip19.decode(nprofile)
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||
let { type, data } = nip19.decode(nprofile)
|
||||
assert(type === 'nprofile')
|
||||
assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
### Encrypting and decrypting direct messages
|
||||
## 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 {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
||||
|
||||
// sender
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
|
||||
// receiver
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
// on the sender side
|
||||
let message = 'hello'
|
||||
let ciphertext = await nip04.encrypt(sk1, pk2, message)
|
||||
|
||||
let event = {
|
||||
kind: 4,
|
||||
pubkey: pk1,
|
||||
tags: [['p', pk2]],
|
||||
content: ciphertext,
|
||||
...otherProperties
|
||||
}
|
||||
|
||||
sendEvent(event)
|
||||
|
||||
// on the receiver side
|
||||
sub.on('event', event => {
|
||||
let sender = event.pubkey
|
||||
pk1 === sender
|
||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||
})
|
||||
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
|
||||
```
|
||||
|
||||
### Performing and checking for delegation
|
||||
### Using it with `nostr-wasm`
|
||||
|
||||
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||
|
||||
```js
|
||||
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
||||
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
|
||||
// delegator
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||
initNostrWasm().then(setNostrWasm)
|
||||
|
||||
// delegatee
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
// generate delegation
|
||||
let delegation = nip26.createDelegation(sk1, {
|
||||
pubkey: pk2,
|
||||
kind: 1,
|
||||
since: Math.round(Date.now() / 1000),
|
||||
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */
|
||||
})
|
||||
|
||||
// the delegatee uses the delegation when building an event
|
||||
let event = {
|
||||
pubkey: pk2,
|
||||
kind: 1,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'hello from a delegated key',
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]]
|
||||
}
|
||||
|
||||
// finally any receiver of this event can check for the presence of a valid delegation tag
|
||||
let delegator = nip26.getDelegator(event)
|
||||
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
|
||||
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||
```
|
||||
|
||||
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
|
||||
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
|
||||
|
||||
```js
|
||||
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
|
||||
initNostrWasm().then(setNostrWasm)
|
||||
|
||||
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||
const pool = new AbstractSimplePool({ verifyEvent })
|
||||
```
|
||||
|
||||
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
|
||||
|
||||
```
|
||||
benchmark time (avg) (min … max) p75 p99 p995
|
||||
------------------------------------------------- -----------------------------
|
||||
• relay read message and verify event (many events)
|
||||
------------------------------------------------- -----------------------------
|
||||
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
|
||||
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
|
||||
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
|
||||
|
||||
summary for relay read message and verify event
|
||||
wasm
|
||||
86.77x slower than trusted
|
||||
6.86x faster than pure js
|
||||
```
|
||||
|
||||
### Using from the browser (if you don't want to use a bundler)
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
<script>
|
||||
window.NostrTools.generatePrivateKey('...') // and so on
|
||||
window.NostrTools.generateSecretKey('...') // and so on
|
||||
</script>
|
||||
```
|
||||
|
||||
## Plumbing
|
||||
|
||||
1. Install [`just`](https://just.systems/)
|
||||
2. `just -l`
|
||||
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
190
abstract-pool.ts
Normal file
190
abstract-pool.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event, Nostr } from './core.ts'
|
||||
import { type Filter } from './filter.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
export type SubCloser = { close: () => void }
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export class AbstractSimplePool {
|
||||
private relays = new Map<string, AbstractRelay>()
|
||||
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public verifyEvent: Nostr['verifyEvent']
|
||||
public trustedRelayURLs = new Set<string>()
|
||||
|
||||
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
}
|
||||
|
||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||
url = normalizeURL(url)
|
||||
|
||||
let relay = this.relays.get(url)
|
||||
if (!relay) {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
})
|
||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||
this.relays.set(url, relay)
|
||||
}
|
||||
await relay.connect()
|
||||
|
||||
return relay
|
||||
}
|
||||
|
||||
close(relays: string[]) {
|
||||
relays.map(normalizeURL).forEach(url => {
|
||||
this.relays.get(url)?.close()
|
||||
})
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(id, set)
|
||||
}
|
||||
set.add(relay)
|
||||
}
|
||||
}
|
||||
|
||||
const _knownIds = new Set<string>()
|
||||
const subs: Subscription[] = []
|
||||
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
}
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relays.length) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const localAlreadyHaveEventHandler = (id: string) => {
|
||||
if (params.alreadyHaveEvent?.(id)) {
|
||||
return true
|
||||
}
|
||||
const have = _knownIds.has(id)
|
||||
_knownIds.add(id)
|
||||
return have
|
||||
}
|
||||
|
||||
// open a subscription in all given relays
|
||||
const allOpened = Promise.all(
|
||||
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
handleClose(i, 'duplicate url')
|
||||
return
|
||||
}
|
||||
|
||||
let relay: AbstractRelay
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
handleClose(i, (err as any)?.message || String(err))
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => handleClose(i, reason),
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
|
||||
subs.push(subscription)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
async close() {
|
||||
await allOpened
|
||||
subs.forEach(sub => {
|
||||
sub.close()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close()
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
}
|
||||
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
this.subscribeManyEose(relays, [filter], {
|
||||
...params,
|
||||
onevent(event: Event) {
|
||||
events.push(event)
|
||||
},
|
||||
onclose(_: string[]) {
|
||||
resolve(events)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event): Promise<string>[] {
|
||||
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
return Promise.reject('duplicate url')
|
||||
}
|
||||
|
||||
let r = await this.ensureRelay(url)
|
||||
return r.publish(event)
|
||||
})
|
||||
}
|
||||
}
|
||||
359
abstract-relay.ts
Normal file
359
abstract-relay.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { Queue, normalizeURL } from './utils.ts'
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { yieldThread } from './helpers.ts'
|
||||
|
||||
export class AbstractRelay {
|
||||
public readonly url: string
|
||||
private _connected: boolean = false
|
||||
|
||||
public onclose: (() => void) | null = null
|
||||
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||
|
||||
public baseEoseTimeout: number = 4400
|
||||
public connectionTimeout: number = 4400
|
||||
public openSubs = new Map<string, Subscription>()
|
||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
private connectionPromise: Promise<void> | undefined
|
||||
private openCountRequests = new Map<string, CountResolver>()
|
||||
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||
private ws: WebSocket | undefined
|
||||
private incomingMessageQueue = new Queue<string>()
|
||||
private queueRunning = false
|
||||
private challenge: string | undefined
|
||||
private serial: number = 0
|
||||
private verifyEvent: Nostr['verifyEvent']
|
||||
|
||||
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
this.url = normalizeURL(url)
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
}
|
||||
|
||||
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
const relay = new AbstractRelay(url, opts)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
private closeAllSubscriptions(reason: string) {
|
||||
for (let [_, sub] of this.openSubs) {
|
||||
sub.close(reason)
|
||||
}
|
||||
this.openSubs.clear()
|
||||
|
||||
for (let [_, ep] of this.openEventPublishes) {
|
||||
ep.reject(new Error(reason))
|
||||
}
|
||||
this.openEventPublishes.clear()
|
||||
|
||||
for (let [_, cr] of this.openCountRequests) {
|
||||
cr.reject(new Error(reason))
|
||||
}
|
||||
this.openCountRequests.clear()
|
||||
}
|
||||
|
||||
public get connected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionPromise) return this.connectionPromise
|
||||
|
||||
this.challenge = undefined
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connectionTimeoutHandle = setTimeout(() => {
|
||||
reject('connection timed out')
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection timed out')
|
||||
}, this.connectionTimeout)
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
this._connected = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = ev => {
|
||||
reject((ev as any).message)
|
||||
if (this._connected) {
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection errored')
|
||||
this._connected = false
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
this._connected = false
|
||||
}
|
||||
|
||||
this.ws.onmessage = this._onmessage.bind(this)
|
||||
})
|
||||
|
||||
return this.connectionPromise
|
||||
}
|
||||
|
||||
private async runQueue() {
|
||||
this.queueRunning = true
|
||||
while (true) {
|
||||
if (false === this.handleNext()) {
|
||||
break
|
||||
}
|
||||
await yieldThread()
|
||||
}
|
||||
this.queueRunning = false
|
||||
}
|
||||
|
||||
private handleNext(): undefined | false {
|
||||
const json = this.incomingMessageQueue.dequeue()
|
||||
if (!json) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
const so = this.openSubs.get(subid as string)
|
||||
if (!so) {
|
||||
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
||||
return
|
||||
}
|
||||
|
||||
// this will be called only when this message is a EVENT message for a subscription we have
|
||||
// we do this before parsing the JSON to not have to do that for duplicate events
|
||||
// since JSON parsing is slow
|
||||
const id = getHex64(json, 'id')
|
||||
const alreadyHave = so.alreadyHaveEvent?.(id)
|
||||
|
||||
// notify any interested client that the relay has this event
|
||||
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
||||
so.receivedEvent?.(this, id)
|
||||
|
||||
if (alreadyHave) {
|
||||
// if we had already seen this event we can just stop here
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json)
|
||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||
const event = data[2] as Event
|
||||
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
|
||||
so.onevent(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT': {
|
||||
const id: string = data[1]
|
||||
const payload = data[2] as { count: number }
|
||||
const cr = this.openCountRequests.get(id) as CountResolver
|
||||
if (cr) {
|
||||
cr.resolve(payload.count)
|
||||
this.openCountRequests.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'EOSE': {
|
||||
const so = this.openSubs.get(data[1] as string)
|
||||
if (!so) return
|
||||
so.receivedEose()
|
||||
return
|
||||
}
|
||||
case 'OK': {
|
||||
const id: string = data[1]
|
||||
const ok: boolean = data[2]
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
return
|
||||
}
|
||||
case 'CLOSED': {
|
||||
const id: string = data[1]
|
||||
const so = this.openSubs.get(id)
|
||||
if (!so) return
|
||||
so.closed = true
|
||||
so.close(data[2] as string)
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
this.onnotice(data[1] as string)
|
||||
return
|
||||
case 'AUTH': {
|
||||
this.challenge = data[1] as string
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
public async send(message: string) {
|
||||
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||
|
||||
this.connectionPromise.then(() => {
|
||||
this.ws?.send(message)
|
||||
})
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
})
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
||||
this.serial++
|
||||
const id = params?.id || 'count:' + this.serial
|
||||
const ret = new Promise<number>((resolve, reject) => {
|
||||
this.openCountRequests.set(id, { resolve, reject })
|
||||
})
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.closeAllSubscriptions('relay connection closed by us')
|
||||
this._connected = false
|
||||
this.ws?.close()
|
||||
}
|
||||
|
||||
// this is the function assigned to this.ws.onmessage
|
||||
// it's exposed for testing and debugging purposes
|
||||
public _onmessage(ev: MessageEvent<any>) {
|
||||
this.incomingMessageQueue.enqueue(ev.data as string)
|
||||
if (!this.queueRunning) {
|
||||
this.runQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Subscription {
|
||||
public readonly relay: AbstractRelay
|
||||
public readonly id: string
|
||||
|
||||
public closed: boolean = false
|
||||
public eosed: boolean = false
|
||||
public filters: Filter[]
|
||||
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
||||
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
|
||||
|
||||
public onevent: (evt: Event) => void
|
||||
public oneose: (() => void) | undefined
|
||||
public onclose: ((reason: string) => void) | undefined
|
||||
|
||||
public eoseTimeout: number
|
||||
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||
this.relay = relay
|
||||
this.filters = filters
|
||||
this.id = id
|
||||
this.alreadyHaveEvent = params.alreadyHaveEvent
|
||||
this.receivedEvent = params.receivedEvent
|
||||
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
||||
|
||||
this.oneose = params.oneose
|
||||
this.onclose = params.onclose
|
||||
this.onevent =
|
||||
params.onevent ||
|
||||
(event => {
|
||||
console.warn(
|
||||
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
||||
event,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public fire() {
|
||||
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
||||
|
||||
// only now we start counting the eoseTimeout
|
||||
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
||||
}
|
||||
|
||||
public receivedEose() {
|
||||
if (this.eosed) return
|
||||
clearTimeout(this.eoseTimeoutHandle)
|
||||
this.eosed = true
|
||||
this.oneose?.()
|
||||
}
|
||||
|
||||
public close(reason: string = 'closed by caller') {
|
||||
if (!this.closed) {
|
||||
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||
// otherwise this._open will be already set to false so we will skip this
|
||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||
this.closed = true
|
||||
}
|
||||
this.relay.openSubs.delete(this.id)
|
||||
this.onclose?.(reason)
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscriptionParams = {
|
||||
onevent?: (evt: Event) => void
|
||||
oneose?: () => void
|
||||
onclose?: (reason: string) => void
|
||||
alreadyHaveEvent?: (id: string) => boolean
|
||||
receivedEvent?: (relay: AbstractRelay, id: string) => void
|
||||
eoseTimeout?: number
|
||||
}
|
||||
|
||||
export type CountResolver = {
|
||||
resolve: (count: number) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
61
benchmarks.ts
Normal file
61
benchmarks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { run, bench, group, baseline } from 'mitata'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
import { NostrEvent } from './core'
|
||||
import { finalizeEvent, generateSecretKey } from './pure'
|
||||
import { setNostrWasm, verifyEvent } from './wasm'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
import { Relay as PureRelay } from './relay.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
// benchmarking relay reads with verifyEvent
|
||||
const EVENTS = 200
|
||||
let messages: string[] = []
|
||||
let baseContent = ''
|
||||
for (let i = 0; i < EVENTS; i++) {
|
||||
baseContent += 'a'
|
||||
}
|
||||
const secretKey = generateSecretKey()
|
||||
for (let i = 0; i < EVENTS; i++) {
|
||||
const tags = []
|
||||
for (let t = 0; t < i; t++) {
|
||||
tags.push(['t', 'nada'])
|
||||
}
|
||||
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
|
||||
const signed = finalizeEvent(event, secretKey)
|
||||
messages.push(JSON.stringify(['EVENT', '_', signed]))
|
||||
}
|
||||
|
||||
setNostrWasm(await initNostrWasm())
|
||||
|
||||
const pureRelay = new PureRelay('wss://pure.com/')
|
||||
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
|
||||
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
|
||||
|
||||
const runWith = (relay: AbstractRelay) => async () => {
|
||||
return new Promise<void>(resolve => {
|
||||
let received = 0
|
||||
let sub = relay.prepareSubscription([{}], {
|
||||
id: '_',
|
||||
onevent(_: NostrEvent) {
|
||||
received++
|
||||
if (received === messages.length - 1) {
|
||||
resolve()
|
||||
sub.closed = true
|
||||
sub.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
for (let e = 0; e < messages.length; e++) {
|
||||
relay._onmessage({ data: messages[e] } as any)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
group(`relay read ${EVENTS} messages and verify its events`, () => {
|
||||
baseline('wasm', runWith(wasmRelay))
|
||||
bench('pure js', runWith(pureRelay))
|
||||
bench('trusted', runWith(trustedRelay))
|
||||
})
|
||||
|
||||
// actually running the thing
|
||||
await run()
|
||||
55
build.js
55
build.js
@@ -1,47 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const fs = require('node:fs')
|
||||
const esbuild = require('esbuild')
|
||||
const { join } = require('path')
|
||||
|
||||
const entryPoints = fs
|
||||
.readdirSync(process.cwd())
|
||||
.filter(
|
||||
file =>
|
||||
file.endsWith('.ts') &&
|
||||
file !== 'core.ts' &&
|
||||
file !== 'test-helpers.ts' &&
|
||||
file !== 'helpers.ts' &&
|
||||
file !== 'benchmarks.ts' &&
|
||||
!file.endsWith('.test.ts') &&
|
||||
fs.statSync(join(process.cwd(), file)).isFile(),
|
||||
)
|
||||
|
||||
let common = {
|
||||
entryPoints: ['index.ts'],
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
sourcemap: 'external'
|
||||
sourcemap: 'external',
|
||||
}
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outfile: 'lib/esm/nostr.mjs',
|
||||
outdir: 'lib/esm',
|
||||
format: 'esm',
|
||||
packages: 'external'
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('esm build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outdir: 'lib/cjs',
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({type: 'module'})
|
||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
||||
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('esm build success.')
|
||||
console.log('cjs build success.')
|
||||
})
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
outfile: 'lib/nostr.cjs.js',
|
||||
format: 'cjs',
|
||||
packages: 'external'
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
...common,
|
||||
entryPoints: ['index.ts'],
|
||||
outfile: 'lib/nostr.bundle.js',
|
||||
format: 'iife',
|
||||
globalName: 'NostrTools',
|
||||
define: {
|
||||
window: 'self',
|
||||
global: 'self',
|
||||
process: '{"env": {}}'
|
||||
}
|
||||
process: '{"env": {}}',
|
||||
},
|
||||
})
|
||||
.then(() => console.log('standalone build success.'))
|
||||
|
||||
293
core.test.ts
Normal file
293
core.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import {
|
||||
finalizeEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifyEvent,
|
||||
verifiedSymbol,
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
} from './pure.ts'
|
||||
import { ShortTextNote } from './kinds.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
|
||||
describe('finalizeEvent', () => {
|
||||
test('should create a signed event from a template', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finalizeEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
test('should serialize a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
test('should return the correct event hash', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid properties', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid tags', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEvent', () => {
|
||||
test('should return true for a valid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifyEvent({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event id', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
51
core.ts
Normal file
51
core.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface Nostr {
|
||||
generateSecretKey(): Uint8Array
|
||||
getPublicKey(secretKey: Uint8Array): string
|
||||
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
|
||||
verifyEvent(event: Event): event is VerifiedEvent
|
||||
}
|
||||
|
||||
/** Designates a verified event signature. */
|
||||
export const verifiedSymbol = Symbol('verified')
|
||||
|
||||
export interface Event {
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
id: string
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type NostrEvent = Event
|
||||
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
||||
|
||||
/** An event whose signature has been verified. */
|
||||
export interface VerifiedEvent extends Event {
|
||||
[verifiedSymbol]: true
|
||||
}
|
||||
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
if (typeof event.pubkey !== 'string') return false
|
||||
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
352
event.test.ts
352
event.test.ts
@@ -1,352 +0,0 @@
|
||||
import {
|
||||
getBlankEvent,
|
||||
finishEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
getSignature,
|
||||
Kind,
|
||||
} from './event.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
|
||||
describe('Event', () => {
|
||||
describe('getBlankEvent', () => {
|
||||
it('should return a blank event object', () => {
|
||||
expect(getBlankEvent()).toEqual({
|
||||
kind: 255,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a blank event object with defined kind', () => {
|
||||
expect(getBlankEvent(Kind.Text)).toEqual({
|
||||
kind: 1,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishEvent', () => {
|
||||
it('should create a signed event from a template', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
}
|
||||
|
||||
const event = finishEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
it('should serialize a valid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!'
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for an invalid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
it('should return the correct event hash', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115 // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid properties', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey'
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid tags', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true for a valid event signature', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for an invalid event signature', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/0/g, '1')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey2 =
|
||||
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
},
|
||||
privateKey1
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifySignature({
|
||||
...event,
|
||||
pubkey: publicKey2
|
||||
})
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSignature', () => {
|
||||
it('should produce the correct signature for an event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, privateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should not sign an event with different private key', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const wrongPrivateKey =
|
||||
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
138
event.ts
138
event.ts
@@ -1,138 +0,0 @@
|
||||
import {schnorr} from '@noble/curves/secp256k1'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
|
||||
import {getPublicKey} from './keys.ts'
|
||||
import {utf8Encoder} from './utils.ts'
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum Kind {
|
||||
Metadata = 0,
|
||||
Text = 1,
|
||||
RecommendRelay = 2,
|
||||
Contacts = 3,
|
||||
EncryptedDirectMessage = 4,
|
||||
EventDeletion = 5,
|
||||
Repost = 6,
|
||||
Reaction = 7,
|
||||
BadgeAward = 8,
|
||||
ChannelCreation = 40,
|
||||
ChannelMetadata = 41,
|
||||
ChannelMessage = 42,
|
||||
ChannelHideMessage = 43,
|
||||
ChannelMuteUser = 44,
|
||||
Blank = 255,
|
||||
Report = 1984,
|
||||
ZapRequest = 9734,
|
||||
Zap = 9735,
|
||||
RelayList = 10002,
|
||||
ClientAuth = 22242,
|
||||
HttpAuth = 27235,
|
||||
ProfileBadge = 30008,
|
||||
BadgeDefinition = 30009,
|
||||
Article = 30023
|
||||
}
|
||||
|
||||
export type EventTemplate<K extends number = Kind> = {
|
||||
kind: K
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
|
||||
id: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export function getBlankEvent(): EventTemplate<Kind.Blank>
|
||||
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
|
||||
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
|
||||
return {
|
||||
kind,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function finishEvent<K extends number = Kind>(
|
||||
t: EventTemplate<K>,
|
||||
privateKey: string
|
||||
): Event<K> {
|
||||
let event = t as Event<K>
|
||||
event.pubkey = getPublicKey(privateKey)
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
return event
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||
if (!validateEvent(evt))
|
||||
throw new Error("can't serialize event with wrong or missing properties")
|
||||
|
||||
return JSON.stringify([
|
||||
0,
|
||||
evt.pubkey,
|
||||
evt.created_at,
|
||||
evt.kind,
|
||||
evt.tags,
|
||||
evt.content
|
||||
])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent<number>): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
|
||||
obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
if (typeof event.pubkey !== 'string') return false
|
||||
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function verifySignature(event: Event<number>): boolean {
|
||||
try {
|
||||
return schnorr.verify(event.sig, getEventHash(event), event.pubkey)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `getSignature` instead. */
|
||||
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||
console.warn(
|
||||
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
|
||||
)
|
||||
return getSignature(event, key)
|
||||
}
|
||||
|
||||
/** Calculate the signature for an event. */
|
||||
export function getSignature(
|
||||
event: UnsignedEvent<number>,
|
||||
key: string
|
||||
): string {
|
||||
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||
|
||||
test('match id', () => {
|
||||
expect(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -20,15 +21,15 @@ test('match kind', () => {
|
||||
expect(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
1
|
||||
)
|
||||
1,
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||
12720
|
||||
)
|
||||
12720,
|
||||
),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -36,12 +37,8 @@ test('match subscription id', () => {
|
||||
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
||||
'kasjbdjkav'
|
||||
)
|
||||
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||
expect(
|
||||
getSubscriptionId(
|
||||
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
||||
)
|
||||
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||
})
|
||||
|
||||
150
filter.test.ts
150
filter.test.ts
@@ -1,16 +1,17 @@
|
||||
import {matchFilter, matchFilters, mergeFilters} from './filter.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
it('should return true when all filter conditions are met', () => {
|
||||
test('should return true when all filter conditions are met', () => {
|
||||
const filter = {
|
||||
ids: ['123', '456'],
|
||||
kinds: [1, 2, 3],
|
||||
authors: ['abc'],
|
||||
since: 100,
|
||||
until: 200,
|
||||
'#tag': ['value']
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
@@ -18,7 +19,7 @@ describe('Filter', () => {
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']]
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
@@ -26,68 +27,68 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
const filter = {ids: ['123', '456']}
|
||||
test('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({id: '789'})
|
||||
const event = buildEvent({ id: '789' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the event id starts with a prefix', () => {
|
||||
const filter = {ids: ['22', '00']}
|
||||
test('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({id: '001'})
|
||||
const event = buildEvent({ id: '001' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
const filter = {kinds: [1, 2, 3]}
|
||||
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 event = buildEvent({ kind: 4 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
const filter = {authors: ['abc', 'def']}
|
||||
test('should return false when the event author is not in the filter', () => {
|
||||
const filter = { authors: ['abc', 'def'] }
|
||||
|
||||
const event = buildEvent({pubkey: 'ghi'})
|
||||
const event = buildEvent({ pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
test('should return false when a tag is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({tags: [['not_tag', 'value1']]})
|
||||
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
test('should return false when a tag value is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({tags: [['tag', 'value3']]})
|
||||
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = {'#tag1': ['foo']}
|
||||
test('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = { '#tag1': ['foo'] }
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
@@ -96,8 +97,8 @@ describe('Filter', () => {
|
||||
created_at: 150,
|
||||
tags: [
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar']
|
||||
]
|
||||
['tag2', 'bar'],
|
||||
],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
@@ -105,40 +106,40 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
const filter = {since: 100}
|
||||
test('should return false when the event is before the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({created_at: 50})
|
||||
const event = buildEvent({ created_at: 50 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
const filter = {since: 100}
|
||||
test('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({created_at: 100})
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
const filter = {until: 100}
|
||||
test('should return false when the event is after the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({created_at: 150})
|
||||
const event = buildEvent({ created_at: 150 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
const filter = {until: 100}
|
||||
test('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({created_at: 100})
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -147,46 +148,46 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('matchFilters', () => {
|
||||
it('should return true when at least one filter matches the event', () => {
|
||||
test('should return true when at least one filter matches the event', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({id: '789', kind: 3, pubkey: 'ghi'})
|
||||
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when at least one prefix matches the event', () => {
|
||||
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']}
|
||||
{ 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 event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when event matches one or more filters and some have limit set', () => {
|
||||
test('should return true when event matches one or more filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], limit: 1},
|
||||
{kinds: [1], limit: 2},
|
||||
{authors: ['abc'], limit: 3}
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150
|
||||
created_at: 150,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
@@ -194,31 +195,31 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when no filters match the event', () => {
|
||||
test('should return false when no filters match the event', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({id: '100', kind: 4, pubkey: 'jkl'})
|
||||
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when event matches none of the filters and some have limit set', () => {
|
||||
test('should return false when event matches none of the filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], limit: 1},
|
||||
{kinds: [1], limit: 2},
|
||||
{authors: ['abc'], limit: 3}
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
const event = buildEvent({
|
||||
id: '456',
|
||||
kind: 2,
|
||||
pubkey: 'def',
|
||||
created_at: 200
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
@@ -228,21 +229,16 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('mergeFilters', () => {
|
||||
it('should merge filters', () => {
|
||||
expect(
|
||||
mergeFilters(
|
||||
{ids: ['a', 'b'], limit: 3},
|
||||
{authors: ['x'], ids: ['b', 'c']}
|
||||
)
|
||||
).toEqual({ids: ['a', 'b', 'c'], limit: 3, authors: ['x']})
|
||||
test('should merge filters', () => {
|
||||
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||
ids: ['a', 'b', 'c'],
|
||||
limit: 3,
|
||||
authors: ['x'],
|
||||
})
|
||||
|
||||
expect(
|
||||
mergeFilters(
|
||||
{kinds: [1], since: 15, until: 30},
|
||||
{since: 10, kinds: [7], until: 15},
|
||||
{kinds: [9, 10]}
|
||||
)
|
||||
).toEqual({kinds: [1, 7, 9, 10], since: 10, until: 30})
|
||||
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
46
filter.ts
46
filter.ts
@@ -1,20 +1,17 @@
|
||||
import {Event, type Kind} from './event.ts'
|
||||
import { Event } from './core.ts'
|
||||
|
||||
export type Filter<K extends number = Kind> = {
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: K[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[]
|
||||
[key: `#${string}`]: string[] | undefined
|
||||
}
|
||||
|
||||
export function matchFilter(
|
||||
filter: Filter<number>,
|
||||
event: Event<number>
|
||||
): boolean {
|
||||
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
|
||||
@@ -31,13 +28,7 @@ export function matchFilter(
|
||||
if (f[0] === '#') {
|
||||
let tagName = f.slice(1)
|
||||
let values = filter[`#${tagName}`]
|
||||
if (
|
||||
values &&
|
||||
!event.tags.find(
|
||||
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,27 +38,19 @@ export function matchFilter(
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(
|
||||
filters: Filter<number>[],
|
||||
event: Event<number>
|
||||
): boolean {
|
||||
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
|
||||
let result: Filter<number> = {}
|
||||
export function mergeFilters(...filters: Filter[]): Filter {
|
||||
let result: Filter = {}
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
let filter = filters[i]
|
||||
Object.entries(filter).forEach(([property, values]) => {
|
||||
if (
|
||||
property === 'kinds' ||
|
||||
property === 'ids' ||
|
||||
property === 'authors' ||
|
||||
property[0] === '#'
|
||||
) {
|
||||
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||
// @ts-ignore
|
||||
result[property] = result[property] || []
|
||||
// @ts-ignore
|
||||
@@ -80,12 +63,9 @@ export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
|
||||
}
|
||||
})
|
||||
|
||||
if (filter.limit && (!result.limit || filter.limit > result.limit))
|
||||
result.limit = filter.limit
|
||||
if (filter.until && (!result.until || filter.until > result.until))
|
||||
result.until = filter.until
|
||||
if (filter.since && (!result.since || filter.since < result.since))
|
||||
result.since = filter.since
|
||||
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
21
helpers.ts
Normal file
21
helpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||
|
||||
export async function yieldThread() {
|
||||
return new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel()
|
||||
const handler = () => {
|
||||
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||
ch.port1.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||
ch.port1.addEventListener('message', handler)
|
||||
ch.port2.postMessage(0)
|
||||
ch.port1.start()
|
||||
})
|
||||
}
|
||||
|
||||
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
|
||||
t[verifiedSymbol] = true
|
||||
return true
|
||||
}
|
||||
11
index.ts
11
index.ts
@@ -1,25 +1,28 @@
|
||||
export * from './keys.ts'
|
||||
export * from './pure.ts'
|
||||
export * from './relay.ts'
|
||||
export * from './event.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04.ts'
|
||||
export * as nip05 from './nip05.ts'
|
||||
export * as nip06 from './nip06.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip11 from './nip11.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
export * as nip25 from './nip25.ts'
|
||||
export * as nip26 from './nip26.ts'
|
||||
export * as nip27 from './nip27.ts'
|
||||
export * as nip28 from './nip28.ts'
|
||||
export * as nip30 from './nip30.ts'
|
||||
export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as kinds from './kinds.ts'
|
||||
export * as fj from './fakejson.ts'
|
||||
export * as utils from './utils.ts'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
}
|
||||
23
justfile
23
justfile
@@ -1,17 +1,14 @@
|
||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
|
||||
install-dependencies:
|
||||
yarn --ignore-engines
|
||||
|
||||
build:
|
||||
rm -rf lib
|
||||
node build.js
|
||||
bun run build.js
|
||||
|
||||
test:
|
||||
jest
|
||||
bun test --timeout 20000
|
||||
|
||||
test-only file:
|
||||
jest {{file}}
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
@@ -20,4 +17,16 @@ publish: build emit-types
|
||||
npm publish
|
||||
|
||||
format:
|
||||
prettier --plugin-search-dir . --write .
|
||||
eslint --ext .ts --fix *.ts
|
||||
prettier --write *.ts
|
||||
|
||||
lint:
|
||||
eslint --ext .ts *.ts
|
||||
prettier --check *.ts
|
||||
|
||||
benchmark:
|
||||
bun build --target=node --outfile=bench.js benchmarks.ts
|
||||
timeout 60s deno run --allow-read bench.js || true
|
||||
timeout 60s node bench.js || true
|
||||
timeout 60s bun run benchmarks.ts || true
|
||||
rm bench.js
|
||||
|
||||
18
keys.test.ts
18
keys.test.ts
@@ -1,18 +0,0 @@
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
10
keys.ts
10
keys.ts
@@ -1,10 +0,0 @@
|
||||
import {schnorr} from '@noble/curves/secp256k1'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return bytesToHex(schnorr.utils.randomPrivateKey())
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return bytesToHex(schnorr.getPublicKey(privateKey))
|
||||
}
|
||||
21
kinds.test.ts
Normal file
21
kinds.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { classifyKind } from './kinds.ts'
|
||||
|
||||
test('kind classification', () => {
|
||||
expect(classifyKind(1)).toBe('regular')
|
||||
expect(classifyKind(5)).toBe('regular')
|
||||
expect(classifyKind(6)).toBe('regular')
|
||||
expect(classifyKind(7)).toBe('regular')
|
||||
expect(classifyKind(1000)).toBe('regular')
|
||||
expect(classifyKind(9999)).toBe('regular')
|
||||
expect(classifyKind(0)).toBe('replaceable')
|
||||
expect(classifyKind(3)).toBe('replaceable')
|
||||
expect(classifyKind(10000)).toBe('replaceable')
|
||||
expect(classifyKind(19999)).toBe('replaceable')
|
||||
expect(classifyKind(20000)).toBe('ephemeral')
|
||||
expect(classifyKind(29999)).toBe('ephemeral')
|
||||
expect(classifyKind(30000)).toBe('parameterized')
|
||||
expect(classifyKind(39999)).toBe('parameterized')
|
||||
expect(classifyKind(40000)).toBe('unknown')
|
||||
expect(classifyKind(255)).toBe('unknown')
|
||||
})
|
||||
105
kinds.ts
Normal file
105
kinds.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
export function isRegularKind(kind: number) {
|
||||
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||
}
|
||||
|
||||
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||
export function isReplaceableKind(kind: number) {
|
||||
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
export function isEphemeralKind(kind: number) {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isParameterizedReplaceableKind(kind: number) {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** Classification of the event kind. */
|
||||
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||
export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export const Metadata = 0
|
||||
export const ShortTextNote = 1
|
||||
export const RecommendRelay = 2
|
||||
export const Contacts = 3
|
||||
export const EncryptedDirectMessage = 4
|
||||
export const EventDeletion = 5
|
||||
export const Repost = 6
|
||||
export const Reaction = 7
|
||||
export const BadgeAward = 8
|
||||
export const ChannelCreation = 40
|
||||
export const ChannelMetadata = 41
|
||||
export const ChannelMessage = 42
|
||||
export const ChannelHideMessage = 43
|
||||
export const ChannelMuteUser = 44
|
||||
export const Report = 1984
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const RelayList = 10002
|
||||
export const ClientAuth = 22242
|
||||
export const BadgeDefinition = 30009
|
||||
export const FileMetadata = 1063
|
||||
export const EncryptedDirectMessages = 4
|
||||
export const GenericRepost = 16
|
||||
export const OpenTimestamps = 1040
|
||||
export const LiveChatMessage = 1311
|
||||
export const ProblemTracker = 1971
|
||||
export const Reporting = 1984
|
||||
export const Label = 1985
|
||||
export const CommunityPostApproval = 4550
|
||||
export const JobRequest = 5999
|
||||
export const JobResult = 6999
|
||||
export const JobFeedback = 7000
|
||||
export const ZapGoal = 9041
|
||||
export const Highlights = 9802
|
||||
export const Mutelist = 10000
|
||||
export const Pinlist = 10001
|
||||
export const BookmarkList = 10003
|
||||
export const CommunitiesList = 10004
|
||||
export const PublicChatsList = 10005
|
||||
export const BlockedRelaysList = 10006
|
||||
export const SearchRelaysList = 10007
|
||||
export const InterestsList = 10015
|
||||
export const UserEmojiList = 10030
|
||||
export const NWCWalletInfo = 13194
|
||||
export const LightningPubRPC = 21000
|
||||
export const NWCWalletRequest = 23194
|
||||
export const NWCWalletResponse = 23195
|
||||
export const NostrConnect = 24133
|
||||
export const HTTPAuth = 27235
|
||||
export const Followsets = 30000
|
||||
export const Genericlists = 30001
|
||||
export const Relaysets = 30002
|
||||
export const Bookmarksets = 30003
|
||||
export const Curationsets = 30004
|
||||
export const ProfileBadges = 30008
|
||||
export const Interestsets = 30015
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
export const LongFormArticle = 30023
|
||||
export const DraftLong = 30024
|
||||
export const Emojisets = 30030
|
||||
export const Application = 30078
|
||||
export const LiveEvent = 30311
|
||||
export const UserStatuses = 30315
|
||||
export const ClassifiedListing = 30402
|
||||
export const DraftClassifiedListing = 30403
|
||||
export const Date = 31922
|
||||
export const Time = 31923
|
||||
export const Calendar = 31924
|
||||
export const CalendarEventRSVP = 31925
|
||||
export const Handlerrecommendation = 31989
|
||||
export const Handlerinformation = 31990
|
||||
export const CommunityDefinition = 34550
|
||||
@@ -1,19 +1,50 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import {encrypt, decrypt} from './nip04.ts'
|
||||
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||
import { encrypt, decrypt } from './nip04.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
try {
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let sk2 = generatePrivateKey()
|
||||
let sk1 = generateSecretKey()
|
||||
let sk2 = generateSecretKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
expect(
|
||||
await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))
|
||||
).toEqual('hello')
|
||||
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
|
||||
|
||||
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
|
||||
})
|
||||
|
||||
test('decrypt message from go-nostr', async () => {
|
||||
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||
|
||||
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
|
||||
|
||||
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
|
||||
})
|
||||
|
||||
test('decrypt big payload from go-nostr', async () => {
|
||||
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||
|
||||
let ciphertext =
|
||||
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
|
||||
let plaintext = ''
|
||||
for (let i = 0; i < 800; i++) {
|
||||
plaintext += 'z'
|
||||
}
|
||||
|
||||
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
|
||||
})
|
||||
|
||||
50
nip04.ts
50
nip04.ts
@@ -1,8 +1,8 @@
|
||||
import {randomBytes} from '@noble/hashes/utils'
|
||||
import {secp256k1} from '@noble/curves/secp256k1'
|
||||
import {base64} from '@scure/base'
|
||||
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import {utf8Decoder, utf8Encoder} from './utils.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
@@ -10,58 +10,32 @@ if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(
|
||||
privkey: string,
|
||||
pubkey: string,
|
||||
text: string
|
||||
): Promise<string> {
|
||||
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
let plaintext = utf8Encoder.encode(text)
|
||||
let cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
normalizedKey,
|
||||
{name: 'AES-CBC'},
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
let ciphertext = await crypto.subtle.encrypt(
|
||||
{name: 'AES-CBC', iv},
|
||||
cryptoKey,
|
||||
plaintext
|
||||
)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
privkey: string,
|
||||
pubkey: string,
|
||||
data: string
|
||||
): Promise<string> {
|
||||
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
normalizedKey,
|
||||
{name: 'AES-CBC'},
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt(
|
||||
{name: 'AES-CBC', iv},
|
||||
cryptoKey,
|
||||
ciphertext
|
||||
)
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import {useFetchImplementation, queryProfile} from './nip05.ts'
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual(
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
)
|
||||
expect(p3!.relays).toEqual([
|
||||
'wss://relay.nostr.bg',
|
||||
'wss://nos.lol',
|
||||
'wss://nostr-verified.wellorder.net',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://eden.nostr.land',
|
||||
'wss://nostr.milou.lol',
|
||||
])
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||
})
|
||||
|
||||
11
nip05.ts
11
nip05.ts
@@ -1,4 +1,4 @@
|
||||
import {ProfilePointer} from './nip19.ts'
|
||||
import { ProfilePointer } from './nip19.ts'
|
||||
|
||||
/**
|
||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||
@@ -19,14 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function searchDomain(
|
||||
domain: string,
|
||||
query = ''
|
||||
): Promise<{[name: string]: string}> {
|
||||
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()
|
||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||
|
||||
return res.names
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import {privateKeyFromSeedWords} from './nip06.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual(
|
||||
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
||||
)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual(
|
||||
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
||||
)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||
})
|
||||
|
||||
19
nip06.ts
19
nip06.ts
@@ -1,18 +1,11 @@
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
import {wordlist} from '@scure/bip39/wordlists/english'
|
||||
import {
|
||||
generateMnemonic,
|
||||
mnemonicToSeedSync,
|
||||
validateMnemonic
|
||||
} from '@scure/bip39'
|
||||
import {HDKey} from '@scure/bip32'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||
import { HDKey } from '@scure/bip32'
|
||||
|
||||
export function privateKeyFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string
|
||||
): string {
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return bytesToHex(privateKey)
|
||||
}
|
||||
|
||||
310
nip10.test.ts
310
nip10.test.ts
@@ -1,351 +1,229 @@
|
||||
import {parse} from './nip10.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { parse } from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
}
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.todo('recommended + a lot of events')
|
||||
test.todo('recommended + 3 events')
|
||||
test.todo('recommended + 2 events')
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
'wss://relay.mostr.pub'
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
'wss://relay.mostr.pub',
|
||||
'reply'
|
||||
],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
|
||||
]
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
}
|
||||
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
root: undefined
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
17
nip10.ts
17
nip10.ts
@@ -1,5 +1,5 @@
|
||||
import type {Event} from './event.ts'
|
||||
import type {EventPointer, ProfilePointer} from './nip19.ts'
|
||||
import type { Event } from './core.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: []
|
||||
profiles: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
@@ -41,7 +41,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
result.profiles.push({
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : []
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,16 +49,11 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
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] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : []
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
|
||||
16
nip11.test.ts
Normal file
16
nip11.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
test('testing a relay', async () => {
|
||||
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||
expect(info.name).toEqual('nostr.land')
|
||||
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
|
||||
expect(info.fees).toBeTruthy()
|
||||
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||
expect(info.software).toEqual('custom')
|
||||
})
|
||||
})
|
||||
286
nip11.ts
Normal file
286
nip11.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function fetchRelayInformation(url: string) {
|
||||
return (await (
|
||||
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||
headers: { Accept: 'application/nostr+json' },
|
||||
})
|
||||
).json()) as RelayInformation
|
||||
}
|
||||
|
||||
/**
|
||||
* ## Relay Information Document
|
||||
|
||||
* Relays may provide server metadata to clients to inform
|
||||
* them of capabilities, administrative contacts, and
|
||||
* various server attributes. This is made available as a
|
||||
* JSON document over HTTP, on the same URI as the relay's
|
||||
* websocket.
|
||||
|
||||
* Any field may be omitted, and clients MUST ignore any
|
||||
* additional fields they do not understand. Relays MUST
|
||||
* accept CORS requests by sending
|
||||
* `Access-Control-Allow-Origin`,
|
||||
* `Access-Control-Allow-Headers`, and
|
||||
* `Access-Control-Allow-Methods` headers.
|
||||
* @param name string identifying relay
|
||||
* @param description string with detailed information
|
||||
* @param pubkey administrative contact pubkey
|
||||
* @param contact: administrative alternate contact
|
||||
* @param supported_nips a list of NIP numbers supported by
|
||||
* the relay
|
||||
* @param software identifying relay software URL
|
||||
* @param version string version identifier
|
||||
*/
|
||||
export interface BasicRelayInformation {
|
||||
// string identifying relay
|
||||
name: string
|
||||
description: string
|
||||
pubkey: string
|
||||
contact: string
|
||||
supported_nips: number[]
|
||||
software: string
|
||||
version: string
|
||||
// limitation?: Limitations<A, P>
|
||||
}
|
||||
|
||||
/**
|
||||
* * ## Extra Fields
|
||||
|
||||
* * ### Server Limitations
|
||||
|
||||
* These are limitations imposed by the relay on clients.
|
||||
* Your client should expect that requests which exceed
|
||||
* these practical_ limitations are rejected or fail immediately.
|
||||
* @param max_message_length this is the maximum number of
|
||||
* bytes for incoming JSON that the relay will attempt to
|
||||
* decode and act upon. When you send large subscriptions,
|
||||
* you will be limited by this value. It also effectively
|
||||
* limits the maximum size of any event. Value is calculated
|
||||
* from `[` to `]` and is after UTF-8 serialization (so some
|
||||
* unicode characters will cost 2-3 bytes). It is equal to
|
||||
* the maximum size of the WebSocket message frame.
|
||||
* @param max_subscription total number of subscriptions
|
||||
* that may be active on a single websocket connection to
|
||||
* this relay. It's possible that authenticated clients with
|
||||
* a (paid) relationship to the relay may have higher limits.
|
||||
* @param max_filters maximum number of filter values in
|
||||
* each subscription. Must be one or higher.
|
||||
* @param max_limit the relay server will clamp each
|
||||
* filter's `limit` value to this number.
|
||||
* This means the client won't be able to get more than this
|
||||
* number of events from a single subscription filter. This
|
||||
* clamping is typically done silently by the relay, but
|
||||
* with this number, you can know that there are additional
|
||||
* results if you narrowed your filter's time range or other
|
||||
* parameters.
|
||||
* @param max_subid_length maximum length of subscription id as a
|
||||
* string.
|
||||
* @param min_prefix for `authors` and `ids` filters which
|
||||
* are to match against a hex prefix, you must provide at
|
||||
* least this many hex digits in the prefix.
|
||||
* @param max_event_tags in any event, this is the maximum
|
||||
* number of elements in the `tags` list.
|
||||
* @param max_content_length maximum number of characters in
|
||||
* the `content` field of any event. This is a count of
|
||||
* unicode characters. After serializing into JSON it may be
|
||||
* larger (in bytes), and is still subject to the
|
||||
* max_message_length`, if defined.
|
||||
* @param min_pow_difficulty new events will require at
|
||||
* least this difficulty of PoW, based on [NIP-13](13.md),
|
||||
* or they will be rejected by this server.
|
||||
* @param auth_required this relay requires [NIP-42](42.md)
|
||||
* authentication to happen before a new connection may
|
||||
* perform any other action. Even if set to False,
|
||||
* authentication may be required for specific actions.
|
||||
* @param payment_required this relay requires payment
|
||||
* before a new connection may perform any action.
|
||||
*/
|
||||
export interface Limitations {
|
||||
max_message_length: number
|
||||
max_subscription: number
|
||||
max_filters: number
|
||||
max_limit: number
|
||||
max_subid_length: number
|
||||
min_prefix: number
|
||||
max_event_tags: number
|
||||
max_content_length: number
|
||||
min_pow_difficulty: number
|
||||
auth_required: boolean
|
||||
payment_required: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
}
|
||||
type AnyRetentionDetails = RetentionDetails
|
||||
/**
|
||||
* ### Event Retention
|
||||
|
||||
* There may be a cost associated with storing data forever,
|
||||
* so relays may wish to state retention times. The values
|
||||
* stated here are defaults for unauthenticated users and
|
||||
* visitors. Paid users would likely have other policies.
|
||||
|
||||
* Retention times are given in seconds, with `null`
|
||||
* indicating infinity. If zero is provided, this means the
|
||||
* event will not be stored at all, and preferably an error
|
||||
* will be provided when those are received.
|
||||
* ```json
|
||||
{
|
||||
...
|
||||
"retention": [
|
||||
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
|
||||
{ "kinds": [[40000, 49999]], "time": 100 },
|
||||
{ "kinds": [[30000, 39999]], "count": 1000 },
|
||||
{ "time": 3600, "count": 10000 }
|
||||
]
|
||||
...
|
||||
}
|
||||
```
|
||||
* @param retention is a list of specifications: each will
|
||||
* apply to either all kinds, or a subset of kinds. Ranges
|
||||
* may be specified for the kind field as a tuple of
|
||||
* inclusive start and end values. Events of indicated kind
|
||||
* (or all) are then limited to a `count` and/or time
|
||||
* period.
|
||||
|
||||
* It is possible to effectively blacklist Nostr-based
|
||||
* protocols that rely on a specific `kind` number, by
|
||||
* giving a retention time of zero for those `kind` values.
|
||||
* While that is unfortunate, it does allow clients to
|
||||
* discover servers that will support their protocol quickly
|
||||
* via a single HTTP fetch.
|
||||
|
||||
* There is no need to specify retention times for
|
||||
* _ephemeral events_ as defined in [NIP-16](16.md) since
|
||||
* they are not retained.
|
||||
*/
|
||||
export interface Retention {
|
||||
retention: AnyRetentionDetails[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Some relays may be governed by the arbitrary laws of a
|
||||
* nation state. This may limit what content can be stored
|
||||
* in cleartext on those relays. All clients are encouraged
|
||||
* to use encryption to work around this limitation.
|
||||
|
||||
* It is not possible to describe the limitations of each
|
||||
* country's laws and policies which themselves are
|
||||
* typically vague and constantly shifting.
|
||||
|
||||
* Therefore, this field allows the relay operator to
|
||||
* indicate which countries' laws might end up being
|
||||
* enforced on them, and then indirectly on their users'
|
||||
* content.
|
||||
|
||||
* Users should be able to avoid relays in countries they
|
||||
* don't like, and/or select relays in more favourable
|
||||
* zones. Exposing this flexibility is up to the client
|
||||
* software.
|
||||
|
||||
* @param relay_countries a list of two-level ISO country
|
||||
* codes (ISO 3166-1 alpha-2) whose laws and policies may
|
||||
* affect this relay. `EU` may be used for European Union
|
||||
* countries.
|
||||
|
||||
* Remember that a relay may be hosted in a country which is
|
||||
* not the country of the legal entities who own the relay,
|
||||
* so it's very likely a number of countries are involved.
|
||||
*/
|
||||
export interface ContentLimitations {
|
||||
relay_countries: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Community Preferences
|
||||
|
||||
* For public text notes at least, a relay may try to foster
|
||||
* a local community. This would encourage users to follow
|
||||
* the global feed on that relay, in addition to their usual
|
||||
* individual follows. To support this goal, relays MAY
|
||||
* specify some of the following values.
|
||||
|
||||
* @param language_tags is an ordered list of [IETF
|
||||
* language
|
||||
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
|
||||
* indicating the major languages spoken on the relay.
|
||||
* @param tags is a list of limitations on the topics to be
|
||||
* discussed. For example `sfw-only` indicates that only
|
||||
* "Safe For Work" content is encouraged on this relay. This
|
||||
* relies on assumptions of what the "work" "community"
|
||||
* feels "safe" talking about. In time, a common set of tags
|
||||
* may emerge that allow users to find relays that suit
|
||||
* their needs, and client software will be able to parse
|
||||
* these tags easily. The `bitcoin-only` tag indicates that
|
||||
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
|
||||
* be ridiculed without mercy.
|
||||
* @param posting_policy is a link to a human-readable page
|
||||
* which specifies the community policies for the relay. In
|
||||
* cases where `sfw-only` is True, it's important to link to
|
||||
* a page which gets into the specifics of your posting
|
||||
* policy.
|
||||
|
||||
* The `description` field should be used to describe your
|
||||
* community goals and values, in brief. The
|
||||
* `posting_policy` is for additional detail and legal
|
||||
* terms. Use the `tags` field to signify limitations on
|
||||
* content, or topics to be discussed, which could be
|
||||
* machine processed by appropriate client software.
|
||||
*/
|
||||
export interface CommunityPreferences {
|
||||
language_tags: string[]
|
||||
tags: string[]
|
||||
posting_policy: string
|
||||
}
|
||||
|
||||
export interface Amount {
|
||||
amount: number
|
||||
unit: 'msat'
|
||||
}
|
||||
export interface PublicationAmount extends Amount {
|
||||
kinds: number[]
|
||||
}
|
||||
export interface Subscription extends Amount {
|
||||
period: number
|
||||
}
|
||||
export interface Fees {
|
||||
admission: Amount[]
|
||||
subscription: Subscription[]
|
||||
publication: PublicationAmount[]
|
||||
}
|
||||
/**
|
||||
* Relays that require payments may want to expose their fee
|
||||
* schedules.
|
||||
*/
|
||||
export interface PayToRelay {
|
||||
payments_url: string
|
||||
fees: Fees
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL pointing to an image to be used as an icon for the
|
||||
* relay. Recommended to be squared in shape.
|
||||
*/
|
||||
export interface Icon {
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type RelayInformation = BasicRelayInformation &
|
||||
Partial<Retention> & {
|
||||
limitation?: Partial<Limitations>
|
||||
} & Partial<ContentLimitations> &
|
||||
Partial<CommunityPreferences> &
|
||||
Partial<PayToRelay> &
|
||||
Partial<Icon>
|
||||
@@ -1,7 +1,25 @@
|
||||
import {getPow} from './nip13.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { getPow, minePow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
})
|
||||
|
||||
test('mines POW for an event', async () => {
|
||||
const difficulty = 10
|
||||
|
||||
const event = minePow(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 0,
|
||||
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
difficulty,
|
||||
)
|
||||
|
||||
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
|
||||
})
|
||||
|
||||
62
nip13.ts
62
nip13.ts
@@ -1,42 +1,52 @@
|
||||
import {hexToBytes} from '@noble/hashes/utils'
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(id: string): number {
|
||||
return getLeadingZeroBits(hexToBytes(id))
|
||||
}
|
||||
export function getPow(hex: string): number {
|
||||
let count = 0
|
||||
|
||||
/**
|
||||
* Get number of leading 0 bits. Adapted from nostream.
|
||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
||||
*/
|
||||
function getLeadingZeroBits(hash: Uint8Array): number {
|
||||
let total: number, i: number, bits: number
|
||||
|
||||
for (i = 0, total = 0; i < hash.length; i++) {
|
||||
bits = msb(hash[i])
|
||||
total += bits
|
||||
if (bits !== 8) {
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapted from nostream.
|
||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
||||
* 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
|
||||
*/
|
||||
function msb(b: number) {
|
||||
let n = 0
|
||||
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||
let count = 0
|
||||
|
||||
if (b === 0) {
|
||||
return 8
|
||||
const event = unsigned as Omit<Event, 'sig'>
|
||||
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||
|
||||
event.tags.push(tag)
|
||||
|
||||
while (true) {
|
||||
const now = Math.floor(new Date().getTime() / 1000)
|
||||
|
||||
if (now !== event.created_at) {
|
||||
count = 0
|
||||
event.created_at = now
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while (b >>= 1) {
|
||||
n++
|
||||
tag[1] = (++count).toString()
|
||||
|
||||
event.id = getEventHash(event)
|
||||
|
||||
if (getPow(event.id) >= difficulty) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return 7 - n
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
import {finishEvent, Kind} from './event.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
import {finishRepostEvent, getRepostedEventPointer, getRepostedEvent} from './nip18.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Repost, ShortTextNote } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
const relayUrl = 'https://relay.example.com'
|
||||
|
||||
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const repostedEvent = finishEvent(
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey']
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
test('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(
|
||||
template,
|
||||
repostedEvent,
|
||||
relayUrl,
|
||||
privateKey
|
||||
)
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.kind).toEqual(Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey]
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
@@ -58,25 +53,20 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
test('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '' as const,
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(
|
||||
template,
|
||||
repostedEvent,
|
||||
relayUrl,
|
||||
privateKey
|
||||
)
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.kind).toEqual(Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey]
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual('')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
@@ -92,21 +82,21 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(undefined)
|
||||
expect(repostedEventFromContent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
it('should parse an event with only an `e` tag', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
kind: Kind.Repost,
|
||||
kind: Repost,
|
||||
tags: [['e', 'reposted event id', relayUrl]],
|
||||
})
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||
expect(repostedEventPointer!.author).toEqual(undefined)
|
||||
expect(repostedEventPointer!.author).toBeUndefined()
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
|
||||
46
nip18.ts
46
nip18.ts
@@ -1,5 +1,6 @@
|
||||
import {Event, finishEvent, Kind, verifySignature} from './event.ts'
|
||||
import {EventPointer} from './nip19.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
@@ -13,31 +14,30 @@ export type RepostEventTemplate = {
|
||||
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
|
||||
* @default Stringified JSON of the reposted event
|
||||
*/
|
||||
content?: '';
|
||||
content?: ''
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishRepostEvent(
|
||||
t: RepostEventTemplate,
|
||||
reposted: Event<number>,
|
||||
reposted: Event,
|
||||
relayUrl: string,
|
||||
privateKey: string,
|
||||
): Event<Kind.Repost> {
|
||||
return finishEvent({
|
||||
kind: Kind.Repost,
|
||||
tags: [
|
||||
...(t.tags ?? []),
|
||||
[ 'e', reposted.id, relayUrl ],
|
||||
[ 'p', reposted.pubkey ],
|
||||
],
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
}, privateKey)
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Repost) {
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -61,26 +61,26 @@ export function getRepostedEventPointer(event: Event<number>): undefined | Event
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [ lastETag[2], lastPTag?.[2] ].filter((x): x is string => typeof x === 'string'),
|
||||
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||
author: lastPTag?.[1],
|
||||
}
|
||||
}
|
||||
|
||||
export type GetRepostedEventOptions = {
|
||||
skipVerification?: boolean,
|
||||
};
|
||||
skipVerification?: boolean
|
||||
}
|
||||
|
||||
export function getRepostedEvent(event: Event<number>, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event<number> {
|
||||
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
|
||||
const pointer = getRepostedEventPointer(event)
|
||||
|
||||
if (pointer === undefined || event.content === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let repostedEvent: undefined | Event<number>
|
||||
let repostedEvent: undefined | Event
|
||||
|
||||
try {
|
||||
repostedEvent = JSON.parse(event.content) as Event<number>
|
||||
repostedEvent = JSON.parse(event.content) as Event
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export function getRepostedEvent(event: Event<number>, { skipVerification }: Get
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!skipVerification && !verifySignature(repostedEvent)) {
|
||||
if (!skipVerification && !verifyEvent(repostedEvent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
124
nip19.test.ts
124
nip19.test.ts
@@ -1,4 +1,5 @@
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
@@ -6,37 +7,36 @@ import {
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let {type, data} = decode(nsec)
|
||||
let { type, data } = decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let npub = npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let {type, data} = decode(npub)
|
||||
let { type, data } = decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let nprofile = nprofileEncode({pubkey: pk, relays})
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let {type, data} = decode(nprofile)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
@@ -48,31 +48,24 @@ test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
decode(
|
||||
nprofileEncode({
|
||||
pubkey:
|
||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: []
|
||||
})
|
||||
).data
|
||||
).toHaveProperty(
|
||||
'pubkey',
|
||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
|
||||
)
|
||||
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: [],
|
||||
}),
|
||||
).data,
|
||||
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana'
|
||||
identifier: 'banana',
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let {type, data} = decode(naddr)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
@@ -82,32 +75,79 @@ test('encode and decode naddr', () => {
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nevent', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
})
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
})
|
||||
|
||||
test('encode and decode nevent with kind 0', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 0,
|
||||
})
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(0)
|
||||
})
|
||||
|
||||
test('encode and decode naddr with empty "d"', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
identifier: '',
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 3,
|
||||
})
|
||||
expect(naddr).toMatch(/naddr\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.identifier).toEqual('')
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(3)
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let {type, data} = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
||||
let { type, data } = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(
|
||||
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
||||
)
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let {type, data} = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
||||
let { type, data } = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
)
|
||||
expect(pointer.relays).toContain(
|
||||
'wss://relay.nostr.example.mydomain.example.com'
|
||||
)
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
@@ -117,7 +157,7 @@ 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)
|
||||
let { type, data } = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
})
|
||||
|
||||
85
nip19.ts
85
nip19.ts
@@ -1,7 +1,7 @@
|
||||
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils'
|
||||
import {bech32} from '@scure/base'
|
||||
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import {utf8Decoder, utf8Encoder} from './utils.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
|
||||
@@ -9,8 +9,20 @@ const Bech32MaxSize = 5000
|
||||
* Bech32 regex.
|
||||
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||
*/
|
||||
export const BECH32_REGEX =
|
||||
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
|
||||
function integerToUint8Array(number: number) {
|
||||
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
|
||||
const uint8Array = new Uint8Array(4)
|
||||
|
||||
// Use bitwise operations to extract the bytes.
|
||||
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
|
||||
uint8Array[1] = (number >> 16) & 0xff
|
||||
uint8Array[2] = (number >> 8) & 0xff
|
||||
uint8Array[3] = number & 0xff // Least significant byte (LSB)
|
||||
|
||||
return uint8Array
|
||||
}
|
||||
|
||||
export type ProfilePointer = {
|
||||
pubkey: string // hex
|
||||
@@ -21,6 +33,7 @@ export type EventPointer = {
|
||||
id: string // hex
|
||||
relays?: string[]
|
||||
author?: string
|
||||
kind?: number
|
||||
}
|
||||
|
||||
export type AddressPointer = {
|
||||
@@ -35,7 +48,7 @@ type Prefixes = {
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: string
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
}
|
||||
@@ -52,7 +65,7 @@ export type DecodeResult = {
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
@@ -65,24 +78,25 @@ export function decode(nip19: string): DecodeResult {
|
||||
type: 'nprofile',
|
||||
data: {
|
||||
pubkey: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
}
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'nevent': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
||||
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||
if (tlv[2] && tlv[2][0].length !== 32)
|
||||
throw new Error('TLV 2 should be 32 bytes')
|
||||
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||
|
||||
return {
|
||||
type: 'nevent',
|
||||
data: {
|
||||
id: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
|
||||
}
|
||||
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +114,8 @@ export function decode(nip19: string): DecodeResult {
|
||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||
pubkey: bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
}
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,21 +125,23 @@ export function decode(nip19: string): DecodeResult {
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0])
|
||||
data: utf8Decoder.decode(tlv[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
return { type: prefix, data }
|
||||
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return {type: prefix, data: bytesToHex(data)}
|
||||
return { type: prefix, data: bytesToHex(data) }
|
||||
|
||||
default:
|
||||
throw new Error(`unknown prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
type TLV = {[t: number]: Uint8Array[]}
|
||||
type TLV = { [t: number]: Uint8Array[] }
|
||||
|
||||
function parseTLV(data: Uint8Array): TLV {
|
||||
let result: TLV = {}
|
||||
@@ -133,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
while (rest.length > 0) {
|
||||
let t = rest[0]
|
||||
let l = rest[1]
|
||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
||||
let v = rest.slice(2, 2 + l)
|
||||
rest = rest.slice(2 + l)
|
||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||
@@ -143,16 +158,16 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
return result
|
||||
}
|
||||
|
||||
export function nsecEncode(hex: string): `nsec1${string}` {
|
||||
return encodeBytes('nsec', hex)
|
||||
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
||||
return encodeBytes('nsec', key)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
return encodeBytes('npub', hex)
|
||||
return encodeBytes('npub', hexToBytes(hex))
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
return encodeBytes('note', hex)
|
||||
return encodeBytes('note', hexToBytes(hex))
|
||||
}
|
||||
|
||||
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||
@@ -160,25 +175,31 @@ 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, hex: string): `${Prefix}1${string}` {
|
||||
let data = hexToBytes(hex)
|
||||
return encodeBech32(prefix, data)
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
return encodeBech32(prefix, bytes)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
})
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
let kindArray
|
||||
if (event.kind !== undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
}
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(event.id)],
|
||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: event.author ? [hexToBytes(event.author)] : []
|
||||
2: event.author ? [hexToBytes(event.author)] : [],
|
||||
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||
})
|
||||
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
@@ -190,14 +211,14 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
0: [utf8Encoder.encode(addr.identifier)],
|
||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: [hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)]
|
||||
3: [new Uint8Array(kind)],
|
||||
})
|
||||
return encodeBech32('naddr', data)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)]
|
||||
0: [utf8Encoder.encode(url)],
|
||||
})
|
||||
return encodeBech32('nrelay', data)
|
||||
}
|
||||
@@ -205,7 +226,9 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
Object.entries(tlv).forEach(([t, vs]) => {
|
||||
Object.entries(tlv)
|
||||
.reverse()
|
||||
.forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
import {test as testRegex, parse} from './nip21.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { test as testRegex, parse } from './nip21.ts'
|
||||
|
||||
test('test()', () => {
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
testRegex(
|
||||
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
|
||||
expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
|
||||
expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('nostr:')).toBe(false)
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('gggggg')).toBe(false)
|
||||
})
|
||||
|
||||
test('parse', () => {
|
||||
const result = parse(
|
||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
)
|
||||
const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||
|
||||
expect(result).toEqual({
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
|
||||
}
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
9
nip21.ts
9
nip21.ts
@@ -1,14 +1,11 @@
|
||||
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||
)
|
||||
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||
}
|
||||
|
||||
/** Parsed Nostr URI data. */
|
||||
@@ -28,6 +25,6 @@ export function parse(uri: string): NostrURI {
|
||||
return {
|
||||
uri: match[0] as `nostr:${string}`,
|
||||
value: match[1],
|
||||
decoded: decode(match[1])
|
||||
decoded: decode(match[1]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import {finishEvent, Kind} from './event.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
import {finishReactionEvent, getReactedEventPointer} from './nip25.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Reaction, ShortTextNote } from './kinds.ts'
|
||||
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
|
||||
|
||||
describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const reactedEvent = finishEvent(
|
||||
const reactedEvent = finalizeEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey']
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
test('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.kind).toEqual(Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('+')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
@@ -47,22 +47,22 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
test('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '👍',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.kind).toEqual(Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('👍')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
|
||||
37
nip25.ts
37
nip25.ts
@@ -1,6 +1,7 @@
|
||||
import {Event, finishEvent, Kind} from './event.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { Reaction } from './kinds.ts'
|
||||
|
||||
import type {EventPointer} from './nip19.ts'
|
||||
import type { EventPointer } from './nip19.ts'
|
||||
|
||||
export type ReactionEventTemplate = {
|
||||
/**
|
||||
@@ -16,30 +17,22 @@ export type ReactionEventTemplate = {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishReactionEvent(
|
||||
t: ReactionEventTemplate,
|
||||
reacted: Event<number>,
|
||||
privateKey: string,
|
||||
): Event<Kind.Reaction> {
|
||||
const inheritedTags = reacted.tags.filter(
|
||||
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
|
||||
)
|
||||
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, privateKey: Uint8Array): Event {
|
||||
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
|
||||
|
||||
return finishEvent({
|
||||
return finalizeEvent(
|
||||
{
|
||||
...t,
|
||||
kind: Kind.Reaction,
|
||||
tags: [
|
||||
...(t.tags ?? []),
|
||||
...inheritedTags,
|
||||
['e', reacted.id],
|
||||
['p', reacted.pubkey],
|
||||
],
|
||||
kind: Reaction,
|
||||
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||
content: t.content ?? '+',
|
||||
}, privateKey)
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Reaction) {
|
||||
export function getReactedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Reaction) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -63,7 +56,7 @@ export function getReactedEventPointer(event: Event<number>): undefined | EventP
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined),
|
||||
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
|
||||
author: lastPTag[1],
|
||||
}
|
||||
}
|
||||
|
||||
105
nip26.test.ts
105
nip26.test.ts
@@ -1,105 +0,0 @@
|
||||
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||
import {getDelegator, createDelegation} from './nip26.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
|
||||
test('parse good delegation from NIP', async () => {
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
|
||||
})
|
||||
|
||||
test('parse bad delegations', async () => {
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1740995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
test('create and verify delegation', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1})
|
||||
expect(delegation).toHaveProperty('from', pk1)
|
||||
expect(delegation).toHaveProperty('to', pk2)
|
||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
||||
|
||||
let event = buildEvent({
|
||||
kind: 1,
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||
pubkey: pk2,
|
||||
})
|
||||
expect(getDelegator(event)).toEqual(pk1)
|
||||
})
|
||||
92
nip26.ts
92
nip26.ts
@@ -1,92 +0,0 @@
|
||||
import {schnorr} from '@noble/curves/secp256k1'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
|
||||
import {utf8Encoder} from './utils.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
export type Parameters = {
|
||||
pubkey: string // the key to whom the delegation will be given
|
||||
kind?: number
|
||||
until?: number // delegation will only be valid until this date
|
||||
since?: number // delegation will be valid from this date on
|
||||
}
|
||||
|
||||
export type Delegation = {
|
||||
from: string // the pubkey who signed the delegation
|
||||
to: string // the pubkey that is allowed to use the delegation
|
||||
cond: string // the string of conditions as they should be included in the event tag
|
||||
sig: string
|
||||
}
|
||||
|
||||
export function createDelegation(
|
||||
privateKey: string,
|
||||
parameters: Parameters
|
||||
): Delegation {
|
||||
let conditions = []
|
||||
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
|
||||
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
|
||||
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
|
||||
let cond = conditions.join('&')
|
||||
|
||||
if (cond === '')
|
||||
throw new Error('refusing to create a delegation without any conditions')
|
||||
|
||||
let sighash = sha256(
|
||||
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
||||
)
|
||||
|
||||
let sig = bytesToHex(
|
||||
schnorr.sign(sighash, privateKey)
|
||||
)
|
||||
|
||||
return {
|
||||
from: getPublicKey(privateKey),
|
||||
to: parameters.pubkey,
|
||||
cond,
|
||||
sig
|
||||
}
|
||||
}
|
||||
|
||||
export function getDelegator(event: Event<number>): string | null {
|
||||
// find delegation tag
|
||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
||||
if (!tag) return null
|
||||
|
||||
let pubkey = tag[1]
|
||||
let cond = tag[2]
|
||||
let sig = tag[3]
|
||||
|
||||
// check conditions
|
||||
let conditions = cond.split('&')
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
let [key, operator, value] = conditions[i].split(/\b/)
|
||||
|
||||
// the supported conditions are just 'kind' and 'created_at' for now
|
||||
if (key === 'kind' && operator === '=' && event.kind === parseInt(value))
|
||||
continue
|
||||
else if (
|
||||
key === 'created_at' &&
|
||||
operator === '<' &&
|
||||
event.created_at < parseInt(value)
|
||||
)
|
||||
continue
|
||||
else if (
|
||||
key === 'created_at' &&
|
||||
operator === '>' &&
|
||||
event.created_at > parseInt(value)
|
||||
)
|
||||
continue
|
||||
else return null // invalid condition
|
||||
}
|
||||
|
||||
// check signature
|
||||
let sighash = sha256(
|
||||
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
|
||||
)
|
||||
if (!schnorr.verify(sig, sighash, pubkey)) return null
|
||||
|
||||
return pubkey
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import {matchAll, replaceAll} from './nip27.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
@@ -11,40 +12,40 @@ test('matchAll', () => {
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147
|
||||
}
|
||||
end: 147,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note'
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
}
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -52,7 +53,7 @@ test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
|
||||
const result = replaceAll(content, ({decoded, value}) => {
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
|
||||
15
nip27.ts
15
nip27.ts
@@ -1,5 +1,5 @@
|
||||
import {decode} from './nip19.ts'
|
||||
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
@@ -13,7 +13,7 @@ export interface NostrURIMatch extends NostrURI {
|
||||
}
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
@@ -25,7 +25,7 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length
|
||||
end: match.index! + uri.length,
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
@@ -52,15 +52,12 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(
|
||||
content: string,
|
||||
replacer: (match: NostrURI) => string
|
||||
): string {
|
||||
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)
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
130
nip28.test.ts
Normal file
130
nip28.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { getPublicKey } from './pure.ts'
|
||||
import * as Kind from './kinds.ts'
|
||||
import {
|
||||
channelCreateEvent,
|
||||
channelMetadataEvent,
|
||||
channelMessageEvent,
|
||||
channelHideMessageEvent,
|
||||
channelMuteUserEvent,
|
||||
ChannelMetadata,
|
||||
ChannelMessageEventTemplate,
|
||||
} from './nip28.ts'
|
||||
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
describe('NIP-28 Functions', () => {
|
||||
const channelMetadata: ChannelMetadata = {
|
||||
name: 'Test Channel',
|
||||
about: 'This is a test channel',
|
||||
picture: 'https://example.com/picture.jpg',
|
||||
}
|
||||
|
||||
test('channelCreateEvent should create an event with given template', () => {
|
||||
const template = {
|
||||
content: channelMetadata,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelCreateEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelCreation)
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
})
|
||||
|
||||
test('channelMetadataEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
content: channelMetadata,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMetadataEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelMetadata)
|
||||
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMessageEvent should create a signed message event with given template', () => {
|
||||
const template: ChannelMessageEventTemplate = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
relay_url: 'https://relay.example.com',
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMessageEvent(template, privateKey)
|
||||
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMessageEvent should create a signed message reply event with given template', () => {
|
||||
const template: ChannelMessageEventTemplate = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
reply_to_channel_message_event_id: 'channel message event id',
|
||||
relay_url: 'https://relay.example.com',
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelMessageEvent(template, privateKey)
|
||||
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
|
||||
'e',
|
||||
template.channel_create_event_id,
|
||||
template.relay_url,
|
||||
'root',
|
||||
])
|
||||
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
|
||||
'e',
|
||||
template.reply_to_channel_message_event_id as string,
|
||||
template.relay_url,
|
||||
'reply',
|
||||
])
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelHideMessageEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_message_event_id: 'channel message event id',
|
||||
content: { reason: 'Inappropriate content' },
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = channelHideMessageEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
|
||||
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
test('channelMuteUserEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
content: { reason: 'Spamming' },
|
||||
created_at: 1617932115,
|
||||
pubkey_to_mute: 'pubkey to mute',
|
||||
}
|
||||
|
||||
const event = channelMuteUserEvent(template, privateKey)
|
||||
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
|
||||
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
|
||||
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
expect(typeof event!.id).toEqual('string')
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
152
nip28.ts
Normal file
152
nip28.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||
|
||||
export interface ChannelMetadata {
|
||||
name: string
|
||||
about: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export interface ChannelCreateEventTemplate {
|
||||
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||
content: string | ChannelMetadata
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMetadataEventTemplate {
|
||||
channel_create_event_id: string
|
||||
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||
content: string | ChannelMetadata
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMessageEventTemplate {
|
||||
channel_create_event_id: string
|
||||
reply_to_channel_message_event_id?: string
|
||||
relay_url: string
|
||||
content: string
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelHideMessageEventTemplate {
|
||||
channel_message_event_id: string
|
||||
content: string | { reason: string }
|
||||
created_at: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface ChannelMuteUserEventTemplate {
|
||||
content: string | { reason: string }
|
||||
created_at: number
|
||||
pubkey_to_mute: string
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelCreation,
|
||||
tags: [...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMetadata,
|
||||
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
|
||||
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
|
||||
|
||||
if (t.reply_to_channel_message_event_id) {
|
||||
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMessage,
|
||||
tags: [...tags, ...(t.tags ?? [])],
|
||||
content: t.content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
/* "e" tag should be the kind 42 event to hide */
|
||||
export const channelHideMessageEvent = (
|
||||
t: ChannelHideMessageEventTemplate,
|
||||
privateKey: Uint8Array,
|
||||
): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelHideMessage,
|
||||
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
} else if (typeof t.content === 'string') {
|
||||
content = t.content
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMuteUser,
|
||||
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
33
nip30.test.ts
Normal file
33
nip30.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip30.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
name: 'blobcat',
|
||||
shortcode: ':blobcat:',
|
||||
start: 6,
|
||||
end: 15,
|
||||
},
|
||||
{
|
||||
name: 'disputed',
|
||||
shortcode: ':disputed:',
|
||||
start: 16,
|
||||
end: 26,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
|
||||
|
||||
const result = replaceAll(content, ({ name }) => {
|
||||
return `<img src="https://ditto.pub/emoji/${name}.png" />`
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
|
||||
)
|
||||
})
|
||||
51
nip30.ts
Normal file
51
nip30.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Regex for a single emoji shortcode. */
|
||||
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
|
||||
|
||||
/** Regex to find emoji shortcodes in content. */
|
||||
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||
|
||||
/** Represents a Nostr custom emoji. */
|
||||
export interface CustomEmoji {
|
||||
/** The matched emoji name with colons. */
|
||||
shortcode: `:${string}:`
|
||||
/** The matched emoji name without colons. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Match result for a custom emoji in text content. */
|
||||
export interface CustomEmojiMatch extends CustomEmoji {
|
||||
/** Index where the emoji begins in the text content. */
|
||||
start: number
|
||||
/** Index where the emoji ends in the text content. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Find all custom emoji shortcodes. */
|
||||
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [shortcode, name] = match
|
||||
|
||||
yield {
|
||||
shortcode: shortcode as `:${string}:`,
|
||||
name,
|
||||
start: match.index!,
|
||||
end: match.index! + shortcode.length,
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace all emoji shortcodes in the content. */
|
||||
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
|
||||
return content.replaceAll(regex(), (shortcode, name) => {
|
||||
return replacer({
|
||||
shortcode: shortcode as `:${string}:`,
|
||||
name,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import {useFetchImplementation, validateGithub} from './nip39.ts'
|
||||
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||
|
||||
test('validate github claim', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
@@ -8,7 +9,7 @@ test('validate github claim', async () => {
|
||||
let result = await validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421',
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
15
nip39.ts
15
nip39.ts
@@ -8,19 +8,10 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function validateGithub(
|
||||
pubkey: string,
|
||||
username: string,
|
||||
proof: string
|
||||
): Promise<boolean> {
|
||||
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||
try {
|
||||
let res = await (
|
||||
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
|
||||
).text()
|
||||
return (
|
||||
res ===
|
||||
`Verifying that I control the following Nostr public key: ${pubkey}`
|
||||
)
|
||||
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import 'websocket-polyfill'
|
||||
import { test, expect } from 'bun:test'
|
||||
|
||||
import {finishEvent} from './event.ts'
|
||||
import {generatePrivateKey} from './keys.ts'
|
||||
import {authenticate} from './nip42.ts'
|
||||
import {relayInit} from './relay.ts'
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
test('auth flow', () => {
|
||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
||||
relay.connect()
|
||||
const sk = generatePrivateKey()
|
||||
test('auth flow', async () => {
|
||||
const relay = await Relay.connect('wss://nostr.wine')
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
relay.on('auth', async challenge => {
|
||||
await expect(
|
||||
authenticate({
|
||||
challenge,
|
||||
relay,
|
||||
sign: (e) => finishEvent(e, sk)
|
||||
})
|
||||
).rejects.toBeTruthy()
|
||||
relay.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
43
nip42.ts
43
nip42.ts
@@ -1,42 +1,17 @@
|
||||
import {Kind, type EventTemplate, type Event} from './event.ts'
|
||||
import {Relay} from './relay.ts'
|
||||
import { EventTemplate } from './core.ts'
|
||||
import { ClientAuth } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Authenticate via NIP-42 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* relay.on('auth', challenge =>
|
||||
* authenticate({ relay, sign, challenge })
|
||||
* )
|
||||
* creates an EventTemplate for an AUTH event to be signed.
|
||||
*/
|
||||
export const authenticate = async ({
|
||||
challenge,
|
||||
relay,
|
||||
sign
|
||||
}: {
|
||||
challenge: string
|
||||
relay: Relay
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
|
||||
}): Promise<void> => {
|
||||
const e: EventTemplate = {
|
||||
kind: Kind.ClientAuth,
|
||||
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
|
||||
return {
|
||||
kind: ClientAuth,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relay.url],
|
||||
['challenge', challenge]
|
||||
['relay', relayURL],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: ''
|
||||
content: '',
|
||||
}
|
||||
const pub = relay.auth(await sign(e))
|
||||
return new Promise((resolve, reject) => {
|
||||
pub.on('ok', function ok() {
|
||||
pub.off('ok', ok)
|
||||
resolve()
|
||||
})
|
||||
pub.on('failed', function fail(reason: string) {
|
||||
pub.off('failed', fail)
|
||||
reject(reason)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
44
nip44.test.ts
Normal file
44
nip44.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { v2 } from './nip44.js'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
test('get_conversation_key', () => {
|
||||
for (const v of v2vec.valid.get_conversation_key) {
|
||||
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
}
|
||||
})
|
||||
|
||||
test('encrypt_decrypt', () => {
|
||||
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||
expect(ciphertext).toEqual(v.payload)
|
||||
const decrypted = v2.decrypt(ciphertext, key)
|
||||
expect(decrypted).toEqual(v.plaintext)
|
||||
}
|
||||
})
|
||||
|
||||
test('calc_padded_len', () => {
|
||||
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||
const actual = v2.utils.calcPaddedLen(len)
|
||||
expect(actual).toEqual(shouldBePaddedTo)
|
||||
}
|
||||
})
|
||||
|
||||
test('decrypt', async () => {
|
||||
for (const v of v2vec.invalid.decrypt) {
|
||||
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
|
||||
}
|
||||
})
|
||||
|
||||
test('get_conversation_key', async () => {
|
||||
for (const v of v2vec.invalid.get_conversation_key) {
|
||||
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
|
||||
}
|
||||
})
|
||||
131
nip44.ts
Normal file
131
nip44.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { chacha20 } from '@noble/ciphers/chacha'
|
||||
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||
import { hmac } from '@noble/hashes/hmac'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const u = {
|
||||
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||
|
||||
utf8Encode: utf8ToBytes,
|
||||
utf8Decode(bytes: Uint8Array) {
|
||||
return decoder.decode(bytes)
|
||||
},
|
||||
|
||||
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
},
|
||||
|
||||
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||
ensureBytes(conversationKey, 32)
|
||||
ensureBytes(nonce, 32)
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
},
|
||||
|
||||
calcPaddedLen(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||
if (len <= 32) return 32
|
||||
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||
},
|
||||
|
||||
writeU16BE(num: number) {
|
||||
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||
const arr = new Uint8Array(2)
|
||||
new DataView(arr.buffer).setUint16(0, num, false)
|
||||
return arr
|
||||
},
|
||||
|
||||
pad(plaintext: string): Uint8Array {
|
||||
const unpadded = u.utf8Encode(plaintext)
|
||||
const unpaddedLen = unpadded.length
|
||||
const prefix = u.writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
return concatBytes(prefix, unpadded, suffix)
|
||||
},
|
||||
|
||||
unpad(padded: Uint8Array): string {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||
if (
|
||||
unpaddedLen < u.minPlaintextSize ||
|
||||
unpaddedLen > u.maxPlaintextSize ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return u.utf8Decode(unpadded)
|
||||
},
|
||||
|
||||
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
},
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
// padded plaintext: 32b to 0xffff
|
||||
// ciphertext: 32b+2 to 0xffff+2
|
||||
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||
// compressed payload (base64): 132b to 87472b
|
||||
decodePayload(payload: string) {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = base64.decode(payload)
|
||||
} catch (error) {
|
||||
throw new Error('invalid base64: ' + (error as any).message)
|
||||
}
|
||||
const dlen = data.length
|
||||
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||
const vers = data[0]
|
||||
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||
return {
|
||||
nonce: data.subarray(1, 33),
|
||||
ciphertext: data.subarray(33, -32),
|
||||
mac: data.subarray(-32),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const padded = u.pad(plaintext)
|
||||
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||
}
|
||||
|
||||
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||
return u.unpad(padded)
|
||||
}
|
||||
|
||||
export const v2 = {
|
||||
utils: u,
|
||||
encrypt,
|
||||
decrypt,
|
||||
}
|
||||
|
||||
export default { v2 }
|
||||
605
nip44.vectors.json
Normal file
605
nip44.vectors.json
Normal file
@@ -0,0 +1,605 @@
|
||||
{
|
||||
"v2": {
|
||||
"valid": {
|
||||
"get_conversation_key": [
|
||||
{
|
||||
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
|
||||
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
|
||||
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
|
||||
},
|
||||
{
|
||||
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
|
||||
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
|
||||
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
|
||||
},
|
||||
{
|
||||
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
|
||||
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
|
||||
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
|
||||
},
|
||||
{
|
||||
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
|
||||
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
|
||||
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
|
||||
},
|
||||
{
|
||||
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
|
||||
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
|
||||
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
|
||||
},
|
||||
{
|
||||
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
|
||||
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
|
||||
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
|
||||
},
|
||||
{
|
||||
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
|
||||
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
|
||||
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
|
||||
},
|
||||
{
|
||||
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
|
||||
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
|
||||
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
|
||||
},
|
||||
{
|
||||
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
|
||||
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
|
||||
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
|
||||
},
|
||||
{
|
||||
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
|
||||
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
|
||||
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
|
||||
},
|
||||
{
|
||||
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
|
||||
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
|
||||
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
|
||||
},
|
||||
{
|
||||
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
|
||||
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
|
||||
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
|
||||
},
|
||||
{
|
||||
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
|
||||
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
|
||||
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
|
||||
},
|
||||
{
|
||||
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
|
||||
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
|
||||
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
|
||||
},
|
||||
{
|
||||
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
|
||||
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
|
||||
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
|
||||
},
|
||||
{
|
||||
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
|
||||
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
|
||||
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
|
||||
},
|
||||
{
|
||||
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
|
||||
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
|
||||
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
|
||||
},
|
||||
{
|
||||
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
|
||||
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
|
||||
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
|
||||
},
|
||||
{
|
||||
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
|
||||
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
|
||||
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
|
||||
},
|
||||
{
|
||||
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
|
||||
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
|
||||
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
|
||||
},
|
||||
{
|
||||
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
|
||||
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
|
||||
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
|
||||
},
|
||||
{
|
||||
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
|
||||
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
|
||||
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
|
||||
},
|
||||
{
|
||||
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
|
||||
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
|
||||
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
|
||||
},
|
||||
{
|
||||
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
|
||||
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
|
||||
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
|
||||
},
|
||||
{
|
||||
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
|
||||
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
|
||||
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
|
||||
},
|
||||
{
|
||||
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
|
||||
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
|
||||
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
|
||||
},
|
||||
{
|
||||
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
|
||||
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
|
||||
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
|
||||
},
|
||||
{
|
||||
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
|
||||
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
|
||||
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
|
||||
},
|
||||
{
|
||||
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
|
||||
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
|
||||
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
|
||||
},
|
||||
{
|
||||
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
|
||||
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
|
||||
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
|
||||
},
|
||||
{
|
||||
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
|
||||
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
|
||||
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
|
||||
},
|
||||
{
|
||||
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
|
||||
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
|
||||
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
|
||||
"note": "sec1 = n-2, pub2: random, 0x02"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
|
||||
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
|
||||
"note": "sec1 = 2, pub2: rand"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
|
||||
"note": "sec1 == pub2"
|
||||
}
|
||||
],
|
||||
"get_message_keys": {
|
||||
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
|
||||
"keys": [
|
||||
{
|
||||
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
|
||||
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
|
||||
"chacha_nonce": "c4ad129bb01180c0933a160c",
|
||||
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
|
||||
},
|
||||
{
|
||||
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
|
||||
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
|
||||
"chacha_nonce": "22925e920cee4a50a478be90",
|
||||
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
|
||||
},
|
||||
{
|
||||
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
|
||||
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
|
||||
"chacha_nonce": "d3594987af769a52904656ac",
|
||||
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
|
||||
},
|
||||
{
|
||||
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
|
||||
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
|
||||
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
|
||||
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
|
||||
},
|
||||
{
|
||||
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
|
||||
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
|
||||
"chacha_nonce": "400224ba67fc2f1b76736916",
|
||||
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
|
||||
},
|
||||
{
|
||||
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
|
||||
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
|
||||
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
|
||||
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
|
||||
},
|
||||
{
|
||||
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
|
||||
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
|
||||
"chacha_nonce": "72f69a5a5f795465cee59da8",
|
||||
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
|
||||
},
|
||||
{
|
||||
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
|
||||
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
|
||||
"chacha_nonce": "6e69be92d61c04a276021565",
|
||||
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
|
||||
},
|
||||
{
|
||||
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
|
||||
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
|
||||
"chacha_nonce": "3dda53569cfcb7fac1805c35",
|
||||
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
|
||||
},
|
||||
{
|
||||
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
|
||||
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
|
||||
"chacha_nonce": "65064239186e50304cc0f156",
|
||||
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
|
||||
},
|
||||
{
|
||||
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
|
||||
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
|
||||
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
|
||||
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
|
||||
},
|
||||
{
|
||||
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
|
||||
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
|
||||
"chacha_nonce": "cbb2530ea653766e5a37a83a",
|
||||
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
|
||||
},
|
||||
{
|
||||
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
|
||||
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
|
||||
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
|
||||
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
|
||||
},
|
||||
{
|
||||
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
|
||||
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
|
||||
"chacha_nonce": "67803605a7e5010d0f63f8c8",
|
||||
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
|
||||
},
|
||||
{
|
||||
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
|
||||
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
|
||||
"chacha_nonce": "4e62a0073087ed808be62469",
|
||||
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
|
||||
},
|
||||
{
|
||||
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
|
||||
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
|
||||
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
|
||||
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
|
||||
},
|
||||
{
|
||||
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
|
||||
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
|
||||
"chacha_nonce": "329bb3024461e84b2e1c489b",
|
||||
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
|
||||
},
|
||||
{
|
||||
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
|
||||
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
|
||||
"chacha_nonce": "653d759042b85194d4d8c0a7",
|
||||
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
|
||||
},
|
||||
{
|
||||
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
|
||||
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
|
||||
"chacha_nonce": "b822e2c959df32b3cb772a7c",
|
||||
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
|
||||
},
|
||||
{
|
||||
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
|
||||
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
|
||||
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
|
||||
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
|
||||
},
|
||||
{
|
||||
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
|
||||
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
|
||||
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
|
||||
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
|
||||
},
|
||||
{
|
||||
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
|
||||
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
|
||||
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
|
||||
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
|
||||
},
|
||||
{
|
||||
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
|
||||
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
|
||||
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
|
||||
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
|
||||
},
|
||||
{
|
||||
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
|
||||
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
|
||||
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
|
||||
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
|
||||
},
|
||||
{
|
||||
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
|
||||
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
|
||||
"chacha_nonce": "263830a065af33d9c6c5aa1f",
|
||||
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
|
||||
},
|
||||
{
|
||||
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
|
||||
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
|
||||
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
|
||||
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
|
||||
},
|
||||
{
|
||||
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
|
||||
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
|
||||
"chacha_nonce": "ccdaad5b3b7645be430992eb",
|
||||
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
|
||||
},
|
||||
{
|
||||
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
|
||||
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
|
||||
"chacha_nonce": "ed02dece5fc3a186f123420b",
|
||||
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
|
||||
},
|
||||
{
|
||||
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
|
||||
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
|
||||
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
|
||||
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
|
||||
},
|
||||
{
|
||||
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
|
||||
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
|
||||
"chacha_nonce": "26b450612ca5e905b937e147",
|
||||
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
|
||||
},
|
||||
{
|
||||
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
|
||||
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
|
||||
"chacha_nonce": "4a296a1fb0048e5020d3b129",
|
||||
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
|
||||
},
|
||||
{
|
||||
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
|
||||
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
|
||||
"chacha_nonce": "a8188daff807a1182200b39d",
|
||||
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"calc_padded_len": [
|
||||
[16, 32],
|
||||
[32, 32],
|
||||
[33, 64],
|
||||
[37, 64],
|
||||
[45, 64],
|
||||
[49, 64],
|
||||
[64, 64],
|
||||
[65, 96],
|
||||
[100, 128],
|
||||
[111, 128],
|
||||
[200, 224],
|
||||
[250, 256],
|
||||
[320, 320],
|
||||
[383, 384],
|
||||
[384, 384],
|
||||
[400, 448],
|
||||
[500, 512],
|
||||
[512, 512],
|
||||
[515, 640],
|
||||
[700, 768],
|
||||
[800, 896],
|
||||
[900, 1024],
|
||||
[1020, 1024],
|
||||
[65536, 65536]
|
||||
],
|
||||
"encrypt_decrypt": [
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"plaintext": "a",
|
||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||
"plaintext": "🍕🫃",
|
||||
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
|
||||
},
|
||||
{
|
||||
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
|
||||
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
|
||||
},
|
||||
{
|
||||
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
|
||||
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
|
||||
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
|
||||
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
|
||||
"plaintext": "ability🤝的 ȺȾ",
|
||||
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
|
||||
},
|
||||
{
|
||||
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
|
||||
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
|
||||
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
|
||||
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
|
||||
"plaintext": "pepper👀їжак",
|
||||
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
|
||||
},
|
||||
{
|
||||
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
|
||||
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||
"plaintext": "( ͡° ͜ʖ ͡°)",
|
||||
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
|
||||
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
|
||||
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
|
||||
"plaintext": "الكل في المجمو عة (5)",
|
||||
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
|
||||
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
|
||||
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
|
||||
}
|
||||
],
|
||||
"encrypt_decrypt_long_msg": [
|
||||
{
|
||||
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
|
||||
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
|
||||
"letter": "ф",
|
||||
"repeat": 65535,
|
||||
"payload_checksum_sha256": "",
|
||||
"note": "фффф... (65535 times)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"invalid": {
|
||||
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
|
||||
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
|
||||
"get_conversation_key": [
|
||||
{
|
||||
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 higher than curve.n"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 is 0"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"note": "pub2 is invalid, no sqrt, all-ff"
|
||||
},
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "sec1 == curve.n"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"note": "pub2 is invalid, no sqrt"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"note": "pub2 is point of order 3 on twist"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
|
||||
"note": "pub2 is point of order 13 on twist"
|
||||
},
|
||||
{
|
||||
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
|
||||
"note": "pub2 is point of order 3319 on twist"
|
||||
}
|
||||
],
|
||||
"decrypt": [
|
||||
{
|
||||
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o b l e",
|
||||
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
|
||||
"note": "unknown encryption version"
|
||||
},
|
||||
{
|
||||
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
|
||||
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
|
||||
"plaintext": "⚠️",
|
||||
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
|
||||
"note": "unknown encryption version 0"
|
||||
},
|
||||
{
|
||||
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o s t r",
|
||||
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
|
||||
"note": "invalid base64"
|
||||
},
|
||||
{
|
||||
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
|
||||
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
|
||||
"plaintext": "¯\\_(ツ)_/¯",
|
||||
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
|
||||
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
|
||||
"plaintext": "🥎",
|
||||
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
|
||||
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
|
||||
"plaintext": "elliptic-curve cryptography",
|
||||
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
|
||||
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
|
||||
"plaintext": "noble",
|
||||
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
|
||||
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
|
||||
"plaintext": "censorship-resistant and global social network",
|
||||
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
|
||||
"note": "invalid padding"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
65
nip47.test.ts
Normal file
65
nip47.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { decrypt } from './nip04.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
describe('parseConnectionString', () => {
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||
|
||||
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||
expect(relay).toBe('wss://relay.damus.io')
|
||||
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
})
|
||||
|
||||
test('throws an error if no pubkey in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no relay in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no secret in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeNwcRequestEvent', () => {
|
||||
test('returns a valid NWC request event', async () => {
|
||||
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
const invoice =
|
||||
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
|
||||
expect(result.kind).toBe(NWCWalletRequest)
|
||||
expect(await decrypt(secret, pubkey, result.content)).toEqual(
|
||||
JSON.stringify({
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result.tags).toEqual([['p', pubkey]])
|
||||
expect(result.id).toEqual(expect.any(String))
|
||||
expect(result.sig).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
34
nip47.ts
Normal file
34
nip47.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
import { encrypt } from './nip04.ts'
|
||||
|
||||
export function parseConnectionString(connectionString: string) {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const relay = searchParams.get('relay')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (!pubkey || !relay || !secret) {
|
||||
throw new Error('invalid connection string')
|
||||
}
|
||||
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: NWCWalletRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: encryptedContent,
|
||||
tags: [['p', pubkey]],
|
||||
}
|
||||
|
||||
return finalizeEvent(eventTemplate, secretKey)
|
||||
}
|
||||
245
nip57.test.ts
245
nip57.test.ts
@@ -1,69 +1,56 @@
|
||||
import {finishEvent} from './event.ts'
|
||||
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||
import {
|
||||
getZapEndpoint,
|
||||
makeZapReceipt,
|
||||
makeZapRequest,
|
||||
useFetchImplementation,
|
||||
validateZapRequest,
|
||||
} from './nip57.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = buildEvent({kind: 0, content: '{}'})
|
||||
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
||||
const fetchImplementation = mock(() => Promise.reject(new Error()))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
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'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns null if the response does not allow Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
Promise.resolve({json: () => ({allowsNostr: false})})
|
||||
)
|
||||
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
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'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
const fetchImplementation = mock(() =>
|
||||
Promise.resolve({
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
nostrPubkey: 'pubkey',
|
||||
callback: 'callback'
|
||||
})
|
||||
})
|
||||
callback: 'callback',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
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'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,8 +62,8 @@ describe('makeZapRequest', () => {
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
comment: ''
|
||||
})
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
@@ -87,8 +74,8 @@ describe('makeZapRequest', () => {
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
comment: ''
|
||||
})
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
@@ -98,7 +85,7 @@ describe('makeZapRequest', () => {
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
relays: ['relay1', 'relay2'],
|
||||
comment: 'comment'
|
||||
comment: 'comment',
|
||||
})
|
||||
expect(result.kind).toBe(9734)
|
||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||
@@ -107,18 +94,16 @@ describe('makeZapRequest', () => {
|
||||
expect.arrayContaining([
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
])
|
||||
['relays', 'relay1', 'relay2'],
|
||||
['e', 'event'],
|
||||
]),
|
||||
)
|
||||
expect(result.tags).toContainEqual(['e', 'event'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
expect(validateZapRequest('invalid JSON')).toBe(
|
||||
'Invalid zap request JSON.'
|
||||
)
|
||||
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||
@@ -129,17 +114,15 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
'Zap request is not a valid Nostr event.'
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||
})
|
||||
|
||||
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = {
|
||||
@@ -150,40 +133,34 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
'Invalid signature on zap request.'
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
const privateKey = generateSecretKey()
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'p' tag."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
const privateKey = generateSecretKey()
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
@@ -191,22 +168,20 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'p' tag is not valid hex."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
@@ -215,44 +190,20 @@ describe('validateZapRequest', () => {
|
||||
['p', publicKey],
|
||||
['e', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'e' tag is not valid hex."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100']
|
||||
]
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'relays' tag."
|
||||
)
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
@@ -260,10 +211,30 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
@@ -271,70 +242,78 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
describe('makeZapReceipt', () => {
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const zapRequest = JSON.stringify(
|
||||
finishEvent(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const preimage = 'preimage'
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
||||
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
||||
expect(result.tags).toContainEqual(['p', publicKey])
|
||||
expect(result.tags).toContainEqual(['preimage', preimage])
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', target],
|
||||
['P', publicKey],
|
||||
['preimage', preimage],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('returns a valid Zap receipt without a preimage', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finishEvent(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = makeZapReceipt({zapRequest, bolt11, paidAt})
|
||||
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.tags).toContainEqual(['bolt11', bolt11])
|
||||
expect(result.tags).toContainEqual(['description', zapRequest])
|
||||
expect(result.tags).toContainEqual(['p', publicKey])
|
||||
expect(result.tags).not.toContain('preimage')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', target],
|
||||
['P', publicKey],
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||
})
|
||||
})
|
||||
|
||||
59
nip57.ts
59
nip57.ts
@@ -1,13 +1,7 @@
|
||||
import {bech32} from '@scure/base'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import {
|
||||
Kind,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
type Event,
|
||||
type EventTemplate,
|
||||
} from './event.ts'
|
||||
import {utf8Decoder} from './utils.ts'
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -19,14 +13,12 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function getZapEndpoint(
|
||||
metadata: Event<Kind.Metadata>
|
||||
): Promise<null | string> {
|
||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let {lud06, lud16} = JSON.parse(metadata.content)
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
let {words} = bech32.decode(lud06, 1000)
|
||||
let { words } = bech32.decode(lud06, 1000)
|
||||
let data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
@@ -54,26 +46,26 @@ export function makeZapRequest({
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = ''
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
}): EventTemplate<Kind.ZapRequest> {
|
||||
}): EventTemplate {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
|
||||
let zr: EventTemplate<Kind.ZapRequest> = {
|
||||
let zr: EventTemplate = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays]
|
||||
]
|
||||
['relays', ...relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
@@ -92,19 +84,16 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
||||
return 'Invalid zap request JSON.'
|
||||
}
|
||||
|
||||
if (!validateEvent(zapRequest))
|
||||
return 'Zap request is not a valid Nostr event.'
|
||||
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||
|
||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
||||
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
|
||||
|
||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/))
|
||||
return "Zap request 'p' tag is not valid hex."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||
|
||||
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/))
|
||||
return "Zap request 'e' tag is not valid hex."
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||
|
||||
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||
@@ -116,27 +105,21 @@ export function makeZapReceipt({
|
||||
zapRequest,
|
||||
preimage,
|
||||
bolt11,
|
||||
paidAt
|
||||
paidAt,
|
||||
}: {
|
||||
zapRequest: string
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate<Kind.Zap> {
|
||||
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(
|
||||
([t]) => t === 'e' || t === 'p' || t === 'a'
|
||||
)
|
||||
}): EventTemplate {
|
||||
let zr: Event = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||
|
||||
let zap: EventTemplate<Kind.Zap> = {
|
||||
let zap: EventTemplate = {
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
...tagsFromZapRequest,
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest]
|
||||
]
|
||||
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||
}
|
||||
|
||||
if (preimage) {
|
||||
|
||||
146
nip98.test.ts
146
nip98.test.ts
@@ -1,109 +1,93 @@
|
||||
import {base64} from '@scure/base'
|
||||
import {getToken, validateToken} from './nip98.ts'
|
||||
import {Event, Kind, finishEvent} from './event.ts'
|
||||
import {utf8Decoder} from './utils.ts'
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const sk = generatePrivateKey()
|
||||
const sk = generateSecretKey()
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e =>
|
||||
finishEvent(e, sk)
|
||||
)
|
||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = JSON.parse(
|
||||
utf8Decoder.decode(base64.decode(result))
|
||||
)
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get']
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e =>
|
||||
finishEvent(e, sk)
|
||||
)
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = JSON.parse(
|
||||
utf8Decoder.decode(base64.decode(result))
|
||||
)
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post']
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
|
||||
let result = await getToken(
|
||||
'http://test.com',
|
||||
'post',
|
||||
e => finishEvent(e, sk),
|
||||
true
|
||||
)
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = JSON.parse(
|
||||
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
|
||||
)
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post']
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken unknown method throws an error', async () => {
|
||||
const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
|
||||
test('getToken missing loginUrl throws an error', async () => {
|
||||
const result = getToken('', 'get', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
test('getToken missing httpMethod throws an error', async () => {
|
||||
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e =>
|
||||
finishEvent(e, sk)
|
||||
)
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken(
|
||||
'http://test.com',
|
||||
'get',
|
||||
e => finishEvent(e, sk),
|
||||
true
|
||||
)
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
@@ -111,29 +95,65 @@ describe('validateToken', () => {
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e =>
|
||||
finishEvent(e, sk)
|
||||
)
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e =>
|
||||
finishEvent(e, sk)
|
||||
)
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
109
nip98.ts
109
nip98.ts
@@ -1,70 +1,69 @@
|
||||
import {base64} from '@scure/base'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
Kind,
|
||||
getBlankEvent,
|
||||
verifySignature
|
||||
} from './event'
|
||||
import {utf8Decoder, utf8Encoder} from './utils'
|
||||
|
||||
enum HttpMethod {
|
||||
Get = 'get',
|
||||
Post = 'post'
|
||||
}
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { base64 } from '@scure/base'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* await getToken('https://example.com/login', 'post', sign, true)
|
||||
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||
*/
|
||||
export async function getToken(
|
||||
loginUrl: string,
|
||||
httpMethod: HttpMethod | string,
|
||||
sign: <K extends number = number>(
|
||||
e: EventTemplate<K>
|
||||
) => Promise<Event<K>> | Event<K>,
|
||||
includeAuthorizationScheme: boolean = false
|
||||
httpMethod: string,
|
||||
sign: (e: EventTemplate) => Promise<Event> | Event,
|
||||
includeAuthorizationScheme: boolean = false,
|
||||
payload?: Record<string, any>,
|
||||
): Promise<string> {
|
||||
if (!loginUrl || !httpMethod)
|
||||
throw new Error('Missing loginUrl or httpMethod')
|
||||
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
|
||||
throw new Error('Unknown httpMethod')
|
||||
|
||||
const event = getBlankEvent(Kind.HttpAuth)
|
||||
|
||||
event.tags = [
|
||||
const event: EventTemplate = {
|
||||
kind: HTTPAuth,
|
||||
tags: [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod]
|
||||
]
|
||||
event.created_at = Math.round(new Date().getTime() / 1000)
|
||||
['method', httpMethod],
|
||||
],
|
||||
created_at: Math.round(new Date().getTime() / 1000),
|
||||
content: '',
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||
|
||||
const authorizationScheme = includeAuthorizationScheme
|
||||
? _authorizationScheme
|
||||
: ''
|
||||
return (
|
||||
authorizationScheme +
|
||||
base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
)
|
||||
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* await validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||
*/
|
||||
export async function validateToken(
|
||||
token: string,
|
||||
url: string,
|
||||
method: string
|
||||
): Promise<boolean> {
|
||||
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
@@ -76,13 +75,18 @@ export async function validateToken(
|
||||
}
|
||||
|
||||
const event = JSON.parse(eventB64) as Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
}
|
||||
if (!verifySignature(event)) {
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== Kind.HttpAuth) {
|
||||
if (event.kind !== HTTPAuth) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
@@ -101,12 +105,17 @@ export async function validateToken(
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (
|
||||
methodTag?.length !== 1 &&
|
||||
methodTag?.[1].toLowerCase() !== method.toLowerCase()
|
||||
) {
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
||||
if (payloadTag?.[1] !== payloadHash) {
|
||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
209
package.json
209
package.json
@@ -1,29 +1,189 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "1.13.0",
|
||||
"version": "2.1.2",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nbd-wtf/nostr-tools.git"
|
||||
},
|
||||
"files": [
|
||||
"./lib/**/*"
|
||||
"lib"
|
||||
],
|
||||
"types": "./lib/index.d.ts",
|
||||
"main": "lib/nostr.cjs.js",
|
||||
"module": "lib/esm/nostr.mjs",
|
||||
"sideEffects": false,
|
||||
"module": "./lib/esm/index.js",
|
||||
"main": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"exports": {
|
||||
"import": "./lib/esm/nostr.mjs",
|
||||
"require": "./lib/nostr.cjs.js",
|
||||
"types": "./lib/index.d.ts"
|
||||
".": {
|
||||
"import": "./lib/esm/index.js",
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./pure": {
|
||||
"import": "./lib/esm/pure.js",
|
||||
"require": "./lib/cjs/pure.js",
|
||||
"types": "./lib/types/pure.d.ts"
|
||||
},
|
||||
"./wasm": {
|
||||
"import": "./lib/esm/wasm.js",
|
||||
"require": "./lib/cjs/wasm.js",
|
||||
"types": "./lib/types/wasm.d.ts"
|
||||
},
|
||||
"./kinds": {
|
||||
"import": "./lib/esm/kinds.js",
|
||||
"require": "./lib/cjs/kinds.js",
|
||||
"types": "./lib/types/kinds.d.ts"
|
||||
},
|
||||
"./filter": {
|
||||
"import": "./lib/esm/filter.js",
|
||||
"require": "./lib/cjs/filter.js",
|
||||
"types": "./lib/types/filter.d.ts"
|
||||
},
|
||||
"./abstract-relay": {
|
||||
"import": "./lib/esm/abstract-relay.js",
|
||||
"require": "./lib/cjs/abstract-relay.js",
|
||||
"types": "./lib/types/abstract-relay.d.ts"
|
||||
},
|
||||
"./relay": {
|
||||
"import": "./lib/esm/relay.js",
|
||||
"require": "./lib/cjs/relay.js",
|
||||
"types": "./lib/types/relay.d.ts"
|
||||
},
|
||||
"./abstract-pool": {
|
||||
"import": "./lib/esm/abstract-pool.js",
|
||||
"require": "./lib/cjs/abstract-pool.js",
|
||||
"types": "./lib/types/abstract-pool.d.ts"
|
||||
},
|
||||
"./pool": {
|
||||
"import": "./lib/esm/pool.js",
|
||||
"require": "./lib/cjs/pool.js",
|
||||
"types": "./lib/types/pool.d.ts"
|
||||
},
|
||||
"./references": {
|
||||
"import": "./lib/esm/references.js",
|
||||
"require": "./lib/cjs/references.js",
|
||||
"types": "./lib/types/references.d.ts"
|
||||
},
|
||||
"./nip04": {
|
||||
"import": "./lib/esm/nip04.js",
|
||||
"require": "./lib/cjs/nip04.js",
|
||||
"types": "./lib/types/nip04.d.ts"
|
||||
},
|
||||
"./nip44": {
|
||||
"import": "./lib/esm/nip44.js",
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip05": {
|
||||
"import": "./lib/esm/nip05.js",
|
||||
"require": "./lib/cjs/nip05.js",
|
||||
"types": "./lib/types/nip05.d.ts"
|
||||
},
|
||||
"./nip06": {
|
||||
"import": "./lib/esm/nip06.js",
|
||||
"require": "./lib/cjs/nip06.js",
|
||||
"types": "./lib/types/nip06.d.ts"
|
||||
},
|
||||
"./nip10": {
|
||||
"import": "./lib/esm/nip10.js",
|
||||
"require": "./lib/cjs/nip10.js",
|
||||
"types": "./lib/types/nip10.d.ts"
|
||||
},
|
||||
"./nip11": {
|
||||
"import": "./lib/esm/nip11.js",
|
||||
"require": "./lib/cjs/nip11.js",
|
||||
"types": "./lib/types/nip11.d.ts"
|
||||
},
|
||||
"./nip13": {
|
||||
"import": "./lib/esm/nip13.js",
|
||||
"require": "./lib/cjs/nip13.js",
|
||||
"types": "./lib/types/nip13.d.ts"
|
||||
},
|
||||
"./nip18": {
|
||||
"import": "./lib/esm/nip18.js",
|
||||
"require": "./lib/cjs/nip18.js",
|
||||
"types": "./lib/types/nip18.d.ts"
|
||||
},
|
||||
"./nip19": {
|
||||
"import": "./lib/esm/nip19.js",
|
||||
"require": "./lib/cjs/nip19.js",
|
||||
"types": "./lib/types/nip19.d.ts"
|
||||
},
|
||||
"./nip21": {
|
||||
"import": "./lib/esm/nip21.js",
|
||||
"require": "./lib/cjs/nip21.js",
|
||||
"types": "./lib/types/nip21.d.ts"
|
||||
},
|
||||
"./nip25": {
|
||||
"import": "./lib/esm/nip25.js",
|
||||
"require": "./lib/cjs/nip25.js",
|
||||
"types": "./lib/types/nip25.d.ts"
|
||||
},
|
||||
"./nip27": {
|
||||
"import": "./lib/esm/nip27.js",
|
||||
"require": "./lib/cjs/nip27.js",
|
||||
"types": "./lib/types/nip27.d.ts"
|
||||
},
|
||||
"./nip28": {
|
||||
"import": "./lib/esm/nip28.js",
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
"types": "./lib/types/nip30.d.ts"
|
||||
},
|
||||
"./nip39": {
|
||||
"import": "./lib/esm/nip39.js",
|
||||
"require": "./lib/cjs/nip39.js",
|
||||
"types": "./lib/types/nip39.d.ts"
|
||||
},
|
||||
"./nip42": {
|
||||
"import": "./lib/esm/nip42.js",
|
||||
"require": "./lib/cjs/nip42.js",
|
||||
"types": "./lib/types/nip42.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
"types": "./lib/types/nip57.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
"types": "./lib/types/nip98.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
"types": "./lib/types/fakejson.d.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"import": "./lib/esm/utils.js",
|
||||
"require": "./lib/cjs/utils.js",
|
||||
"types": "./lib/types/utils.d.ts"
|
||||
}
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/curves": "1.0.0",
|
||||
"@noble/hashes": "1.3.0",
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.0",
|
||||
"@scure/bip39": "1.2.0"
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"mitata": "^0.1.6",
|
||||
"nostr-wasm": "v0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
@@ -32,30 +192,25 @@
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-jest": "^27.2.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.5.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^2.8.4",
|
||||
"ts-jest": "^29.1.0",
|
||||
"prettier": "^3.0.3",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build && just emit-types"
|
||||
}
|
||||
}
|
||||
|
||||
156
pool.test.ts
156
pool.test.ts
@@ -1,140 +1,116 @@
|
||||
import 'websocket-polyfill'
|
||||
import { test, expect, afterAll } from 'bun:test'
|
||||
|
||||
import {finishEvent, type Event} from './event.ts'
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import {SimplePool} from './pool.ts'
|
||||
import { finalizeEvent, type Event } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
let relays = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.bg/',
|
||||
'wss://nostr.fmt.wiz.biz/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/'
|
||||
]
|
||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([
|
||||
...relays,
|
||||
'wss://nostr.wine',
|
||||
'wss://offchain.pub',
|
||||
'wss://eden.nostr.land'
|
||||
])
|
||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
test('removing duplicates when subscribing', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub = pool.sub(relays, [{authors: [pub]}])
|
||||
let received: Event[] = []
|
||||
|
||||
sub.on('event', event => {
|
||||
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 = finishEvent({
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: []
|
||||
}, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event)
|
||||
})
|
||||
|
||||
test('same with double querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
test('same with double subs', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub1 = pool.sub(relays, [{authors: [pub]}])
|
||||
let sub2 = pool.sub(relays, [{authors: [pub]}])
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
sub1.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
sub2.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent({
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: []
|
||||
}, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
test('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 }],
|
||||
{
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
onclose: resolve as any,
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await pool.list(
|
||||
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||
[
|
||||
{
|
||||
authors: [
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
],
|
||||
test('querySync()', async () => {
|
||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2
|
||||
}
|
||||
]
|
||||
)
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toEqual(
|
||||
events
|
||||
.map(evt => evt.id)
|
||||
// @ts-ignore ???
|
||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
|
||||
.length
|
||||
)
|
||||
|
||||
let relaysForAllEvents = events
|
||||
.map(event => pool.seenOn(event.id))
|
||||
.reduce((acc, n) => acc.concat(n), [])
|
||||
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
|
||||
expect(events.length).toBeGreaterThan(2)
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
expect(events).toHaveLength(uniqueEventCount)
|
||||
})
|
||||
|
||||
test('seenOnEnabled: false', async () => {
|
||||
const poolWithoutSeenOn = new SimplePool({seenOnEnabled: false})
|
||||
|
||||
const event = await poolWithoutSeenOn.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
|
||||
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
|
||||
|
||||
expect(relaysForEvent).toHaveLength(0)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
||||
})
|
||||
|
||||
212
pool.ts
212
pool.ts
@@ -1,208 +1,10 @@
|
||||
import {
|
||||
relayInit,
|
||||
type Pub,
|
||||
type Relay,
|
||||
type Sub,
|
||||
type SubscriptionOptions,
|
||||
} from './relay.ts'
|
||||
import {normalizeURL} from './utils.ts'
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
import type {Filter} from './filter.ts'
|
||||
export class SimplePool {
|
||||
private _conn: {[url: string]: Relay}
|
||||
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
|
||||
|
||||
private eoseSubTimeout: number
|
||||
private getTimeout: number
|
||||
private seenOnEnabled: boolean = true
|
||||
|
||||
constructor(options: {eoseSubTimeout?: number; getTimeout?: number; seenOnEnabled?: boolean} = {}) {
|
||||
this._conn = {}
|
||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||
this.getTimeout = options.getTimeout || 3400
|
||||
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||
}
|
||||
|
||||
close(relays: string[]): void {
|
||||
relays.forEach(url => {
|
||||
let relay = this._conn[normalizeURL(url)]
|
||||
if (relay) relay.close()
|
||||
})
|
||||
}
|
||||
|
||||
async ensureRelay(url: string): Promise<Relay> {
|
||||
const nm = normalizeURL(url)
|
||||
|
||||
if (!this._conn[nm]) {
|
||||
this._conn[nm] = relayInit(nm, {
|
||||
getTimeout: this.getTimeout * 0.9,
|
||||
listTimeout: this.getTimeout * 0.9
|
||||
})
|
||||
}
|
||||
|
||||
const relay = this._conn[nm]
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
||||
let _knownIds: Set<string> = new Set()
|
||||
let modifiedOpts = {...(opts || {})}
|
||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||
return true
|
||||
}
|
||||
if (this.seenOnEnabled) {
|
||||
let set = this._seenOn[id] || new Set()
|
||||
set.add(url)
|
||||
this._seenOn[id] = set
|
||||
}
|
||||
return _knownIds.has(id)
|
||||
}
|
||||
|
||||
let subs: Sub[] = []
|
||||
let eventListeners: Set<any> = new Set()
|
||||
let eoseListeners: Set<() => void> = new Set()
|
||||
let eosesMissing = relays.length
|
||||
|
||||
let eoseSent = false
|
||||
let eoseTimeout = setTimeout(() => {
|
||||
eoseSent = true
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
}, this.eoseSubTimeout)
|
||||
|
||||
relays.forEach(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
} catch (err) {
|
||||
handleEose()
|
||||
return
|
||||
}
|
||||
if (!r) return
|
||||
let s = r.sub(filters, modifiedOpts)
|
||||
s.on('event', (event) => {
|
||||
_knownIds.add(event.id as string)
|
||||
for (let cb of eventListeners.values()) cb(event)
|
||||
})
|
||||
s.on('eose', () => {
|
||||
if (eoseSent) return
|
||||
handleEose()
|
||||
})
|
||||
subs.push(s)
|
||||
|
||||
function handleEose() {
|
||||
eosesMissing--
|
||||
if (eosesMissing === 0) {
|
||||
clearTimeout(eoseTimeout)
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let greaterSub: Sub = {
|
||||
sub(filters, opts) {
|
||||
subs.forEach(sub => sub.sub(filters, opts))
|
||||
return greaterSub
|
||||
},
|
||||
unsub() {
|
||||
subs.forEach(sub => sub.unsub())
|
||||
},
|
||||
on(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.add(cb)
|
||||
} else if (type === 'eose') {
|
||||
eoseListeners.add(cb as () => void | Promise<void>)
|
||||
}
|
||||
},
|
||||
off(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.delete(cb)
|
||||
} else if (type === 'eose')
|
||||
eoseListeners.delete(cb as () => void | Promise<void>)
|
||||
}
|
||||
}
|
||||
|
||||
return greaterSub
|
||||
}
|
||||
|
||||
get<K extends number = number>(
|
||||
relays: string[],
|
||||
filter: Filter<K>,
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event<K> | null> {
|
||||
return new Promise(resolve => {
|
||||
let sub = this.sub(relays, [filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
sub.unsub()
|
||||
resolve(null)
|
||||
}, this.getTimeout)
|
||||
sub.on('event', (event) => {
|
||||
resolve(event)
|
||||
clearTimeout(timeout)
|
||||
sub.unsub()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
list<K extends number = number>(
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
let events: Event<K>[] = []
|
||||
let sub = this.sub(relays, filters, opts)
|
||||
|
||||
sub.on('event', (event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
// we can rely on an eose being emitted here because pool.sub() will fake one
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
resolve(events)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event<number>): Pub {
|
||||
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
return r.publish(event)
|
||||
} catch (_) {
|
||||
return {on() {}, off() {}}
|
||||
}
|
||||
})
|
||||
|
||||
const callbackMap = new Map()
|
||||
|
||||
return {
|
||||
on(type, cb) {
|
||||
relays.forEach(async (relay, i) => {
|
||||
let pub = await pubPromises[i]
|
||||
let callback = () => cb(relay)
|
||||
callbackMap.set(cb, callback)
|
||||
pub.on(type, callback)
|
||||
})
|
||||
},
|
||||
|
||||
off(type, cb) {
|
||||
relays.forEach(async (_, i) => {
|
||||
let callback = callbackMap.get(cb)
|
||||
if (callback) {
|
||||
let pub = await pubPromises[i]
|
||||
pub.off(type, callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seenOn(id: string): string[] {
|
||||
return Array.from(this._seenOn[id]?.values?.() || [])
|
||||
export class SimplePool extends AbstractSimplePool {
|
||||
constructor() {
|
||||
super({ verifyEvent })
|
||||
}
|
||||
}
|
||||
|
||||
export * from './abstract-pool.ts'
|
||||
|
||||
59
pure.ts
Normal file
59
pure.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
class JS implements Nostr {
|
||||
generateSecretKey(): Uint8Array {
|
||||
return schnorr.utils.randomPrivateKey()
|
||||
}
|
||||
getPublicKey(secretKey: Uint8Array): string {
|
||||
return bytesToHex(schnorr.getPublicKey(secretKey))
|
||||
}
|
||||
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||
const event = t as VerifiedEvent
|
||||
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
||||
event.id = getEventHash(event)
|
||||
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
|
||||
event[verifiedSymbol] = true
|
||||
return event
|
||||
}
|
||||
verifyEvent(event: Event): event is VerifiedEvent {
|
||||
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||
|
||||
const hash = getEventHash(event)
|
||||
if (hash !== event.id) {
|
||||
event[verifiedSymbol] = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = schnorr.verify(event.sig, hash, event.pubkey)
|
||||
event[verifiedSymbol] = valid
|
||||
return valid
|
||||
} catch (err) {
|
||||
event[verifiedSymbol] = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent): string {
|
||||
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const i = new JS()
|
||||
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
export const verifyEvent = i.verifyEvent
|
||||
export * from './core.ts'
|
||||
@@ -1,25 +1,13 @@
|
||||
import {parseReferences} from './references.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { parseReferences } from './references.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
test('parse mentions', () => {
|
||||
let evt = buildEvent({
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
'wss://nostr.com'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
|
||||
'wss://other.com',
|
||||
'reply'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
''
|
||||
]
|
||||
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
|
||||
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
|
||||
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
|
||||
],
|
||||
content:
|
||||
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||
@@ -29,33 +17,31 @@ test('parse mentions', () => {
|
||||
{
|
||||
text: '#[0]',
|
||||
profile: {
|
||||
pubkey:
|
||||
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
relays: ['wss://nostr.com']
|
||||
}
|
||||
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
relays: ['wss://nostr.com'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '#[2]',
|
||||
event: {
|
||||
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
relays: []
|
||||
}
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||
profile: {
|
||||
pubkey:
|
||||
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: []
|
||||
}
|
||||
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||
event: {
|
||||
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
author: undefined
|
||||
}
|
||||
}
|
||||
author: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
decode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
type EventPointer,
|
||||
} from './nip19.ts'
|
||||
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
type Reference = {
|
||||
text: string
|
||||
@@ -14,8 +9,7 @@ type Reference = {
|
||||
address?: AddressPointer
|
||||
}
|
||||
|
||||
const mentionRegex =
|
||||
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
|
||||
export function parseReferences(evt: Event): Reference[] {
|
||||
let references: Reference[] = []
|
||||
@@ -23,40 +17,40 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
if (ref[2]) {
|
||||
// it's a NIP-27 mention
|
||||
try {
|
||||
let {type, data} = decode(ref[1])
|
||||
let { type, data } = decode(ref[1])
|
||||
switch (type) {
|
||||
case 'npub': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: {pubkey: data as string, relays: []}
|
||||
profile: { pubkey: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nprofile': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: data as ProfilePointer
|
||||
profile: data as ProfilePointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'note': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: {id: data as string, relays: []}
|
||||
event: { id: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nevent': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: data as EventPointer
|
||||
event: data as EventPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'naddr': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: data as AddressPointer
|
||||
address: data as AddressPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -74,14 +68,14 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
case 'p': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []}
|
||||
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []}
|
||||
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -94,8 +88,8 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
identifier,
|
||||
pubkey,
|
||||
kind: parseInt(kind, 10),
|
||||
relays: tag[2] ? [tag[2]] : []
|
||||
}
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
/***/
|
||||
|
||||
211
relay.test.ts
211
relay.test.ts
@@ -1,132 +1,121 @@
|
||||
import 'websocket-polyfill'
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import {finishEvent} from './event.ts'
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import {relayInit} from './relay.ts'
|
||||
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
let relay = relayInit('wss://relay.damus.io/')
|
||||
let relay = new Relay('wss://relay.nostr.bg')
|
||||
|
||||
beforeAll(() => {
|
||||
relay.connect()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity', () => {
|
||||
return expect(
|
||||
new Promise(resolve => {
|
||||
relay.on('connect', () => {
|
||||
resolve(true)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
test('connectivity', async () => {
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
})
|
||||
|
||||
test('connectivity, with Relay.connect()', async () => {
|
||||
const relay = await Relay.connect('wss://public.relaying.io')
|
||||
expect(relay.connected).toBeTrue()
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
await relay.connect()
|
||||
|
||||
let sub = relay.sub([
|
||||
let resolveEvent: () => void
|
||||
let resolveEose: () => void
|
||||
|
||||
const evented = new Promise<void>(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const eosed = new Promise<void>(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
}
|
||||
])
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let [t1, t2] = await Promise.all([
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve2 = resolve
|
||||
})
|
||||
])
|
||||
|
||||
expect(t1).toEqual(true)
|
||||
expect(t2).toEqual(true)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await relay.get({
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await relay.list([
|
||||
{
|
||||
authors: [
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
||||
kinds: [0],
|
||||
},
|
||||
],
|
||||
kinds: [1],
|
||||
limit: 2
|
||||
}
|
||||
])
|
||||
|
||||
expect(events.length).toEqual(2)
|
||||
})
|
||||
|
||||
test('listening (twice) and publishing', async () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [27572],
|
||||
authors: [pk]
|
||||
}
|
||||
])
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
||||
expect(event).toHaveProperty('kind', 0)
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve1(true)
|
||||
await eosed
|
||||
await evented
|
||||
}, 10000)
|
||||
|
||||
test('listening and publishing and closing', async () => {
|
||||
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
|
||||
})
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve2(true)
|
||||
const evented = new Promise(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const closed = new Promise(resolve => {
|
||||
resolveClose = resolve
|
||||
})
|
||||
|
||||
let event = finishEvent({
|
||||
kind: 27572,
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [23571],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
eventReceived = event
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
},
|
||||
onclose() {
|
||||
resolveClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await eosed
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
kind: 23571,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite'
|
||||
}, sk)
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
return expect(
|
||||
Promise.all([
|
||||
new Promise(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolve2 = resolve
|
||||
})
|
||||
])
|
||||
).resolves.toEqual([true, true])
|
||||
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')
|
||||
})
|
||||
|
||||
414
relay.ts
414
relay.ts
@@ -1,401 +1,23 @@
|
||||
/* global WebSocket */
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
|
||||
import {verifySignature, validateEvent, type Event} from './event.ts'
|
||||
import {matchFilters, type Filter} from './filter.ts'
|
||||
import {getHex64, getSubscriptionId} from './fakejson.ts'
|
||||
import { MessageQueue } from './utils.ts'
|
||||
|
||||
type RelayEvent = {
|
||||
connect: () => void | Promise<void>
|
||||
disconnect: () => void | Promise<void>
|
||||
error: () => void | Promise<void>
|
||||
notice: (msg: string) => void | Promise<void>
|
||||
auth: (challenge: string) => void | Promise<void>
|
||||
}
|
||||
export type CountPayload = {
|
||||
count: number
|
||||
}
|
||||
type SubEvent<K extends number> = {
|
||||
event: (event: Event<K>) => void | Promise<void>
|
||||
count: (payload: CountPayload) => void | Promise<void>
|
||||
eose: () => void | Promise<void>
|
||||
}
|
||||
export type Relay = {
|
||||
url: string
|
||||
status: number
|
||||
connect: () => Promise<void>
|
||||
close: () => void
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
|
||||
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
|
||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
||||
count: (
|
||||
filters: Filter[],
|
||||
opts?: SubscriptionOptions
|
||||
) => Promise<CountPayload | null>
|
||||
publish: (event: Event<number>) => Pub
|
||||
auth: (event: Event<number>) => Pub
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
}
|
||||
export type Pub = {
|
||||
on: (type: 'ok' | 'failed', cb: any) => void
|
||||
off: (type: 'ok' | 'failed', cb: any) => void
|
||||
}
|
||||
export type Sub<K extends number = number> = {
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
||||
unsub: () => void
|
||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
/**
|
||||
* @deprecated use Relay.connect() instead.
|
||||
*/
|
||||
export function relayConnect(url: string): Promise<Relay> {
|
||||
return Relay.connect(url)
|
||||
}
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
id?: string
|
||||
verb?: 'REQ' | 'COUNT'
|
||||
skipVerification?: boolean
|
||||
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
|
||||
export class Relay extends AbstractRelay {
|
||||
constructor(url: string) {
|
||||
super(url, { verifyEvent })
|
||||
}
|
||||
|
||||
static async connect(url: string) {
|
||||
const relay = new Relay(url)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
}
|
||||
|
||||
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: [],
|
||||
auth: []
|
||||
})
|
||||
|
||||
export function relayInit(
|
||||
url: string,
|
||||
options: {
|
||||
getTimeout?: number
|
||||
listTimeout?: number
|
||||
countTimeout?: number
|
||||
} = {}
|
||||
): Relay {
|
||||
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
|
||||
|
||||
var ws: WebSocket
|
||||
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
||||
var listeners = newListeners()
|
||||
var subListeners: {
|
||||
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]}
|
||||
} = {}
|
||||
var pubListeners: {
|
||||
[eventid: string]: {
|
||||
ok: Array<() => void>
|
||||
seen: Array<() => void>
|
||||
failed: Array<(reason: string) => void>
|
||||
}
|
||||
} = {}
|
||||
|
||||
var connectionPromise: Promise<void> | undefined
|
||||
async function connectRelay(): Promise<void> {
|
||||
if (connectionPromise) return connectionPromise
|
||||
connectionPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
listeners.connect.forEach(cb => cb())
|
||||
resolve()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
connectionPromise = undefined
|
||||
listeners.error.forEach(cb => cb())
|
||||
reject()
|
||||
}
|
||||
ws.onclose = async () => {
|
||||
connectionPromise = undefined
|
||||
listeners.disconnect.forEach(cb => cb())
|
||||
}
|
||||
|
||||
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||
let handleNextInterval: any
|
||||
|
||||
ws.onmessage = e => {
|
||||
incomingMessageQueue.enqueue(e.data)
|
||||
if (!handleNextInterval) {
|
||||
handleNextInterval = setInterval(handleNext, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (incomingMessageQueue.size === 0) {
|
||||
clearInterval(handleNextInterval)
|
||||
handleNextInterval = null
|
||||
return
|
||||
}
|
||||
|
||||
var json = incomingMessageQueue.dequeue()
|
||||
if (!json) return
|
||||
|
||||
let subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
let so = openSubs[subid]
|
||||
if (
|
||||
so &&
|
||||
so.alreadyHaveEvent &&
|
||||
so.alreadyHaveEvent(getHex64(json, 'id'), url)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json)
|
||||
|
||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
let id = data[1]
|
||||
let event = data[2]
|
||||
if (
|
||||
validateEvent(event) &&
|
||||
openSubs[id] &&
|
||||
(openSubs[id].skipVerification || verifySignature(event)) &&
|
||||
matchFilters(openSubs[id].filters, event)
|
||||
) {
|
||||
openSubs[id]
|
||||
;(subListeners[id]?.event || []).forEach(cb => cb(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT':
|
||||
let id = data[1]
|
||||
let payload = data[2]
|
||||
if (openSubs[id]) {
|
||||
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
|
||||
}
|
||||
return
|
||||
case 'EOSE': {
|
||||
let id = data[1]
|
||||
if (id in subListeners) {
|
||||
subListeners[id].eose.forEach(cb => cb())
|
||||
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'OK': {
|
||||
let id: string = data[1]
|
||||
let ok: boolean = data[2]
|
||||
let reason: string = data[3] || ''
|
||||
if (id in pubListeners) {
|
||||
if (ok) pubListeners[id].ok.forEach(cb => cb())
|
||||
else pubListeners[id].failed.forEach(cb => cb(reason))
|
||||
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
|
||||
pubListeners[id].failed = []
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
let notice = data[1]
|
||||
listeners.notice.forEach(cb => cb(notice))
|
||||
return
|
||||
case 'AUTH': {
|
||||
let challenge = data[1]
|
||||
listeners.auth?.forEach(cb => cb(challenge))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return connectionPromise
|
||||
}
|
||||
|
||||
function connected() {
|
||||
return ws?.readyState === 1
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (connected()) return // ws already open
|
||||
await connectRelay()
|
||||
}
|
||||
|
||||
async function trySend(params: [string, ...any]) {
|
||||
let msg = JSON.stringify(params)
|
||||
if (!connected()) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
if (!connected()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(msg)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const sub = <K extends number = number>(
|
||||
filters: Filter<K>[],
|
||||
{
|
||||
verb = 'REQ',
|
||||
skipVerification = false,
|
||||
alreadyHaveEvent = null,
|
||||
id = Math.random().toString().slice(2)
|
||||
}: SubscriptionOptions = {}
|
||||
): Sub<K> => {
|
||||
let subid = id
|
||||
|
||||
openSubs[subid] = {
|
||||
id: subid,
|
||||
filters,
|
||||
skipVerification,
|
||||
alreadyHaveEvent
|
||||
}
|
||||
trySend([verb, subid, ...filters])
|
||||
|
||||
return {
|
||||
sub: (newFilters, newOpts = {}) =>
|
||||
sub(newFilters || filters, {
|
||||
skipVerification: newOpts.skipVerification || skipVerification,
|
||||
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
|
||||
id: subid
|
||||
}),
|
||||
unsub: () => {
|
||||
delete openSubs[subid]
|
||||
delete subListeners[subid]
|
||||
trySend(['CLOSE', subid])
|
||||
},
|
||||
on: (type, cb) => {
|
||||
subListeners[subid] = subListeners[subid] || {
|
||||
event: [],
|
||||
count: [],
|
||||
eose: []
|
||||
}
|
||||
subListeners[subid][type].push(cb)
|
||||
},
|
||||
off: (type, cb): void => {
|
||||
let listeners = subListeners[subid]
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _publishEvent(event: Event<number>, type: string) {
|
||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
||||
let id = event.id
|
||||
|
||||
trySend([type, event])
|
||||
|
||||
return {
|
||||
on: (type: 'ok' | 'failed', cb: any) => {
|
||||
pubListeners[id] = pubListeners[id] || {
|
||||
ok: [],
|
||||
failed: []
|
||||
}
|
||||
pubListeners[id][type].push(cb)
|
||||
},
|
||||
off: (type: 'ok' | 'failed', cb: any) => {
|
||||
let listeners = pubListeners[id]
|
||||
if (!listeners) return
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
listeners[type].push(cb)
|
||||
if (type === 'connect' && ws?.readyState === 1) {
|
||||
// i would love to know why we need this
|
||||
;(cb as () => void)()
|
||||
}
|
||||
},
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
let index = listeners[type].indexOf(cb)
|
||||
if (index !== -1) listeners[type].splice(index, 1)
|
||||
},
|
||||
list: (filters, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, opts)
|
||||
let events: Event<any>[] = []
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(events)
|
||||
}, listTimeout)
|
||||
s.on('eose', () => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(events)
|
||||
})
|
||||
s.on('event', (event) => {
|
||||
events.push(event)
|
||||
})
|
||||
}),
|
||||
get: (filter, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub([filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, getTimeout)
|
||||
s.on('event', (event) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
count: (filters: Filter[]): Promise<CountPayload | null> =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, {...sub, verb: 'COUNT'})
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, countTimeout)
|
||||
s.on('count', (event: CountPayload) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
publish(event): Pub {
|
||||
return _publishEvent(event, 'EVENT')
|
||||
},
|
||||
auth(event): Pub {
|
||||
return _publishEvent(event, 'AUTH')
|
||||
},
|
||||
connect,
|
||||
close(): void {
|
||||
listeners = newListeners()
|
||||
subListeners = {}
|
||||
pubListeners = {}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws?.close()
|
||||
}
|
||||
},
|
||||
get status() {
|
||||
return ws?.readyState ?? 3
|
||||
}
|
||||
}
|
||||
}
|
||||
export * from './abstract-relay.ts'
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
type EventParams<K extends number> = Partial<Event<K>>
|
||||
import type { Event } from './pure.ts'
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1 as K,
|
||||
kind: 1,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
tags: [],
|
||||
sig: '',
|
||||
...params
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "lib",
|
||||
"allowImportingTsExtensions": true,
|
||||
"outDir": "lib/types",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
|
||||
238
utils.test.ts
238
utils.test.ts
@@ -1,101 +1,110 @@
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import {
|
||||
MessageQueue,
|
||||
insertEventIntoAscendingList,
|
||||
insertEventIntoDescendingList,
|
||||
} from './utils.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(
|
||||
insertEventIntoDescendingList(list0, buildEvent({id: 'abc', created_at: 10}))
|
||||
).toHaveLength(1)
|
||||
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({created_at: 20}), buildEvent({created_at: 10})]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
}))
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 30}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 10}),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
}))
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 30}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 10}),
|
||||
buildEvent({created_at: 1}),
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 1 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 15
|
||||
}))
|
||||
created_at: 15,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 10}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 5
|
||||
}))
|
||||
created_at: 5,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0: Event[] = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 10}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
}))
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 10, id: 'abc'}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
}))
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -103,119 +112,129 @@ describe('inserting into a desc sorted list of events', () => {
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(
|
||||
insertEventIntoAscendingList(list0, buildEvent({id: 'abc', created_at: 10}))
|
||||
).toHaveLength(1)
|
||||
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({created_at: 10}), buildEvent({created_at: 20})]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 1
|
||||
}))
|
||||
created_at: 1,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 10}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 30}),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
}))
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 10}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 30}),
|
||||
buildEvent({created_at: 40}),
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 25
|
||||
}))
|
||||
created_at: 25,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 40}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 50
|
||||
}))
|
||||
created_at: 50,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 30}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
}))
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 20}),
|
||||
buildEvent({created_at: 30, id: 'abc'}),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, buildEvent({
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
}))
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enque a message into MessageQueue', () => {
|
||||
test('enque into an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
describe('enqueue a message into MessageQueue', () => {
|
||||
test('enqueue into an empty queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
})
|
||||
test('enque into a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
test('enqueue into a non-empty queue', () => {
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
expect(queue.last!.value).toBe('node2')
|
||||
expect(queue.size).toBe(3)
|
||||
})
|
||||
test('dequeue from an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
const queue = new Queue()
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe(null)
|
||||
expect(queue.size).toBe(0)
|
||||
})
|
||||
test('dequeue from a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
@@ -225,15 +244,22 @@ describe('enque a message into MessageQueue', () => {
|
||||
expect(item2).toBe('node3')
|
||||
})
|
||||
test('dequeue more than in queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
const queue = new Queue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
expect(queue.size).toBe(0)
|
||||
const item3 = queue.dequeue()
|
||||
expect(item3).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
test('binary search', () => {
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
|
||||
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
|
||||
})
|
||||
|
||||
227
utils.ts
227
utils.ts
@@ -1,4 +1,4 @@
|
||||
import type {Event} from './event.ts'
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
@@ -7,181 +7,110 @@ export function normalizeURL(url: string): string {
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if (
|
||||
(p.port === '80' && p.protocol === 'ws:') ||
|
||||
(p.port === '443' && p.protocol === 'wss:')
|
||||
)
|
||||
p.port = ''
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
//
|
||||
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
||||
//
|
||||
export function insertEventIntoDescendingList(
|
||||
sortedArray: Event<number>[],
|
||||
event: Event<number>
|
||||
) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at < sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at >= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return b.created_at - event.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [
|
||||
...sortedArray.slice(0, position),
|
||||
event,
|
||||
...sortedArray.slice(position)
|
||||
]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(
|
||||
sortedArray: Event<number>[],
|
||||
event: Event<number>
|
||||
) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at > sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at <= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return event.created_at - b.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [
|
||||
...sortedArray.slice(0, position),
|
||||
event,
|
||||
...sortedArray.slice(position)
|
||||
]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export class MessageNode {
|
||||
private _value: string
|
||||
private _next: MessageNode | null
|
||||
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
|
||||
let start = 0
|
||||
let end = arr.length - 1
|
||||
|
||||
public get value(): string {
|
||||
return this._value
|
||||
}
|
||||
public set value(message: string) {
|
||||
this._value = message
|
||||
}
|
||||
public get next(): MessageNode | null {
|
||||
return this._next
|
||||
}
|
||||
public set next(node: MessageNode | null) {
|
||||
this._next = node
|
||||
while (start <= end) {
|
||||
const mid = Math.floor((start + end) / 2)
|
||||
const cmp = compare(arr[mid])
|
||||
|
||||
if (cmp === 0) {
|
||||
return [mid, true]
|
||||
}
|
||||
|
||||
constructor(message: string) {
|
||||
this._value = message
|
||||
this._next = null
|
||||
if (cmp < 0) {
|
||||
end = mid - 1
|
||||
} else {
|
||||
start = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
return [start, false]
|
||||
}
|
||||
|
||||
export class QueueNode<V> {
|
||||
public value: V
|
||||
public next: QueueNode<V> | null = null
|
||||
public prev: QueueNode<V> | null = null
|
||||
|
||||
constructor(message: V) {
|
||||
this.value = message
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageQueue {
|
||||
private _first: MessageNode | null
|
||||
private _last: MessageNode | null
|
||||
|
||||
public get first(): MessageNode | null {
|
||||
return this._first
|
||||
}
|
||||
public set first(messageNode: MessageNode | null) {
|
||||
this._first = messageNode
|
||||
}
|
||||
public get last(): MessageNode | null {
|
||||
return this._last
|
||||
}
|
||||
public set last(messageNode: MessageNode | null) {
|
||||
this._last = messageNode
|
||||
}
|
||||
private _size: number
|
||||
public get size(): number {
|
||||
return this._size
|
||||
}
|
||||
public set size(v: number) {
|
||||
this._size = v
|
||||
}
|
||||
export class Queue<V> {
|
||||
public first: QueueNode<V> | null
|
||||
public last: QueueNode<V> | null
|
||||
|
||||
constructor() {
|
||||
this._first = null
|
||||
this._last = null
|
||||
this._size = 0
|
||||
this.first = null
|
||||
this.last = null
|
||||
}
|
||||
enqueue(message: string): boolean {
|
||||
const newNode = new MessageNode(message)
|
||||
if (this._size === 0 || !this._last) {
|
||||
this._first = newNode
|
||||
this._last = newNode
|
||||
|
||||
enqueue(value: V): boolean {
|
||||
const newNode = new QueueNode(value)
|
||||
if (!this.last) {
|
||||
// list is empty
|
||||
this.first = newNode
|
||||
this.last = newNode
|
||||
} else if (this.last === this.first) {
|
||||
// list has a single element
|
||||
this.last = newNode
|
||||
this.last.prev = this.first
|
||||
this.first.next = newNode
|
||||
} else {
|
||||
this._last.next = newNode
|
||||
this._last = newNode
|
||||
// list has elements, add as last
|
||||
newNode.prev = this.last
|
||||
this.last.next = newNode
|
||||
this.last = newNode
|
||||
}
|
||||
this._size++
|
||||
return true
|
||||
}
|
||||
dequeue(): string | null {
|
||||
if (this._size === 0 || !this._first) return null
|
||||
|
||||
let prev = this._first
|
||||
this._first = prev.next
|
||||
prev.next = null
|
||||
dequeue(): V | null {
|
||||
if (!this.first) return null
|
||||
|
||||
this._size--
|
||||
return prev.value
|
||||
if (this.first === this.last) {
|
||||
const target = this.first
|
||||
this.first = null
|
||||
this.last = null
|
||||
return target.value
|
||||
}
|
||||
|
||||
const target = this.first
|
||||
this.first = target.next
|
||||
|
||||
return target.value
|
||||
}
|
||||
}
|
||||
|
||||
38
wasm.ts
Normal file
38
wasm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||
|
||||
let nw: NostrWasm
|
||||
|
||||
export function setNostrWasm(x: NostrWasm) {
|
||||
nw = x
|
||||
}
|
||||
|
||||
class Wasm implements Nostr {
|
||||
generateSecretKey(): Uint8Array {
|
||||
return nw.generateSecretKey()
|
||||
}
|
||||
getPublicKey(secretKey: Uint8Array): string {
|
||||
return bytesToHex(nw.getPublicKey(secretKey))
|
||||
}
|
||||
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||
nw.finalizeEvent(t as any, secretKey)
|
||||
return t as VerifiedEvent
|
||||
}
|
||||
verifyEvent(event: Event): event is VerifiedEvent {
|
||||
try {
|
||||
nw.verifyEvent(event)
|
||||
event[verifiedSymbol] = true
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const i = new Wasm()
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
export const verifyEvent = i.verifyEvent
|
||||
export * from './core.ts'
|
||||
Reference in New Issue
Block a user