mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
168 Commits
v1.17.0
...
alexgleaso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b582a0206 | ||
|
|
8ed2c13c28 | ||
|
|
27a536f41d | ||
|
|
fbc82d0b73 | ||
|
|
9c0ade1329 | ||
|
|
63ccc8b4c8 | ||
|
|
7cf7df88db | ||
|
|
bded539122 | ||
|
|
3647bbd68a | ||
|
|
fb085ffdf7 | ||
|
|
280d483ef4 | ||
|
|
54b55b98f1 | ||
|
|
84f9881812 | ||
|
|
db6baf2e6b | ||
|
|
bb1e6f4356 | ||
|
|
5626d3048b | ||
|
|
058d0276e2 | ||
|
|
37b046c047 | ||
|
|
846654b449 | ||
|
|
b676dc0987 | ||
|
|
b1ce901555 | ||
|
|
62e5730965 | ||
|
|
01f13292bb | ||
|
|
7b0458db72 | ||
|
|
3aab7121f7 | ||
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 | ||
|
|
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 |
@@ -45,7 +45,6 @@
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
@@ -139,13 +138,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
|
||||
18
.github/workflows/publish.yml
vendored
Normal file
18
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # The OIDC ID token is used for authentication with JSR.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun i
|
||||
- run: npx jsr publish
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -10,19 +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: 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 lint
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ package-lock.json
|
||||
.envrc
|
||||
lib
|
||||
test.html
|
||||
bench.js
|
||||
|
||||
235
README.md
235
README.md
@@ -1,4 +1,4 @@
|
||||
# nostr-tools
|
||||
#  nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
@@ -19,76 +19,66 @@ If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
|
||||
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/pure'
|
||||
|
||||
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, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
|
||||
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 => {
|
||||
console.log('we got the event we wanted:', event)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generatePrivateKey()
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
let sub = relay.sub([
|
||||
relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
console.log('got event:', event)
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
})
|
||||
|
||||
let event = {
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
@@ -96,71 +86,59 @@ let event = {
|
||||
}
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finishEvent(event, sk)
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
|
||||
let events = await relay.list([{ kinds: [0, 1] }])
|
||||
let event = await relay.get({
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
relay.close()
|
||||
```
|
||||
|
||||
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
||||
To use this on Node.js you first must install `ws` and call something like this:
|
||||
|
||||
```js
|
||||
import 'websocket-polyfill'
|
||||
import { useWebSocketImplementation } from 'nostr-tools/relay'
|
||||
useWebSocketImplementation(require('ws'))
|
||||
```
|
||||
|
||||
### Interacting with multiple relays
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
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'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
},
|
||||
oneose() {
|
||||
h.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
sub.on('event', event => {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
})
|
||||
await Promise.any(pool.publish(relays, newEvent))
|
||||
console.log('published to at least one relay!')
|
||||
|
||||
let pubs = pool.publish(relays, newEvent)
|
||||
await Promise.all(pubs)
|
||||
|
||||
let events = await pool.list(relays, [{ kinds: [0, 1] }])
|
||||
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
let batchedEvents = await pool.batchedList('notes', relays, [{ kinds: [1] }])
|
||||
// `batchedList` will wait for other function calls with the same `batchKey`
|
||||
// (e.g. 'notes', 'authors', etc) within a fixed amount of time (default: `100ms`) before sending
|
||||
// next ws request, and batch all requests with similar `batchKey`s together in a single request.
|
||||
|
||||
let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
// relaysForEvent will be an array of URLs from relays a given event was seen on
|
||||
|
||||
pool.close()
|
||||
```
|
||||
|
||||
read more details about `batchedList` on this pr: [https://github.com/nbd-wtf/nostr-tools/pull/279](https://github.com/nbd-wtf/nostr-tools/pull/279#issue-1859315757)
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
|
||||
```js
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
import { parseReferences } from 'nostr-tools/references'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
@@ -180,9 +158,9 @@ for (let i = 0; i < references.length; i++) {
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import { nip05 } from 'nostr-tools'
|
||||
import { queryProfile } from 'nostr-tools/nip05'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
let profile = await queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||
console.log(profile.relays)
|
||||
@@ -192,27 +170,29 @@ console.log(profile.relays)
|
||||
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||
|
||||
```js
|
||||
nip05.useFetchImplementation(require('node-fetch'))
|
||||
import { useFetchImplementation } from 'nostr-tools/nip05'
|
||||
useFetchImplementation(require('node-fetch'))
|
||||
```
|
||||
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import { nip19, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import * as nip19 from 'nostr-tools/nip19'
|
||||
|
||||
let sk = generatePrivateKey()
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
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)
|
||||
assert(type === 'npub')
|
||||
assert(data === pk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
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)
|
||||
@@ -221,94 +201,73 @@ assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
### Encrypting and decrypting direct messages
|
||||
### 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 {nip44, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
||||
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
|
||||
// sender
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||
initNostrWasm().then(setNostrWasm)
|
||||
|
||||
// receiver
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
// on the sender side
|
||||
let message = 'hello'
|
||||
let key = nip44.getSharedSecret(sk1, pk2)
|
||||
let ciphertext = nip44.encrypt(key, message)
|
||||
|
||||
let event = {
|
||||
kind: 4,
|
||||
pubkey: pk1,
|
||||
tags: [['p', pk2]],
|
||||
content: ciphertext,
|
||||
...otherProperties,
|
||||
}
|
||||
|
||||
sendEvent(event)
|
||||
|
||||
// on the receiver side
|
||||
sub.on('event', async event => {
|
||||
let sender = event.pubkey
|
||||
// pk1 === sender
|
||||
let _key = nip44.getSharedSecret(sk2, pk1)
|
||||
let plaintext = nip44.decrypt(_key, event.content)
|
||||
})
|
||||
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||
```
|
||||
|
||||
### Performing and checking for delegation
|
||||
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 { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
|
||||
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'
|
||||
|
||||
// delegator
|
||||
let sk1 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
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
|
||||
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||
const pool = new AbstractSimplePool({ verifyEvent })
|
||||
```
|
||||
|
||||
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
|
||||
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
|
||||
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
## Contributing to this repository
|
||||
|
||||
Use NIP-34 to send your patches to:
|
||||
|
||||
```
|
||||
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
|
||||
```
|
||||
|
||||
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: Map<string, Set<AbstractRelay>> = new Map()
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public verifyEvent: Nostr['verifyEvent']
|
||||
public trustedRelayURLs: Set<string> = new Set()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
369
abstract-relay.ts
Normal file
369
abstract-relay.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/* 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'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
try {
|
||||
_WebSocket = WebSocket
|
||||
} catch {}
|
||||
|
||||
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
_WebSocket = websocketImplementation
|
||||
}
|
||||
|
||||
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: Map<string, Subscription> = new Map()
|
||||
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'] }): Promise<AbstractRelay> {
|
||||
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>): Promise<string> {
|
||||
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()
|
||||
36
build.js
36
build.js
@@ -1,15 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const fs = require('node:fs')
|
||||
const esbuild = require('esbuild')
|
||||
const { join } = require('path');
|
||||
const { join } = require('path')
|
||||
|
||||
const entryPoints = fs.readdirSync(process.cwd())
|
||||
const entryPoints = fs
|
||||
.readdirSync(process.cwd())
|
||||
.filter(
|
||||
(file) =>
|
||||
file.endsWith(".ts") && !file.endsWith("test.ts") &&
|
||||
fs.statSync(join(process.cwd(), file)).isFile()
|
||||
);
|
||||
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,
|
||||
@@ -24,12 +28,7 @@ esbuild
|
||||
format: 'esm',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({ type: 'module' })
|
||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('esm build success.')
|
||||
})
|
||||
.then(() => console.log('esm build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
@@ -38,7 +37,12 @@ esbuild
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('cjs build success.')
|
||||
})
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
|
||||
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
|
||||
}
|
||||
360
event.test.ts
360
event.test.ts
@@ -1,360 +0,0 @@
|
||||
import {
|
||||
getBlankEvent,
|
||||
finishEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
getSignature,
|
||||
Kind,
|
||||
verifiedSymbol,
|
||||
} from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
|
||||
describe('Event', () => {
|
||||
describe('getBlankEvent', () => {
|
||||
it('should return a blank event object', () => {
|
||||
expect(getBlankEvent()).toEqual({
|
||||
kind: 255,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a blank event object with defined kind', () => {
|
||||
expect(getBlankEvent(Kind.Text)).toEqual({
|
||||
kind: 1,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishEvent', () => {
|
||||
it('should create a signed event from a template', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
it('should serialize a valid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for an invalid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
it('should return the correct event hash', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid properties', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid tags', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true for a valid event signature', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false for an invalid event signature', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifySignature({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false for an invalid event id', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSignature', () => {
|
||||
it('should produce the correct signature for an event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, privateKey)
|
||||
|
||||
// verify the signature
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
id: getEventHash(unsignedEvent),
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
it('should not sign an event with different private key', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
expect(sig.length).toEqual(128)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
144
event.ts
144
event.ts
@@ -1,144 +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'
|
||||
|
||||
/** Designates a verified event signature. */
|
||||
export const verifiedSymbol = Symbol('verified')
|
||||
|
||||
/** @deprecated Use numbers instead. */
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum Kind {
|
||||
Metadata = 0,
|
||||
Text = 1,
|
||||
RecommendRelay = 2,
|
||||
Contacts = 3,
|
||||
EncryptedDirectMessage = 4,
|
||||
EventDeletion = 5,
|
||||
Repost = 6,
|
||||
Reaction = 7,
|
||||
BadgeAward = 8,
|
||||
ChannelCreation = 40,
|
||||
ChannelMetadata = 41,
|
||||
ChannelMessage = 42,
|
||||
ChannelHideMessage = 43,
|
||||
ChannelMuteUser = 44,
|
||||
Blank = 255,
|
||||
Report = 1984,
|
||||
ZapRequest = 9734,
|
||||
Zap = 9735,
|
||||
RelayList = 10002,
|
||||
ClientAuth = 22242,
|
||||
NwcRequest = 23194,
|
||||
HttpAuth = 27235,
|
||||
ProfileBadge = 30008,
|
||||
BadgeDefinition = 30009,
|
||||
Article = 30023,
|
||||
FileMetadata = 1063,
|
||||
}
|
||||
|
||||
export interface Event<K extends number = number> {
|
||||
kind: K
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
id: string
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type EventTemplate<K extends number = number> = Pick<Event<K>, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||
export type UnsignedEvent<K extends number = number> = Pick<
|
||||
Event<K>,
|
||||
'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'
|
||||
>
|
||||
|
||||
/** An event whose signature has been verified. */
|
||||
export interface VerifiedEvent<K extends number = number> extends Event<K> {
|
||||
[verifiedSymbol]: true
|
||||
}
|
||||
|
||||
export function getBlankEvent(): EventTemplate<Kind.Blank>
|
||||
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
|
||||
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
|
||||
return {
|
||||
kind,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
|
||||
const event = t as VerifiedEvent<K>
|
||||
event.pubkey = getPublicKey(privateKey)
|
||||
event.id = getEventHash(event)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
event[verifiedSymbol] = true
|
||||
return event
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||
|
||||
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent<number>): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
if (typeof event.pubkey !== 'string') return false
|
||||
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||
|
||||
if (!Array.isArray(event.tags)) return false
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Verify the event's signature. This function mutates the event with a `verified` symbol, making it idempotent. */
|
||||
export function verifySignature<K extends number>(event: Event<K>): event is VerifiedEvent<K> {
|
||||
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||
|
||||
const hash = getEventHash(event)
|
||||
if (hash !== event.id) {
|
||||
return (event[verifiedSymbol] = false)
|
||||
}
|
||||
|
||||
try {
|
||||
return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
|
||||
} catch (err) {
|
||||
return (event[verifiedSymbol] = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `getSignature` instead. */
|
||||
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||
console.warn(
|
||||
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
|
||||
)
|
||||
return getSignature(event, key)
|
||||
}
|
||||
|
||||
/** Calculate the signature for an event. */
|
||||
export function getSignature(event: UnsignedEvent<number>, key: string): string {
|
||||
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||
|
||||
test('match id', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
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],
|
||||
@@ -26,7 +27,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
test('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({ id: '789' })
|
||||
@@ -36,7 +37,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the event id starts with a prefix', () => {
|
||||
test('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({ id: '001' })
|
||||
@@ -46,7 +47,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
test('should return false when the event kind is not in the filter', () => {
|
||||
const filter = { kinds: [1, 2, 3] }
|
||||
|
||||
const event = buildEvent({ kind: 4 })
|
||||
@@ -56,7 +57,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
test('should return false when the event author is not in the filter', () => {
|
||||
const filter = { authors: ['abc', 'def'] }
|
||||
|
||||
const event = buildEvent({ pubkey: 'ghi' })
|
||||
@@ -66,7 +67,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
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']] })
|
||||
@@ -76,7 +77,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
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']] })
|
||||
@@ -86,7 +87,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
test('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = { '#tag1': ['foo'] }
|
||||
|
||||
const event = buildEvent({
|
||||
@@ -105,7 +106,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
test('should return false when the event is before the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 50 })
|
||||
@@ -115,7 +116,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
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 })
|
||||
@@ -125,7 +126,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
test('should return false when the event is after the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 150 })
|
||||
@@ -135,7 +136,7 @@ describe('Filter', () => {
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
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 })
|
||||
@@ -147,7 +148,7 @@ 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'] },
|
||||
@@ -161,7 +162,7 @@ describe('Filter', () => {
|
||||
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'] },
|
||||
@@ -175,7 +176,7 @@ describe('Filter', () => {
|
||||
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 },
|
||||
@@ -194,7 +195,7 @@ 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'] },
|
||||
@@ -208,7 +209,7 @@ describe('Filter', () => {
|
||||
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 },
|
||||
@@ -228,7 +229,7 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('mergeFilters', () => {
|
||||
it('should merge filters', () => {
|
||||
test('should merge filters', () => {
|
||||
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||
ids: ['a', 'b', 'c'],
|
||||
limit: 3,
|
||||
@@ -240,4 +241,27 @@ describe('Filter', () => {
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilterLimit', () => {
|
||||
test('should handle ids', () => {
|
||||
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||
})
|
||||
|
||||
test('should count the authors times replaceable kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||
})
|
||||
|
||||
test('should return Infinity for authors with regular kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('should return Infinity for empty filters', () => {
|
||||
expect(getFilterLimit({})).toEqual(Infinity)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
30
filter.ts
30
filter.ts
@@ -1,8 +1,9 @@
|
||||
import { Event } from './event.ts'
|
||||
import { Event } from './core.ts'
|
||||
import { isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter<K extends number = number> = {
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: K[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
@@ -11,7 +12,7 @@ export type Filter<K extends number = number> = {
|
||||
[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
|
||||
@@ -38,15 +39,15 @@ export function matchFilter(filter: Filter<number>, event: Event<number>): boole
|
||||
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]) => {
|
||||
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
||||
export function getFilterLimit(filter: Filter): number {
|
||||
if (filter.ids && !filter.ids.length) return 0
|
||||
if (filter.kinds && !filter.kinds.length) return 0
|
||||
if (filter.authors && !filter.authors.length) return 0
|
||||
|
||||
return Math.min(
|
||||
Math.max(0, filter.limit ?? Infinity),
|
||||
filter.ids?.length ?? Infinity,
|
||||
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||
? filter.authors.length * filter.kinds.length
|
||||
: Infinity,
|
||||
)
|
||||
}
|
||||
|
||||
21
helpers.ts
Normal file
21
helpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||
|
||||
export async function yieldThread() {
|
||||
return new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel()
|
||||
const handler = () => {
|
||||
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||
ch.port1.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||
ch.port1.addEventListener('message', handler)
|
||||
ch.port2.postMessage(0)
|
||||
ch.port1.start()
|
||||
})
|
||||
}
|
||||
|
||||
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
|
||||
t[verifiedSymbol] = true
|
||||
return true
|
||||
}
|
||||
8
index.ts
8
index.ts
@@ -1,22 +1,21 @@
|
||||
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'
|
||||
@@ -24,5 +23,6 @@ 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',
|
||||
}
|
||||
5
jsr.json
Normal file
5
jsr.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@nostr/tools",
|
||||
"version": "2.3.1",
|
||||
"exports": "./index.ts"
|
||||
}
|
||||
34
justfile
34
justfile
@@ -1,28 +1,30 @@
|
||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
|
||||
install-dependencies:
|
||||
yarn --ignore-engines
|
||||
|
||||
build:
|
||||
rm -rf lib
|
||||
node build.js
|
||||
rm -rf lib
|
||||
bun run build.js
|
||||
tsc
|
||||
|
||||
test:
|
||||
jest
|
||||
bun test --timeout 20000
|
||||
|
||||
test-only file:
|
||||
jest {{file}}
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
|
||||
publish: build emit-types
|
||||
npm publish
|
||||
publish: build
|
||||
npm publish
|
||||
|
||||
format:
|
||||
eslint --ext .ts --fix *.ts
|
||||
prettier --write *.ts
|
||||
eslint --ext .ts --fix *.ts
|
||||
prettier --write *.ts
|
||||
|
||||
lint:
|
||||
eslint --ext .ts *.ts
|
||||
prettier --check *.ts
|
||||
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))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { classifyKind } from './kinds.ts'
|
||||
|
||||
test('kind classification', () => {
|
||||
|
||||
96
kinds.ts
96
kinds.ts
@@ -1,28 +1,28 @@
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
function isRegularKind(kind: number) {
|
||||
export function isRegularKind(kind: number): boolean {
|
||||
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||
}
|
||||
|
||||
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||
function isReplaceableKind(kind: number) {
|
||||
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind)
|
||||
export function isReplaceableKind(kind: number): boolean {
|
||||
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
function isEphemeralKind(kind: number) {
|
||||
export function isEphemeralKind(kind: number): boolean {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
function isParameterizedReplaceableKind(kind: number) {
|
||||
export function isParameterizedReplaceableKind(kind: number): boolean {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** Classification of the event kind. */
|
||||
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||
function classifyKind(kind: number): KindClassification {
|
||||
export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
@@ -30,11 +30,77 @@ function classifyKind(kind: number): KindClassification {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export {
|
||||
classifyKind,
|
||||
isEphemeralKind,
|
||||
isParameterizedReplaceableKind,
|
||||
isRegularKind,
|
||||
isReplaceableKind,
|
||||
type KindClassification,
|
||||
}
|
||||
export const Metadata = 0
|
||||
export const ShortTextNote = 1
|
||||
export const RecommendRelay = 2
|
||||
export const Contacts = 3
|
||||
export const EncryptedDirectMessage = 4
|
||||
export const EncryptedDirectMessages = 4
|
||||
export const EventDeletion = 5
|
||||
export const Repost = 6
|
||||
export const Reaction = 7
|
||||
export const BadgeAward = 8
|
||||
export const GenericRepost = 16
|
||||
export const ChannelCreation = 40
|
||||
export const ChannelMetadata = 41
|
||||
export const ChannelMessage = 42
|
||||
export const ChannelHideMessage = 43
|
||||
export const ChannelMuteUser = 44
|
||||
export const OpenTimestamps = 1040
|
||||
export const FileMetadata = 1063
|
||||
export const LiveChatMessage = 1311
|
||||
export const ProblemTracker = 1971
|
||||
export const Report = 1984
|
||||
export const Reporting = 1984
|
||||
export const Label = 1985
|
||||
export const CommunityPostApproval = 4550
|
||||
export const JobRequest = 5999
|
||||
export const JobResult = 6999
|
||||
export const JobFeedback = 7000
|
||||
export const ZapGoal = 9041
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const Highlights = 9802
|
||||
export const Mutelist = 10000
|
||||
export const Pinlist = 10001
|
||||
export const RelayList = 10002
|
||||
export const BookmarkList = 10003
|
||||
export const CommunitiesList = 10004
|
||||
export const PublicChatsList = 10005
|
||||
export const BlockedRelaysList = 10006
|
||||
export const SearchRelaysList = 10007
|
||||
export const InterestsList = 10015
|
||||
export const UserEmojiList = 10030
|
||||
export const FileServerPreference = 10096
|
||||
export const NWCWalletInfo = 13194
|
||||
export const LightningPubRPC = 21000
|
||||
export const ClientAuth = 22242
|
||||
export const NWCWalletRequest = 23194
|
||||
export const NWCWalletResponse = 23195
|
||||
export const NostrConnect = 24133
|
||||
export const HTTPAuth = 27235
|
||||
export const Followsets = 30000
|
||||
export const Genericlists = 30001
|
||||
export const Relaysets = 30002
|
||||
export const Bookmarksets = 30003
|
||||
export const Curationsets = 30004
|
||||
export const ProfileBadges = 30008
|
||||
export const BadgeDefinition = 30009
|
||||
export const Interestsets = 30015
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
export const LongFormArticle = 30023
|
||||
export const DraftLong = 30024
|
||||
export const Emojisets = 30030
|
||||
export const Application = 30078
|
||||
export const LiveEvent = 30311
|
||||
export const UserStatuses = 30315
|
||||
export const ClassifiedListing = 30402
|
||||
export const DraftClassifiedListing = 30403
|
||||
export const Date = 31922
|
||||
export const Time = 31923
|
||||
export const Calendar = 31924
|
||||
export const CalendarEventRSVP = 31925
|
||||
export const Handlerrecommendation = 31989
|
||||
export const Handlerinformation = 31990
|
||||
export const CommunityDefinition = 34550
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { test, expect } from 'bun:test'
|
||||
|
||||
import { encrypt, decrypt } from './nip04.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
28
nip04.ts
28
nip04.ts
@@ -1,42 +1,38 @@
|
||||
import { randomBytes } from '@noble/hashes/utils'
|
||||
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { cbc } from '@noble/ciphers/aes'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
// @ts-ignore
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(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 ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
|
||||
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(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 ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
return utf8Decoder.decode(plaintext)
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
@@ -15,12 +16,5 @@ test('fetch nip05 profiles', async () => {
|
||||
|
||||
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!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||
})
|
||||
|
||||
52
nip05.ts
52
nip05.ts
@@ -7,7 +7,7 @@ import { ProfilePointer } from './nip19.ts'
|
||||
* - 1: name (optional)
|
||||
* - 2: domain
|
||||
*/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -21,9 +21,10 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
|
||||
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||
try {
|
||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||
|
||||
return res.names
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||
const res = await _fetch(url, { redirect: 'error' })
|
||||
const json = await res.json()
|
||||
return json.names
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
@@ -36,46 +37,17 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const { names, relays } = parseNIP05Result(await res.json())
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
let pubkey = res.names[name]
|
||||
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** nostr.json result. */
|
||||
export interface NIP05Result {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
relays?: {
|
||||
[pubkey: string]: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the nostr.json and throw if it's not valid. */
|
||||
function parseNIP05Result(json: any): NIP05Result {
|
||||
const result: NIP05Result = {
|
||||
names: {},
|
||||
}
|
||||
|
||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||
result.names[name] = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if (json.relays) {
|
||||
result.relays = {}
|
||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
export async function isValid(pubkey: string, nip05: string): Promise<boolean> {
|
||||
let res = await queryProfile(nip05)
|
||||
return res ? res.pubkey === pubkey : false
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
@@ -6,9 +7,22 @@ test('generate private key from a mnemonic', async () => {
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||
})
|
||||
|
||||
4
nip06.ts
4
nip06.ts
@@ -3,9 +3,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { parse } from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
@@ -171,10 +172,6 @@ describe('parse NIP10-referenced events', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.todo('recommended + a lot of events')
|
||||
test.todo('recommended + 3 events')
|
||||
test.todo('recommended + 2 events')
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
|
||||
2
nip10.ts
2
nip10.ts
@@ -1,4 +1,4 @@
|
||||
import type { Event } from './event.ts'
|
||||
import type { Event } from './core.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
|
||||
17
nip11.test.ts
Normal file
17
nip11.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
|
||||
// TODO: replace with a mock
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
test('testing a relay', async () => {
|
||||
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||
expect(info.name).toEqual('nostr.land')
|
||||
expect(info.description).toContain('nostr.land family')
|
||||
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): void {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function fetchRelayInformation(url: string): Promise<RelayInformation> {
|
||||
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,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { getPow, minePow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
|
||||
6
nip13.ts
6
nip13.ts
@@ -1,4 +1,4 @@
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './event.ts'
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(hex: string): number {
|
||||
@@ -23,10 +23,10 @@ export function getPow(hex: string): number {
|
||||
*
|
||||
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
|
||||
*/
|
||||
export function minePow<K extends number>(unsigned: UnsignedEvent<K>, difficulty: number): Omit<Event<K>, 'sig'> {
|
||||
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||
let count = 0
|
||||
|
||||
const event = unsigned as Omit<Event<K>, 'sig'>
|
||||
const event = unsigned as Omit<Event, 'sig'>
|
||||
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||
|
||||
event.tags.push(tag)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.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'],
|
||||
@@ -23,14 +24,14 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
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,
|
||||
}
|
||||
|
||||
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],
|
||||
@@ -52,7 +53,7 @@ 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,
|
||||
@@ -61,7 +62,7 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
|
||||
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],
|
||||
@@ -81,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])
|
||||
})
|
||||
})
|
||||
|
||||
28
nip18.ts
28
nip18.ts
@@ -1,4 +1,5 @@
|
||||
import { Event, finishEvent, Kind, verifySignature } from './event.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
@@ -20,13 +21,13 @@ export type RepostEventTemplate = {
|
||||
|
||||
export function finishRepostEvent(
|
||||
t: RepostEventTemplate,
|
||||
reposted: Event<number>,
|
||||
reposted: Event,
|
||||
relayUrl: string,
|
||||
privateKey: string,
|
||||
): Event<Kind.Repost> {
|
||||
return finishEvent(
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.Repost,
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
@@ -35,8 +36,8 @@ export function finishRepostEvent(
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -69,20 +70,17 @@ export type GetRepostedEventOptions = {
|
||||
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
|
||||
}
|
||||
@@ -91,7 +89,7 @@ export function getRepostedEvent(
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!skipVerification && !verifySignature(repostedEvent)) {
|
||||
if (!skipVerification && !verifyEvent(repostedEvent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} 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)
|
||||
@@ -22,7 +23,7 @@ test('encode and decode nsec', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -31,7 +32,7 @@ test('encode and decode npub', () => {
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
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+/)
|
||||
@@ -55,7 +56,7 @@ test('decode nprofile without relays', () => {
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
@@ -75,15 +76,15 @@ test('encode and decode naddr', () => {
|
||||
})
|
||||
|
||||
test('encode and decode nevent', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
@@ -92,15 +93,15 @@ test('encode and decode nevent', () => {
|
||||
})
|
||||
|
||||
test('encode and decode nevent with kind 0', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 0,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
@@ -108,6 +109,25 @@ test('encode and decode nevent with kind 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',
|
||||
|
||||
40
nip19.ts
40
nip19.ts
@@ -3,7 +3,7 @@ import { bech32 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
export const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
@@ -48,7 +48,7 @@ type Prefixes = {
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: string
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
}
|
||||
@@ -130,6 +130,8 @@ export function decode(nip19: string): DecodeResult {
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
return { type: prefix, data }
|
||||
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return { type: prefix, data: bytesToHex(data) }
|
||||
@@ -147,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}`)
|
||||
@@ -157,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}` {
|
||||
@@ -174,9 +175,8 @@ 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)
|
||||
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
return encodeBech32(prefix, bytes)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
@@ -189,7 +189,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
let kindArray
|
||||
if (event.kind != undefined) {
|
||||
if (event.kind !== undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
}
|
||||
|
||||
@@ -226,15 +226,17 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
Object.entries(tlv).forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
Object.entries(tlv)
|
||||
.reverse()
|
||||
.forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { test as testRegex, parse } from './nip21.ts'
|
||||
|
||||
test('test()', () => {
|
||||
|
||||
2
nip21.ts
2
nip21.ts
@@ -1,7 +1,7 @@
|
||||
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})`)
|
||||
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.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'],
|
||||
@@ -20,14 +21,14 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
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,
|
||||
}
|
||||
|
||||
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'],
|
||||
@@ -46,7 +47,7 @@ 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: '👍',
|
||||
@@ -55,7 +56,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
|
||||
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'],
|
||||
|
||||
17
nip25.ts
17
nip25.ts
@@ -1,4 +1,5 @@
|
||||
import { Event, finishEvent, Kind } from './event.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { Reaction } from './kinds.ts'
|
||||
|
||||
import type { EventPointer } from './nip19.ts'
|
||||
|
||||
@@ -16,17 +17,13 @@ export type ReactionEventTemplate = {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishReactionEvent(
|
||||
t: ReactionEventTemplate,
|
||||
reacted: Event<number>,
|
||||
privateKey: string,
|
||||
): Event<Kind.Reaction> {
|
||||
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,
|
||||
kind: Reaction,
|
||||
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||
content: t.content ?? '+',
|
||||
},
|
||||
@@ -34,8 +31,8 @@ export function finishReactionEvent(
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
101
nip26.test.ts
101
nip26.test.ts
@@ -1,101 +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)
|
||||
})
|
||||
71
nip26.ts
71
nip26.ts
@@ -1,71 +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,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
|
||||
2
nip27.ts
2
nip27.ts
@@ -2,7 +2,7 @@ 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')
|
||||
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
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,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
ChannelMessageEventTemplate,
|
||||
} from './nip28.ts'
|
||||
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
describe('NIP-28 Functions', () => {
|
||||
@@ -20,7 +22,7 @@ describe('NIP-28 Functions', () => {
|
||||
picture: 'https://example.com/picture.jpg',
|
||||
}
|
||||
|
||||
it('channelCreateEvent should create an event with given template', () => {
|
||||
test('channelCreateEvent should create an event with given template', () => {
|
||||
const template = {
|
||||
content: channelMetadata,
|
||||
created_at: 1617932115,
|
||||
@@ -32,7 +34,7 @@ describe('NIP-28 Functions', () => {
|
||||
expect(event!.pubkey).toEqual(publicKey)
|
||||
})
|
||||
|
||||
it('channelMetadataEvent should create a signed event with given template', () => {
|
||||
test('channelMetadataEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_create_event_id: 'channel creation event id',
|
||||
content: channelMetadata,
|
||||
@@ -48,8 +50,8 @@ describe('NIP-28 Functions', () => {
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
it('channelMessageEvent should create a signed message event with given template', () => {
|
||||
const template = {
|
||||
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!',
|
||||
@@ -65,7 +67,7 @@ describe('NIP-28 Functions', () => {
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
|
||||
it('channelMessageEvent should create a signed message reply event with given template', () => {
|
||||
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',
|
||||
@@ -76,15 +78,25 @@ describe('NIP-28 Functions', () => {
|
||||
|
||||
const event = channelMessageEvent(template, privateKey)
|
||||
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||
expect(event.tags).toContainEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
|
||||
expect(event.tags).toContainEqual(['e', template.reply_to_channel_message_event_id, template.relay_url, 'reply'])
|
||||
expect(event.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')
|
||||
})
|
||||
|
||||
it('channelHideMessageEvent should create a signed event with given template', () => {
|
||||
test('channelHideMessageEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
channel_message_event_id: 'channel message event id',
|
||||
content: { reason: 'Inappropriate content' },
|
||||
@@ -100,7 +112,7 @@ describe('NIP-28 Functions', () => {
|
||||
expect(typeof event!.sig).toEqual('string')
|
||||
})
|
||||
|
||||
it('channelMuteUserEvent should create a signed event with given template', () => {
|
||||
test('channelMuteUserEvent should create a signed event with given template', () => {
|
||||
const template = {
|
||||
content: { reason: 'Spamming' },
|
||||
created_at: 1617932115,
|
||||
|
||||
44
nip28.ts
44
nip28.ts
@@ -1,4 +1,5 @@
|
||||
import { Event, finishEvent, Kind } from './event.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||
|
||||
export interface ChannelMetadata {
|
||||
name: string
|
||||
@@ -44,10 +45,7 @@ export interface ChannelMuteUserEventTemplate {
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export const channelCreateEvent = (
|
||||
t: ChannelCreateEventTemplate,
|
||||
privateKey: string,
|
||||
): Event<Kind.ChannelCreation> | undefined => {
|
||||
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
@@ -57,9 +55,9 @@ export const channelCreateEvent = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finishEvent(
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.ChannelCreation,
|
||||
kind: ChannelCreation,
|
||||
tags: [...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
@@ -68,10 +66,7 @@ export const channelCreateEvent = (
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMetadataEvent = (
|
||||
t: ChannelMetadataEventTemplate,
|
||||
privateKey: string,
|
||||
): Event<Kind.ChannelMetadata> | undefined => {
|
||||
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
@@ -81,9 +76,9 @@ export const channelMetadataEvent = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finishEvent(
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.ChannelMetadata,
|
||||
kind: ChannelMetadata,
|
||||
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
@@ -92,16 +87,16 @@ export const channelMetadataEvent = (
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: string): Event<Kind.ChannelMessage> => {
|
||||
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 finishEvent(
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.ChannelMessage,
|
||||
kind: ChannelMessage,
|
||||
tags: [...tags, ...(t.tags ?? [])],
|
||||
content: t.content,
|
||||
created_at: t.created_at,
|
||||
@@ -113,8 +108,8 @@ export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey:
|
||||
/* "e" tag should be the kind 42 event to hide */
|
||||
export const channelHideMessageEvent = (
|
||||
t: ChannelHideMessageEventTemplate,
|
||||
privateKey: string,
|
||||
): Event<Kind.ChannelHideMessage> | undefined => {
|
||||
privateKey: Uint8Array,
|
||||
): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
@@ -124,9 +119,9 @@ export const channelHideMessageEvent = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finishEvent(
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.ChannelHideMessage,
|
||||
kind: ChannelHideMessage,
|
||||
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
@@ -135,10 +130,7 @@ export const channelHideMessageEvent = (
|
||||
)
|
||||
}
|
||||
|
||||
export const channelMuteUserEvent = (
|
||||
t: ChannelMuteUserEventTemplate,
|
||||
privateKey: string,
|
||||
): Event<Kind.ChannelMuteUser> | undefined => {
|
||||
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||
let content: string
|
||||
if (typeof t.content === 'object') {
|
||||
content = JSON.stringify(t.content)
|
||||
@@ -148,9 +140,9 @@ export const channelMuteUserEvent = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finishEvent(
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Kind.ChannelMuteUser,
|
||||
kind: ChannelMuteUser,
|
||||
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
|
||||
166
nip29.ts
Normal file
166
nip29.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { AbstractSimplePool } from './abstract-pool'
|
||||
import { Subscription } from './abstract-relay'
|
||||
import { decode } from './nip19'
|
||||
import type { Event } from './core'
|
||||
import { fetchRelayInformation } from './nip11'
|
||||
import { normalizeURL } from './utils'
|
||||
import { AddressPointer } from './nip19'
|
||||
|
||||
export function subscribeRelayGroups(
|
||||
pool: AbstractSimplePool,
|
||||
url: string,
|
||||
params: {
|
||||
ongroups: (_: Group[]) => void
|
||||
onerror: (_: Error) => void
|
||||
onconnect?: () => void
|
||||
},
|
||||
): () => void {
|
||||
let normalized = normalizeURL(url)
|
||||
let sub: Subscription
|
||||
let groups: Group[] = []
|
||||
|
||||
fetchRelayInformation(normalized)
|
||||
.then(async info => {
|
||||
let rl = await pool.ensureRelay(normalized)
|
||||
params.onconnect?.()
|
||||
sub = rl.prepareSubscription(
|
||||
[
|
||||
{
|
||||
kinds: [39000],
|
||||
limit: 50,
|
||||
authors: [info.pubkey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event: Event) {
|
||||
groups.push(parseGroup(event, normalized))
|
||||
},
|
||||
oneose() {
|
||||
params.ongroups(groups)
|
||||
sub.onevent = (event: Event) => {
|
||||
groups.push(parseGroup(event, normalized))
|
||||
params.ongroups(groups)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
sub.fire()
|
||||
})
|
||||
.catch(params.onerror)
|
||||
|
||||
return () => sub.close()
|
||||
}
|
||||
|
||||
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
|
||||
let normalized = normalizeURL(gr.host)
|
||||
|
||||
let info = await fetchRelayInformation(normalized)
|
||||
let event = await pool.get([normalized], {
|
||||
kinds: [39000],
|
||||
authors: [info.pubkey],
|
||||
'#d': [gr.id],
|
||||
})
|
||||
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
|
||||
return parseGroup(event, normalized)
|
||||
}
|
||||
|
||||
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||
let gr = parseGroupCode(code)
|
||||
if (!gr) throw new Error(`code "${code}" does not identify a group`)
|
||||
return loadGroup(pool, gr)
|
||||
}
|
||||
|
||||
export type GroupReference = {
|
||||
id: string
|
||||
host: string
|
||||
}
|
||||
|
||||
export function parseGroupCode(code: string): null | GroupReference {
|
||||
if (code.startsWith('naddr1')) {
|
||||
try {
|
||||
let { data } = decode(code)
|
||||
|
||||
let { relays, identifier } = data as AddressPointer
|
||||
if (!relays || relays.length === 0) return null
|
||||
|
||||
let host = relays![0]
|
||||
if (host.startsWith('wss://')) {
|
||||
host = host.slice(6)
|
||||
}
|
||||
return { host, id: identifier }
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
} else if (code.split("'").length === 2) {
|
||||
let spl = code.split("'")
|
||||
return { host: spl[0], id: spl[1] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function encodeGroupReference(gr: GroupReference): string {
|
||||
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
|
||||
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
|
||||
return `${gr.host}'${gr.id}`
|
||||
}
|
||||
|
||||
export type Group = {
|
||||
id: string
|
||||
relay: string
|
||||
pubkey: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
public?: boolean
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
export function parseGroup(event: Event, relay: string): Group {
|
||||
const group: Partial<Group> = { relay, pubkey: event.pubkey }
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
switch (tag[0]) {
|
||||
case 'd':
|
||||
group.id = tag[1] || ''
|
||||
break
|
||||
case 'name':
|
||||
group.name = tag[1] || ''
|
||||
break
|
||||
case 'about':
|
||||
group.about = tag[1] || ''
|
||||
break
|
||||
case 'picture':
|
||||
group.picture = tag[1] || ''
|
||||
break
|
||||
case 'open':
|
||||
group.open = true
|
||||
break
|
||||
case 'public':
|
||||
group.public = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return group as Group
|
||||
}
|
||||
|
||||
export type Member = {
|
||||
pubkey: string
|
||||
label?: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export function parseMembers(event: Event): Member[] {
|
||||
const members = []
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length < 2) continue
|
||||
if (tag[0] !== 'p') continue
|
||||
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
|
||||
const member: Member = { pubkey: tag[1], permissions: [] }
|
||||
if (tag.length > 2) member.label = tag[2]
|
||||
if (tag.length > 3) member.permissions = tag.slice(3)
|
||||
members.push(member)
|
||||
}
|
||||
return members
|
||||
}
|
||||
33
nip30.test.ts
Normal file
33
nip30.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip30.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
name: 'blobcat',
|
||||
shortcode: ':blobcat:',
|
||||
start: 6,
|
||||
end: 15,
|
||||
},
|
||||
{
|
||||
name: 'disputed',
|
||||
shortcode: ':disputed:',
|
||||
start: 16,
|
||||
end: 26,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
|
||||
|
||||
const result = replaceAll(content, ({ name }) => {
|
||||
return `<img src="https://ditto.pub/emoji/${name}.png" />`
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
|
||||
)
|
||||
})
|
||||
51
nip30.ts
Normal file
51
nip30.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Regex for a single emoji shortcode. */
|
||||
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
|
||||
|
||||
/** Regex to find emoji shortcodes in content. */
|
||||
export const regex = (): RegExp => 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,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||
|
||||
44
nip40.test.ts
Normal file
44
nip40.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, test, expect, jest } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
|
||||
|
||||
describe('getExpiration', () => {
|
||||
test('returns the expiration as a Date object', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = getExpiration(event)
|
||||
expect(result).toEqual(new Date(123000))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEventExpired', () => {
|
||||
test('returns true when the event has expired', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('returns false when the event has not expired', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 10
|
||||
const event = buildEvent({ tags: [['expiration', future.toString()]] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForExpire', () => {
|
||||
test('returns a promise that resolves when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = await waitForExpire(event)
|
||||
expect(result).toEqual(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExpire', () => {
|
||||
test('calls the callback when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const callback = jest.fn()
|
||||
onExpire(event, callback)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
49
nip40.ts
Normal file
49
nip40.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Event } from './core.ts'
|
||||
|
||||
/** Get the expiration of the event as a `Date` object, if any. */
|
||||
function getExpiration(event: Event): Date | undefined {
|
||||
const tag = event.tags.find(([name]) => name === 'expiration')
|
||||
if (tag) {
|
||||
return new Date(parseInt(tag[1]) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if the event has expired. */
|
||||
function isEventExpired(event: Event): boolean {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
return Date.now() > expiration.getTime()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that resolves when the event expires. */
|
||||
async function waitForExpire(event: Event): Promise<Event> {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
const diff = expiration.getTime() - Date.now()
|
||||
if (diff > 0) {
|
||||
await sleep(diff)
|
||||
return event
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
} else {
|
||||
throw new Error('Event has no expiration')
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the callback when the event expires. */
|
||||
function onExpire(event: Event, callback: (event: Event) => void): void {
|
||||
waitForExpire(event)
|
||||
.then(callback)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resolves when the given number of milliseconds have elapsed. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export { getExpiration, isEventExpired, waitForExpire, onExpire }
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'websocket-polyfill'
|
||||
import { expect, test } 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'
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
test('auth flow', () => {
|
||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
||||
relay.connect()
|
||||
const sk = generatePrivateKey()
|
||||
test('auth flow', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
29
nip42.ts
29
nip42.ts
@@ -1,32 +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],
|
||||
['relay', relayURL],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
return relay.auth(await sign(e))
|
||||
}
|
||||
|
||||
@@ -1,75 +1,44 @@
|
||||
import { encrypt, decrypt, utils } from './nip44.ts'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { v2 } from './nip44.js'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { v2 as vectors } from './nip44.vectors.json'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
test('NIP44: valid_sec', async () => {
|
||||
for (const v of vectors.valid_sec) {
|
||||
const pub2 = getPublicKey(v.sec2)
|
||||
const key = utils.v2.getConversationKey(v.sec1, pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.shared)
|
||||
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
|
||||
expect(ciphertext).toEqual(v.ciphertext)
|
||||
const decrypted = decrypt(key, ciphertext)
|
||||
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('NIP44: valid_pub', async () => {
|
||||
for (const v of vectors.valid_pub) {
|
||||
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.shared)
|
||||
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
|
||||
expect(ciphertext).toEqual(v.ciphertext)
|
||||
const decrypted = decrypt(key, ciphertext)
|
||||
expect(decrypted).toEqual(v.plaintext)
|
||||
}
|
||||
})
|
||||
|
||||
test('NIP44: invalid', async () => {
|
||||
for (const v of vectors.invalid) {
|
||||
expect(() => {
|
||||
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
|
||||
const ciphertext = decrypt(key, v.ciphertext)
|
||||
}).toThrowError(v.note)
|
||||
}
|
||||
})
|
||||
|
||||
test('NIP44: invalid_conversation_key', async () => {
|
||||
for (const v of vectors.invalid_conversation_key) {
|
||||
expect(() => {
|
||||
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
|
||||
const ciphertext = encrypt(key, 'a')
|
||||
}).toThrowError()
|
||||
}
|
||||
})
|
||||
|
||||
test('NIP44: v1 calcPadding', () => {
|
||||
for (const [len, shouldBePaddedTo] of vectors.padding) {
|
||||
const actual = utils.v2.calcPadding(len)
|
||||
test('calc_padded_len', () => {
|
||||
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||
const actual = v2.utils.calcPaddedLen(len)
|
||||
expect(actual).toEqual(shouldBePaddedTo)
|
||||
}
|
||||
})
|
||||
|
||||
// To re-generate vectors and produce new ones:
|
||||
// Create regen.mjs with this content:
|
||||
// import {getPublicKey, nip44} from './lib/esm/nostr.mjs'
|
||||
// import {bytesToHex, hexToBytes} from '@noble/hashes/utils'
|
||||
// import vectors from './nip44.vectors.json' assert { type: "json" };
|
||||
// function genVectors(v) {
|
||||
// const pub2 = v.pub2 ?? getPublicKey(v.sec2);
|
||||
// let sharedKey = nip44.utils.v2.getConversationKey(v.sec1, pub2)
|
||||
// let ciphertext = nip44.encrypt(sharedKey, v.plaintext, { salt: hexToBytes(v.salt) })
|
||||
// console.log({
|
||||
// sec1: v.sec1,
|
||||
// pub2: pub2,
|
||||
// sharedKey: bytesToHex(sharedKey),
|
||||
// salt: v.salt,
|
||||
// plaintext: v.plaintext,
|
||||
// ciphertext
|
||||
// })
|
||||
// }
|
||||
// for (let v of vectors.valid_sec) genVectors(v);
|
||||
// for (let v of vectors.valid_pub) genVectors(v);
|
||||
// const padded = concatBytes(utils.v2.pad(plaintext), new Uint8Array(250))
|
||||
// const mac = randomBytes(32)
|
||||
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)/)
|
||||
}
|
||||
})
|
||||
|
||||
211
nip44.ts
211
nip44.ts
@@ -1,107 +1,132 @@
|
||||
import { chacha20 } from '@noble/ciphers/chacha'
|
||||
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||
import { equalBytes } from '@noble/ciphers/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { hkdf } from '@noble/hashes/hkdf'
|
||||
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 } from '@noble/hashes/utils'
|
||||
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
export const utils = {
|
||||
v2: {
|
||||
maxPlaintextSize: 65536 - 128, // 64kb - 128
|
||||
minCiphertextSize: 100, // should be 128 if min padded to 32b: base64(1+32+32+32)
|
||||
maxCiphertextSize: 102400, // 100kb
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB)
|
||||
return key.subarray(1, 33)
|
||||
},
|
||||
class u {
|
||||
static minPlaintextSize = 0x0001 // 1b msg => padded to 32b
|
||||
static maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
|
||||
|
||||
getMessageKeys(conversationKey: Uint8Array, salt: Uint8Array) {
|
||||
const keys = hkdf(sha256, conversationKey, salt, 'nip44-v2', 76)
|
||||
return {
|
||||
encryption: keys.subarray(0, 32),
|
||||
nonce: keys.subarray(32, 44),
|
||||
auth: keys.subarray(44, 76),
|
||||
}
|
||||
},
|
||||
static utf8Encode = utf8ToBytes
|
||||
|
||||
calcPadding(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 0) 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)
|
||||
},
|
||||
|
||||
pad(unpadded: string): Uint8Array {
|
||||
const unpaddedB = utf8Encoder.encode(unpadded)
|
||||
const len = unpaddedB.length
|
||||
if (len < 1 || len >= utils.v2.maxPlaintextSize) throw new Error('invalid plaintext length: must be between 1b and 64KB')
|
||||
const paddedLen = utils.v2.calcPadding(len)
|
||||
const zeros = new Uint8Array(paddedLen - len)
|
||||
const lenBuf = new Uint8Array(2)
|
||||
new DataView(lenBuf.buffer).setUint16(0, len)
|
||||
return concatBytes(lenBuf, unpaddedB, zeros)
|
||||
},
|
||||
|
||||
unpad(padded: Uint8Array): string {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||
if (
|
||||
unpaddedLen === 0 ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + utils.v2.calcPadding(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return utf8Decoder.decode(unpadded)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function encrypt(
|
||||
key: Uint8Array,
|
||||
plaintext: string,
|
||||
options: { salt?: Uint8Array; version?: number } = {},
|
||||
): string {
|
||||
const version = options.version ?? 2
|
||||
if (version !== 2) throw new Error('unknown encryption version ' + version)
|
||||
const salt = options.salt ?? randomBytes(32)
|
||||
ensureBytes(salt, 32)
|
||||
const keys = utils.v2.getMessageKeys(key, salt)
|
||||
const padded = utils.v2.pad(plaintext)
|
||||
const ciphertext = chacha20(keys.encryption, keys.nonce, padded)
|
||||
const mac = hmac(sha256, keys.auth, ciphertext)
|
||||
return base64.encode(concatBytes(new Uint8Array([version]), salt, ciphertext, mac))
|
||||
}
|
||||
|
||||
export function decrypt(key: Uint8Array, ciphertext: string): string {
|
||||
const u = utils.v2
|
||||
ensureBytes(key, 32)
|
||||
|
||||
const clen = ciphertext.length
|
||||
if (clen < u.minCiphertextSize || clen >= u.maxCiphertextSize) throw new Error('invalid ciphertext length: ' + clen)
|
||||
|
||||
if (ciphertext[0] === '#') throw new Error('unknown encryption version')
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = base64.decode(ciphertext)
|
||||
} catch (error) {
|
||||
throw new Error('invalid base64: ' + (error as any).message)
|
||||
static utf8Decode(bytes: Uint8Array): string {
|
||||
return decoder.decode(bytes)
|
||||
}
|
||||
const vers = data.subarray(0, 1)[0]
|
||||
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||
|
||||
const salt = data.subarray(1, 33)
|
||||
const ciphertext_ = data.subarray(33, -32)
|
||||
const mac = data.subarray(-32)
|
||||
static getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
}
|
||||
|
||||
const keys = u.getMessageKeys(key, salt)
|
||||
const calculatedMac = hmac(sha256, keys.auth, ciphertext_)
|
||||
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||
static getMessageKeys(
|
||||
conversationKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
}
|
||||
|
||||
const padded = chacha20(keys.encryption, keys.nonce, ciphertext_)
|
||||
return u.unpad(padded)
|
||||
static 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)
|
||||
}
|
||||
|
||||
static writeU16BE(num: number): Uint8Array {
|
||||
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
|
||||
}
|
||||
|
||||
static 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)
|
||||
}
|
||||
|
||||
static 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)
|
||||
}
|
||||
|
||||
static hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
}
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
// 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
|
||||
static decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class v2 {
|
||||
static utils = u
|
||||
|
||||
static encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = 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))
|
||||
}
|
||||
|
||||
static 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 default { v2 }
|
||||
|
||||
@@ -1,245 +1,605 @@
|
||||
{
|
||||
"v2": {
|
||||
"valid_sec": [
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"shared": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
|
||||
"salt": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"plaintext": "a",
|
||||
"ciphertext": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYNpT9ESckRbRUY7bUF5P+1rObpA4BNoksAUQ8myMDd9/37W/J2YHvBpRjvy9uC0+ovbpLc0WLaMFieqAMdIYqR14",
|
||||
"note": "sk1 = 1, sk2 = random, 0x02"
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"shared": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
|
||||
"salt": "f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||
"plaintext": "🍕🫃",
|
||||
"ciphertext": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPKY68BwdF7PIT205jBoaZHSs7OMpKsULW5F5ClOJWiy6XjZy7s2v85KugYmbBKgEC2LytbXbxkr7Jpgfk529K3/pP",
|
||||
"note": "sk1 = 1, sk2 = random, 0x02"
|
||||
},
|
||||
{
|
||||
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||
"shared": "94da47d851b9c1ed33b3b72f35434f56aa608d60e573e9c295f568011f4f50a4",
|
||||
"salt": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||
"ciphertext": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7yuU7XwJ8wCYUrq4aXX86HLnkMx7fPFvNeMk0uek9ma01magfEBIf+vJvZdWKiv48eUu9Cv31plAJsH6kSIsGc5TVYBYipkrQUNRxxJA15QT+uCURF96v3XuSS0k2Pf108AI=",
|
||||
"note": "unicode-heavy string"
|
||||
},
|
||||
{
|
||||
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
|
||||
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
|
||||
"shared": "ab99c122d4586cdd5c813058aa543d0e7233545dbf6874fc34a3d8d9a18fbbc3",
|
||||
"salt": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
|
||||
"plaintext": "ability🤝的 ȺȾ",
|
||||
"ciphertext": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvPSc+7YCIFTmGk5OLuh1nhl6TvID7sGKLFUCWRW1eRfV/0a7sT46N3nTQzD7IE67zLWrYqGnE+0DDNz6sJ4hAaFrT"
|
||||
},
|
||||
{
|
||||
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
|
||||
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
|
||||
"shared": "a449f2a85c6d3db0f44c64554a05d11a3c0988d645e4b4b2592072f63662f422",
|
||||
"salt": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
|
||||
"plaintext": "pepper👀їжак",
|
||||
"ciphertext": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGx1TkkMfiHJxEeSdQ/4Rlaghn0okDCNYLihBsHrDzBsNRC27APmH9mmZcpcg66Mb0exH9V5/lLBWdQW+fcY9GpvXv0"
|
||||
},
|
||||
{
|
||||
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||
"shared": "decde9938ffcb14fa7ff300105eb1bf239469af9baf376e69755b9070ae48c47",
|
||||
"salt": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||
"plaintext": "( ͡° ͜ʖ ͡°)",
|
||||
"ciphertext": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHiR8Hljs6Nl/XsNDAmCz6U1Z3NUGhbCtczc3wXXxDzFkjjMimxsf/74OEzu7LphUadM9iSWvVKPrNXY7lTD0B2muz"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
|
||||
"salt": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
|
||||
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
|
||||
"ciphertext": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItohfde4vHVRHUupr+Glh9JW4f9EY+w795hvRZbixs0EQgDZ7zwLlymVQI3NNvMqvemQzHUA1I5+9gSu8XSMwX9gDCUAjUJtntCkRt9+tjdy2Wa2ZrDYqCvgirvzbJTIC69Ve3YbKuiTQCKtVi0PA5ZLqVmnkHPIqfPqDOGj/a3dvJVzGSgeijcIpjuEgFF54uirrWvIWmTBDeTA+tlQzJHpB2wQnUndd2gLDb8+eKFUZPBifshD3WmgWxv8wRv6k3DeWuWEZQ70Z+YDpgpeOzuzHj0MDBwMAlY8Qq86Rx6pxY76PLDDfHh3rE2CHJEKl2MhDj7pGXao2o633vSRd9ueG8W"
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
|
||||
"salt": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
|
||||
"plaintext": "الكل في المجمو عة (5)",
|
||||
"ciphertext": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjTHmhdBC3meTY4A7Lv8s8B86MnmlUBJ8ebzwxFQzDyVCcdSbWFaKe0gigEBdXew7TjrjH8BCpAbtYjoa4YHa8GNjj7zH314ApVnwoByHdLHLB9Vr6VdzkxcJgA6oL4MAsRLg="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
|
||||
"salt": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
|
||||
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
|
||||
"ciphertext": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLg/25Q5uBC88jl5ghtEREXX6o4QijPzM0uwmkeQ54/6aIqUyzGNVdryWKZ0mee2lmVVWhU+26X6XGFQ5DGRn+1v0POsFUCZ/REh35+beBNHnyvjxD/rbrMfhP2Blc8X5m8Xvk="
|
||||
},
|
||||
{
|
||||
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
|
||||
"salt": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||
"ciphertext": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtU9bUC2ji2A2udRI2VCEQZ7IAmYRRgxodBtd5Yi/5htCUczf1jLHxIt9AhVAZLKuRgbWOuEMq5RBybkxPsSeAkxzXVOlWHZ1Febq5ogkjqY/6Xj8CwwmaZxfbx+d1BKKO3Wa+IFuXwuVAZa1Xo+fan+skyf+2R5QSj10QGAnGO7odAu/iZ9A28eMoSNeXsdxqy1+PRt5Zk4i019xmf7C4PDGSzgFZSvQ2EzusJN5WcsnRFmF1L5rXpX1AYo8HusOpWcGf9PjmFbO+8spUkX1W/T21GRm4o7dro1Y6ycgGOA9BsiQ=="
|
||||
}
|
||||
],
|
||||
"valid_pub": [
|
||||
{
|
||||
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"shared": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
|
||||
"salt": "a000000000000000000000000000000000000000000000000000000000000001",
|
||||
"plaintext": "⁰⁴⁵₀₁₂",
|
||||
"ciphertext": "AqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2+xmGnjIMPMqqJGmjdYAYZUDUyEEUO3/evHUaO40LePeR91VlMVZ7I+nKJPkaUiKZ3cQiQnA86Uwti2IxepmzOFN",
|
||||
"note": "sec1 = n-2, pub2: random, 0x02"
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
|
||||
"shared": "aa971537d741089885a0b48f2730a125e15b36033d089d4537a4e1204e76b39e",
|
||||
"salt": "b000000000000000000000000000000000000000000000000000000000000002",
|
||||
"plaintext": "A Peer-to-Peer Electronic Cash System",
|
||||
"ciphertext": "ArAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACyuqG6RycuPyDPtwxzTcuMQu+is3N5XuWTlvCjligVaVBRydexaylXbsX592MEd3/Jt13BNL/GlpYpGDvLS4Tt/+2s9FX/16e/RDc+czdwXglc4DdSHiq+O06BvvXYfEQOPw=",
|
||||
"note": "sec1 = 2, pub2: "
|
||||
},
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"shared": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"salt": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"plaintext": "A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution. Digital signatures provide part of the solution, but the main benefits are lost if a trusted third party is still required to prevent double-spending.",
|
||||
"ciphertext": "Anm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYb9wHyKevpe7ohJ6OkpceFcb0pySY8TLGwT7Q3zWNDKxc9blXanxKborEXkQH8xNaB2ViJfgxpkutbwbYd0Grix34xzaZBASufdsNm7R768t51tI6sdS0nms6kWLVJpEGu6Ke4Bldv4StJtWBLaTcgsgN+4WxDbBhC/nhwjEQiBBbbmUrPWjaVZXjl8dzzPrYtkSoeBNJs/UNvDwym4+qrmhv4ASTvVflpZgLlSe4seqeu6dWoRqn8uRHZQnPs+XhqwbdCHpeKGB3AfGBykZY0RIr0tjarWdXNasGbIhGM3GiLasioJeabAZw0plCevDkKpZYDaNfMJdzqFVJ8UXRIpvDpQad0SOm8lLum/aBzUpLqTjr3RvSlhYdbuODpd9pR5K60k4L2N8nrPtBv08wlilQg2ymwQgKVE6ipxIzzKMetn8+f0nQ9bHjWFJqxetSuMzzArTUQl9c4q/DwZmCBhI2",
|
||||
"note": "sec1 == pub2 == salt"
|
||||
}
|
||||
],
|
||||
"invalid": [
|
||||
{
|
||||
"sec1": "2573d1e9b9ac5de5d570f652cbb9e8d4f235e3d3d334181448e87c417f374e83",
|
||||
"pub2": "8348c2d35549098706e5bab7966d9a9c72fbf6554e918f41c2b6cb275f79ec13",
|
||||
"sharedKey": "8673ec68393a997bfad7eab8661461daf8b3931b7e885d78312a3fb7fe17f41a",
|
||||
"salt": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o b l e",
|
||||
"ciphertext": "##Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbOsrsqIEyf8ccwhlrnI/Cx03mDSmeweOLKD7dw5BDZQDxXe2FwUJ8Ag25VoJ4MGhjlPCNmCU/Uqk4k0jwbhgR3fRh",
|
||||
"note": "unknown encryption version"
|
||||
},
|
||||
{
|
||||
"sec1": "11063318c5cb3cd9cafcced42b4db5ea02ec976ed995962d2bc1fa1e9b52e29f",
|
||||
"pub2": "5c49873b6eac3dd363325250cc55d5dd4c7ce9a885134580405736d83506bb74",
|
||||
"sharedKey": "e2aad10de00913088e5cb0f73fa526a6a17e95763cc5b2a127022f5ea5a73445",
|
||||
"salt": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
|
||||
"plaintext": "⚠️",
|
||||
"ciphertext": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBA4fZmoHrtrz5I5pCHuwWZ22qqL/Xt1VidEZGMLds0yaJ5VwUbeEifEJlPICOFt1ssZJxCUf43HvRwCVTFskbhSMh",
|
||||
"note": "unknown encryption version 0"
|
||||
},
|
||||
{
|
||||
"sec1": "2573d1e9b9ac5de5d570f652cbb9e8d4f235e3d3d334181448e87c417f374e83",
|
||||
"pub2": "8348c2d35549098706e5bab7966d9a9c72fbf6554e918f41c2b6cb275f79ec13",
|
||||
"sharedKey": "8673ec68393a997bfad7eab8661461daf8b3931b7e885d78312a3fb7fe17f41a",
|
||||
"salt": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||
"plaintext": "n o s t r",
|
||||
"ciphertext": "Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbOsrsqIEybscEwg5rnI/Cx03mDSmeweOLKD,7dw5BDZQDxXSlCwX1LIcTJEZaJPTz98Ftu0zSE0d93ED7OtdlvNeZx",
|
||||
"note": "invalid base64"
|
||||
},
|
||||
{
|
||||
"sec1": "5a2f39347fed3883c9fe05868a8f6156a292c45f606bc610495fcc020ed158f7",
|
||||
"pub2": "775bbfeba58d07f9d1fbb862e306ac780f39e5418043dadb547c7b5900245e71",
|
||||
"sharedKey": "2e70c0a1cde884b88392458ca86148d859b273a5695ede5bbe41f731d7d88ffd",
|
||||
"salt": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
|
||||
"plaintext": "¯\\_(ツ)_/¯",
|
||||
"ciphertext": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholdUejFZPARM22IvOqp1U/UmFSkeSyTBYbbwy5ykmi+mKiEcWL+nVmTOf28MMiC+rTpZys/8p1hqQFpn+XWZRPrVay",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"sec1": "067eda13c4a36090ad28a7a183e9df611186ca01f63cb30fcdfa615ebfd6fb6d",
|
||||
"pub2": "32c1ece2c5dd2160ad03b243f50eff12db605b86ac92da47eacc78144bf0cdd3",
|
||||
"sharedKey": "a808915e31afc5b853d654d2519632dac7298ee2ecddc11695b8eba925935c2a",
|
||||
"salt": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
|
||||
"plaintext": "🥎",
|
||||
"ciphertext": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0U0P/M1/oKm95z8qz6Kg0zQawLzwk3DskvWA2drXP4zK+tzHpKvWq0KOdx5MdypboSQsP4NXfhh2KoUffjkyIOiMA",
|
||||
"note": "invalid MAC"
|
||||
},
|
||||
{
|
||||
"sec1": "3e7be560fb9f8c965c48953dbd00411d48577e200cf00d7cc427e49d0e8d9c01",
|
||||
"pub2": "e539e5fee58a337307e2a937ee9a7561b45876fb5df405c5e7be3ee564b239cc",
|
||||
"sharedKey": "6ee3efc4255e3b8270e5dd3f7dc7f6b60878cda6218c8df34a3261cd48744931",
|
||||
"salt": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
|
||||
"plaintext": "elliptic-curve cryptography",
|
||||
"ciphertext": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHBu7F9m36yBd58mVUBB5ktBTOJREDaQT1KAyPmZidP+IRea1lNw5YAEK7+pbnpfCw8CD0i2n8Pf2IDWlKDhLiVvatw",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"sec1": "c22e1d4de967aa39dc143354d8f596cec1d7c912c3140831fff2976ce3e387c1",
|
||||
"pub2": "4e405be192677a2da95ffc733950777213bf880cf7c3b084eeb6f3fe5bd43705",
|
||||
"sharedKey": "1675a773dbf6fbcbef6a293004a4504b6c856978be738b10584b0269d437c8d1",
|
||||
"salt": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
|
||||
"plaintext": "Peer-to-Peer",
|
||||
"ciphertext": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwhT0hvSnF9Xjp9Lml792qtNbmAVvR6laukTe9eYEjeWPpZFxtkVpYTbbL9wDKFeplDMKsUKVa+roSeSvv0ela9seDVl2Sfso=",
|
||||
"note": "invalid padding"
|
||||
},
|
||||
{
|
||||
"sec1": "be1edab14c5912e5c59084f197f0945242e969c363096cccb59af8898815096f",
|
||||
"pub2": "9eaf0775d971e4941c97189232542e1daefcdb7dddafc39bcea2520217710ba2",
|
||||
"sharedKey": "1741a44c052d5ae363c7845441f73d2b6c28d9bfb3006190012bba12eb4c774b",
|
||||
"salt": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
|
||||
"plaintext": "censorship-resistant and global social network",
|
||||
"ciphertext": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6bL9HUMo3Mjkz4bjQo/FJF2LWHmaCr9Byc3hU9D7we+EkNBWenBHasT1G52fZk9r3NKeOC1hLezNwBLr7XXiULh+NbMBDtJh9/aQh1uZ9EpAfeISOzbZXwYwf0P5M85g9XER8hZ2fgJDLb4qMOuQRG6CrPezhr357nS3UHwPC2qHo3uKACxhE+2td+965yDcvMTx4KYTQg1zNhd7PA5v/WPnWeq2B623yLxlevUuo/OvXplFho3QVy7s5QZVop6qV2g2/l/SIsvD0HIcv3V35sywOCBR0K4VHgduFqkx/LEF3NGgAbjONXQHX8ZKushsEeR4TxlFoRSovAyYjhWolz+Ok3KJL2Ertds3H+M/Bdl2WnZGT0IbjZjn3DS+b1Ke0R0X4Onww2ZG3+7o6ncIwTc+lh1O7YQn00V0HJ+EIp03heKV2zWdVSC615By/+Yt9KAiV56n5+02GAuNqA",
|
||||
"note": "invalid padding"
|
||||
}
|
||||
],
|
||||
"invalid_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"
|
||||
}
|
||||
],
|
||||
"padding": [
|
||||
[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],
|
||||
[74123, 81920]
|
||||
]
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
342
nip46.ts
Normal file
342
nip46.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||
import { decrypt, encrypt } from './nip04.ts'
|
||||
import { NIP05_REGEX } from './nip05.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export type BunkerPointer = {
|
||||
relays: string[]
|
||||
pubkey: string
|
||||
secret: null | string
|
||||
}
|
||||
|
||||
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||
and returns a BunkerPointer -- or null in case of error */
|
||||
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||
let match = input.match(BUNKER_REGEX)
|
||||
if (match) {
|
||||
try {
|
||||
const pubkey = match[1]
|
||||
const qs = new URLSearchParams(match[2])
|
||||
return {
|
||||
pubkey,
|
||||
relays: qs.getAll('relay'),
|
||||
secret: qs.get('secret'),
|
||||
}
|
||||
} catch (_err) {
|
||||
/* just move to the next case */
|
||||
}
|
||||
}
|
||||
|
||||
return queryBunkerProfile(input)
|
||||
}
|
||||
|
||||
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
|
||||
const match = nip05.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||
|
||||
let pubkey = res.names[name]
|
||||
let relays = res.nip46[pubkey] || []
|
||||
|
||||
return { pubkey, relays, secret: null }
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type BunkerSignerParams = {
|
||||
pool?: AbstractSimplePool
|
||||
onauth?: (url: string) => void
|
||||
}
|
||||
|
||||
export class BunkerSigner {
|
||||
private pool: AbstractSimplePool
|
||||
private subCloser: SubCloser
|
||||
private isOpen: boolean
|
||||
private serial: number
|
||||
private idPrefix: string
|
||||
private listeners: {
|
||||
[id: string]: {
|
||||
resolve: (_: string) => void
|
||||
reject: (_: string) => void
|
||||
}
|
||||
}
|
||||
private waitingForAuth: { [id: string]: boolean }
|
||||
private secretKey: Uint8Array
|
||||
public bp: BunkerPointer
|
||||
|
||||
/**
|
||||
* Creates a new instance of the Nip46 class.
|
||||
* @param relays - An array of relay addresses.
|
||||
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||
* @param secretKey - An optional key pair.
|
||||
*/
|
||||
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
|
||||
if (bp.relays.length === 0) {
|
||||
throw new Error('no relays are specified for this bunker')
|
||||
}
|
||||
|
||||
this.pool = params.pool || new SimplePool()
|
||||
this.secretKey = clientSecretKey
|
||||
this.bp = bp
|
||||
this.isOpen = false
|
||||
this.idPrefix = Math.random().toString(36).substring(7)
|
||||
this.serial = 0
|
||||
this.listeners = {}
|
||||
this.waitingForAuth = {}
|
||||
|
||||
const listeners = this.listeners
|
||||
const waitingForAuth = this.waitingForAuth
|
||||
|
||||
this.subCloser = this.pool.subscribeMany(
|
||||
this.bp.relays,
|
||||
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }],
|
||||
{
|
||||
async onevent(event: NostrEvent) {
|
||||
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
|
||||
|
||||
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||
delete waitingForAuth[id]
|
||||
|
||||
if (params.onauth) {
|
||||
params.onauth(error)
|
||||
} else {
|
||||
console.warn(
|
||||
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let handler = listeners[id]
|
||||
if (handler) {
|
||||
if (error) handler.reject(error)
|
||||
else if (result) handler.resolve(result)
|
||||
delete listeners[id]
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
this.isOpen = true
|
||||
}
|
||||
|
||||
// closes the subscription -- this object can't be used anymore after this
|
||||
async close() {
|
||||
this.isOpen = false
|
||||
this.subCloser.close()
|
||||
}
|
||||
|
||||
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||
this.serial++
|
||||
const id = `${this.idPrefix}-${this.serial}`
|
||||
|
||||
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params }))
|
||||
|
||||
// the request event
|
||||
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||
{
|
||||
kind: NostrConnect,
|
||||
tags: [['p', this.bp.pubkey]],
|
||||
content: encryptedContent,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.secretKey,
|
||||
)
|
||||
|
||||
// setup callback listener
|
||||
this.listeners[id] = { resolve, reject }
|
||||
this.waitingForAuth[id] = true
|
||||
|
||||
// publish the event
|
||||
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
* The promise will be rejected if the response is not "pong".
|
||||
*/
|
||||
async ping(): Promise<void> {
|
||||
let resp = await this.sendRequest('ping', [])
|
||||
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
await this.sendRequest('connect', [getPublicKey(this.secretKey), this.bp.secret || ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* This was supposed to call the "get_public_key" method on the bunker,
|
||||
* but instead we just returns the public key we already know.
|
||||
*/
|
||||
async getPublicKey(): Promise<string> {
|
||||
return this.bp.pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "get_relays" method on the bunker.
|
||||
*/
|
||||
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> {
|
||||
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event using the remote private key.
|
||||
* @param event - The event to sign.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
|
||||
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||
let signed: NostrEvent = JSON.parse(resp)
|
||||
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) {
|
||||
return signed
|
||||
} else {
|
||||
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
|
||||
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
|
||||
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
|
||||
return hexToBytes(resp)
|
||||
}
|
||||
|
||||
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account with the specified username, domain, and optional email.
|
||||
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||
* @param username - The username for the account.
|
||||
* @param domain - The domain for the account.
|
||||
* @param email - The optional email for the account.
|
||||
* @throws Error if the email is present but invalid.
|
||||
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||
*/
|
||||
export async function createAccount(
|
||||
bunker: BunkerProfile,
|
||||
params: BunkerSignerParams,
|
||||
username: string,
|
||||
domain: string,
|
||||
email?: string,
|
||||
): Promise<BunkerSigner> {
|
||||
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
|
||||
|
||||
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||
|
||||
// once we get the newly created pubkey back, we hijack this signer instance
|
||||
// and turn it into the main instance for this newly created pubkey
|
||||
rpc.bp.pubkey = pubkey
|
||||
await rpc.connect()
|
||||
|
||||
return rpc
|
||||
}
|
||||
|
||||
// @deprecated use fetchBunkerProviders instead
|
||||
export const fetchCustodialBunkers = fetchBunkerProviders
|
||||
|
||||
/**
|
||||
* Fetches info on available providers that announce themselves using NIP-89 events.
|
||||
* @returns A promise that resolves to an array of available bunker objects.
|
||||
*/
|
||||
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [Handlerinformation],
|
||||
'#k': [NostrConnect.toString()],
|
||||
})
|
||||
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// validate bunkers by checking their NIP-05 and pubkey
|
||||
// map to a more useful object
|
||||
const validatedBunkers = await Promise.all(
|
||||
events.map(async (event, i) => {
|
||||
try {
|
||||
const content = JSON.parse(event.content)
|
||||
|
||||
// skip duplicates
|
||||
try {
|
||||
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
const bp = await queryBunkerProfile(content.nip05)
|
||||
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
|
||||
return {
|
||||
bunkerPointer: bp,
|
||||
nip05: content.nip05,
|
||||
domain: content.nip05.split('@')[1],
|
||||
name: content.name || content.display_name,
|
||||
picture: content.picture,
|
||||
about: content.about,
|
||||
website: content.website,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
|
||||
}
|
||||
|
||||
export type BunkerProfile = {
|
||||
bunkerPointer: BunkerPointer
|
||||
domain: string
|
||||
nip05: string
|
||||
name: string
|
||||
picture: string
|
||||
about: string
|
||||
website: string
|
||||
local: boolean
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { Kind } from './event'
|
||||
import { decrypt } from './nip04.ts'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
|
||||
describe('parseConnectionString', () => {
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
@@ -43,19 +40,11 @@ describe('parseConnectionString', () => {
|
||||
describe('makeNwcRequestEvent', () => {
|
||||
test('returns a valid NWC request event', async () => {
|
||||
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||
const secret = '71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
const invoice =
|
||||
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||
const timeBefore = Date.now() / 1000
|
||||
const result = await makeNwcRequestEvent({
|
||||
pubkey,
|
||||
secret,
|
||||
invoice,
|
||||
})
|
||||
const timeAfter = Date.now() / 1000
|
||||
expect(result.kind).toBe(Kind.NwcRequest)
|
||||
expect(result.created_at).toBeGreaterThan(timeBefore)
|
||||
expect(result.created_at).toBeLessThan(timeAfter)
|
||||
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',
|
||||
|
||||
32
nip47.ts
32
nip47.ts
@@ -1,8 +1,14 @@
|
||||
import { finishEvent } from './event.ts'
|
||||
import { type VerifiedEvent, finalizeEvent } from './pure.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
import { encrypt } from './nip04.ts'
|
||||
import { Kind } from './event'
|
||||
|
||||
export function parseConnectionString(connectionString: string) {
|
||||
interface NWCConnection {
|
||||
pubkey: string
|
||||
relay: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export function parseConnectionString(connectionString: string): NWCConnection {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const relay = searchParams.get('relay')
|
||||
@@ -15,28 +21,24 @@ export function parseConnectionString(connectionString: string) {
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent({
|
||||
pubkey,
|
||||
secret,
|
||||
invoice,
|
||||
}: {
|
||||
pubkey: string
|
||||
secret: string
|
||||
invoice: string
|
||||
}) {
|
||||
export async function makeNwcRequestEvent(
|
||||
pubkey: string,
|
||||
secretKey: Uint8Array,
|
||||
invoice: string,
|
||||
): Promise<VerifiedEvent> {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secret, pubkey, JSON.stringify(content))
|
||||
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: Kind.NwcRequest,
|
||||
kind: NWCWalletRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: encryptedContent,
|
||||
tags: [['p', pubkey]],
|
||||
}
|
||||
|
||||
return finishEvent(eventTemplate, secret)
|
||||
return finalizeEvent(eventTemplate, secretKey)
|
||||
}
|
||||
|
||||
95
nip49.test.ts
Normal file
95
nip49.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { decrypt, encrypt } from './nip49'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('encrypt and decrypt', () => {
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
|
||||
let sec = hexToBytes(secret)
|
||||
let there = encrypt(sec, password, logn, ksb)
|
||||
let back = decrypt(there, password)
|
||||
let again = decrypt(ncryptsec, password)
|
||||
expect(back).toEqual(again)
|
||||
expect(again).toEqual(sec)
|
||||
}
|
||||
})
|
||||
|
||||
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
2,
|
||||
0x01,
|
||||
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
3,
|
||||
0x02,
|
||||
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
|
||||
],
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
7,
|
||||
0x00,
|
||||
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
8,
|
||||
0x01,
|
||||
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x02,
|
||||
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
4,
|
||||
0x00,
|
||||
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
5,
|
||||
0x01,
|
||||
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
|
||||
],
|
||||
[
|
||||
'ÅΩẛ̣',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
|
||||
],
|
||||
[
|
||||
'ÅΩṩ',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
|
||||
],
|
||||
]
|
||||
45
nip49.ts
Normal file
45
nip49.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { scrypt } from '@noble/hashes/scrypt'
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||
import { Bech32MaxSize, encodeBytes } from './nip19'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
|
||||
let salt = randomBytes(16)
|
||||
let n = 2 ** logn
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let nonce = randomBytes(24)
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let ciphertext = xc2p1.encrypt(sec)
|
||||
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
|
||||
return encodeBytes('ncryptsec', b)
|
||||
}
|
||||
|
||||
export function decrypt(ncryptsec: string, password: string): Uint8Array {
|
||||
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
|
||||
if (prefix !== 'ncryptsec') {
|
||||
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
|
||||
}
|
||||
let b = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
let version = b[0]
|
||||
if (version !== 0x02) {
|
||||
throw new Error(`invalid version ${version}, expected 0x02`)
|
||||
}
|
||||
|
||||
let logn = b[1]
|
||||
let n = 2 ** logn
|
||||
|
||||
let salt = b.slice(2, 2 + 16)
|
||||
let nonce = b.slice(2 + 16, 2 + 16 + 24)
|
||||
let ksb = b[2 + 16 + 24]
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let ciphertext = b.slice(2 + 16 + 24 + 1)
|
||||
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let sec = xc2p1.decrypt(ciphertext)
|
||||
|
||||
return sec
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { finishEvent } from './event.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.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'
|
||||
|
||||
@@ -12,7 +13,7 @@ describe('getZapEndpoint', () => {
|
||||
})
|
||||
|
||||
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"}' })
|
||||
@@ -23,7 +24,7 @@ describe('getZapEndpoint', () => {
|
||||
})
|
||||
|
||||
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"}' })
|
||||
@@ -34,7 +35,7 @@ describe('getZapEndpoint', () => {
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
const fetchImplementation = mock(() =>
|
||||
Promise.resolve({
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
@@ -94,9 +95,9 @@ describe('makeZapRequest', () => {
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
['e', 'event'],
|
||||
]),
|
||||
)
|
||||
expect(result.tags).toContainEqual(['e', 'event'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,7 +122,7 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
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 = {
|
||||
@@ -140,9 +141,8 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -159,9 +159,8 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -179,10 +178,10 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -201,10 +200,10 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
@@ -221,10 +220,10 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = finishEvent(
|
||||
const zapRequest = finalizeEvent(
|
||||
{
|
||||
kind: 9734,
|
||||
created_at: Date.now() / 1000,
|
||||
@@ -243,18 +242,19 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
describe('makeZapReceipt', () => {
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const privateKey = generatePrivateKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
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'],
|
||||
],
|
||||
@@ -271,24 +271,26 @@ describe('makeZapReceipt', () => {
|
||||
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'],
|
||||
],
|
||||
@@ -304,9 +306,14 @@ describe('makeZapReceipt', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
20
nip57.ts
20
nip57.ts
@@ -1,6 +1,6 @@
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
@@ -13,7 +13,7 @@ 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)
|
||||
@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event<Kind.Metadata>): Promise<nu
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -53,11 +53,11 @@ export function makeZapRequest({
|
||||
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,
|
||||
@@ -86,7 +86,7 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
||||
|
||||
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."
|
||||
@@ -111,15 +111,15 @@ export function makeZapReceipt({
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate<Kind.Zap> {
|
||||
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||
}): 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) {
|
||||
|
||||
203
nip75.test.ts
Normal file
203
nip75.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { ZapGoal } from './kinds.ts'
|
||||
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('Goal Type', () => {
|
||||
it('should create a proper Goal object', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(goal.content).toBe('Fundraising for a new project')
|
||||
expect(goal.amount).toBe('100000000')
|
||||
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
|
||||
expect(goal.closedAt).toBe(1671150419)
|
||||
expect(goal.image).toBe('https://example.com/goal-image.jpg')
|
||||
expect(goal.summary).toBe('Help us reach our fundraising goal!')
|
||||
expect(goal.r).toBe('https://example.com/additional-info')
|
||||
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
|
||||
expect(goal.zapTags).toEqual([
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateGoalEventTemplate', () => {
|
||||
it('should generate an EventTemplate for a fundraising goal', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate that is valid', () => {
|
||||
const sk = generateSecretKey()
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapGoalEvent', () => {
|
||||
it('should validate a proper Goal event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should not validate an event with an incorrect kind', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // Incorrect kind
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "amount" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
// Missing "amount" tag
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "relays" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
// Missing "relays" tag
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
115
nip75.ts
Normal file
115
nip75.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Event, EventTemplate } from './core'
|
||||
import { ZapGoal } from './kinds'
|
||||
|
||||
/**
|
||||
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
|
||||
* This type is used to structure the information needed to create a goal event (`kind:9041`).
|
||||
*/
|
||||
export type Goal = {
|
||||
/**
|
||||
* A human-readable description of the fundraising goal.
|
||||
* This content should provide clear information about the purpose of the fundraising.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The target amount for the fundraising goal in milisats.
|
||||
* This defines the financial target that the fundraiser aims to reach.
|
||||
*/
|
||||
amount: string
|
||||
|
||||
/**
|
||||
* A list of relays where the zaps towards this goal will be sent to and tallied from.
|
||||
* Each relay is represented by its WebSocket URL.
|
||||
*/
|
||||
relays: string[]
|
||||
|
||||
/**
|
||||
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
|
||||
* Zaps published after this timestamp should not count towards the goal progress.
|
||||
* If not provided, the goal remains open indefinitely or until manually closed.
|
||||
*/
|
||||
closedAt?: number
|
||||
|
||||
/**
|
||||
* An optional URL to an image related to the goal.
|
||||
* This can be used to visually represent the goal on client interfaces.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* An optional brief description or summary of the goal.
|
||||
* This can provide a quick overview of the goal, separate from the detailed `content`.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
|
||||
* This is a single URL, as per NIP-75 specifications for linking additional resources.
|
||||
*/
|
||||
r?: string
|
||||
|
||||
/**
|
||||
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
|
||||
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
|
||||
*/
|
||||
a?: string
|
||||
|
||||
/**
|
||||
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
|
||||
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
|
||||
*/
|
||||
zapTags?: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
|
||||
* This function is tailored to fit the structure of EventTemplate as defined in the library.
|
||||
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
|
||||
* @returns An EventTemplate object structured for creating a Nostr event.
|
||||
*/
|
||||
export function generateGoalEventTemplate({
|
||||
amount,
|
||||
content,
|
||||
relays,
|
||||
a,
|
||||
closedAt,
|
||||
image,
|
||||
r,
|
||||
summary,
|
||||
zapTags,
|
||||
}: Goal): EventTemplate {
|
||||
const tags: string[][] = [
|
||||
['amount', amount],
|
||||
['relays', ...relays],
|
||||
]
|
||||
|
||||
// Append optional tags based on the presence of optional properties in zapGoal
|
||||
closedAt && tags.push(['closed_at', closedAt.toString()])
|
||||
image && tags.push(['image', image])
|
||||
summary && tags.push(['summary', summary])
|
||||
r && tags.push(['r', r])
|
||||
a && tags.push(['a', a])
|
||||
zapTags && tags.push(...zapTags)
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
export function validateZapGoalEvent(event: Event): boolean {
|
||||
if (event.kind !== ZapGoal) return false
|
||||
|
||||
const requiredTags = ['amount', 'relays'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
374
nip94.test.ts
Normal file
374
nip94.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
it('should generate the correct event template', () => {
|
||||
const fileMetadataObject: FileMetadataObject = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
}
|
||||
|
||||
const expectedEventTemplate: EventTemplate = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: expect.any(Number),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateEventTemplate(fileMetadataObject)
|
||||
|
||||
expect(eventTemplate).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if kind is not FileMetadataKind', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // not FileMetadataKind
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if content is empty', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // empty
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if required tags are missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithoutUrl: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
// missing url
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutM: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
// missing m
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutX: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
// missing x
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutOx: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
// missing ox
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithoutUrl)).toBe(false)
|
||||
expect(validateEvent(eventWithoutM)).toBe(false)
|
||||
expect(validateEvent(eventWithoutX)).toBe(false)
|
||||
expect(validateEvent(eventWithoutOx)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if size is not a number', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', 'abc'], // not a number
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if dim is not a valid dimension string', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithInvalidDim: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', 'abc'], // invalid dim
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithInvalidDim)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
it('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const parsedEvent = parseEvent(event)
|
||||
|
||||
expect(parsedEvent).toEqual({
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the event is invalid', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // invalid
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||
})
|
||||
})
|
||||
201
nip94.ts
Normal file
201
nip94.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Event, EventTemplate } from './core'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Type definition for File Metadata as specified in NIP-94.
|
||||
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
|
||||
*/
|
||||
export type FileMetadataObject = {
|
||||
/**
|
||||
* A description or caption for the file content.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The URL to download the file.
|
||||
*/
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The MIME type of the file, in lowercase.
|
||||
*/
|
||||
m: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the file.
|
||||
*/
|
||||
x: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
|
||||
*/
|
||||
ox: string
|
||||
|
||||
/**
|
||||
* Optional: The size of the file in bytes.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
|
||||
*/
|
||||
dim?: string
|
||||
|
||||
/**
|
||||
* Optional: The URI to the magnet file.
|
||||
*/
|
||||
magnet?: string
|
||||
|
||||
/**
|
||||
* Optional: The torrent infohash.
|
||||
*/
|
||||
i?: string
|
||||
|
||||
/**
|
||||
* Optional: The blurhash string to show while the file is being loaded by the client.
|
||||
*/
|
||||
blurhash?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
|
||||
*/
|
||||
thumb?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of a preview image with the same dimensions as the original file.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* Optional: A text excerpt or summary of the file's content.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* Optional: A description for accessibility, providing context or a brief description of the file.
|
||||
*/
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a file metadata object.
|
||||
*
|
||||
* @param fileMetadata - The file metadata object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: fileMetadata.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', fileMetadata.url],
|
||||
['m', fileMetadata.m],
|
||||
['x', fileMetadata.x],
|
||||
['ox', fileMetadata.ox],
|
||||
],
|
||||
}
|
||||
|
||||
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
|
||||
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
|
||||
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
|
||||
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
|
||||
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
|
||||
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
|
||||
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
|
||||
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid file metadata event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (event.kind !== FileMetadataKind) return false
|
||||
|
||||
if (!event.content) return false
|
||||
|
||||
const requiredTags = ['url', 'm', 'x', 'ox'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate optional size tag
|
||||
const sizeTag = event.tags.find(([t]) => t == 'size')
|
||||
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
|
||||
|
||||
// validate optional dim tag
|
||||
const dimTag = event.tags.find(([t]) => t == 'dim')
|
||||
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a file metadata object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The file metadata object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): FileMetadataObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const fileMetadata: FileMetadataObject = {
|
||||
content: event.content,
|
||||
url: '',
|
||||
m: '',
|
||||
x: '',
|
||||
ox: '',
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'url':
|
||||
fileMetadata.url = value
|
||||
break
|
||||
case 'm':
|
||||
fileMetadata.m = value
|
||||
break
|
||||
case 'x':
|
||||
fileMetadata.x = value
|
||||
break
|
||||
case 'ox':
|
||||
fileMetadata.ox = value
|
||||
break
|
||||
case 'size':
|
||||
fileMetadata.size = value
|
||||
break
|
||||
case 'dim':
|
||||
fileMetadata.dim = value
|
||||
break
|
||||
case 'magnet':
|
||||
fileMetadata.magnet = value
|
||||
break
|
||||
case 'i':
|
||||
fileMetadata.i = value
|
||||
break
|
||||
case 'blurhash':
|
||||
fileMetadata.blurhash = value
|
||||
break
|
||||
case 'thumb':
|
||||
fileMetadata.thumb = value
|
||||
break
|
||||
case 'image':
|
||||
fileMetadata.image = value
|
||||
break
|
||||
case 'summary':
|
||||
fileMetadata.summary = value
|
||||
break
|
||||
case 'alt':
|
||||
fileMetadata.alt = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
654
nip96.test.ts
Normal file
654
nip96.test.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { FileServerPreference } from './kinds.ts'
|
||||
import {
|
||||
calculateFileHash,
|
||||
checkFileProcessingStatus,
|
||||
deleteFile,
|
||||
generateDownloadUrl,
|
||||
generateFSPEventTemplate,
|
||||
readServerConfig,
|
||||
uploadFile,
|
||||
validateDelayedProcessingResponse,
|
||||
validateFileUploadResponse,
|
||||
validateServerConfiguration,
|
||||
type DelayedProcessingResponse,
|
||||
type FileUploadResponse,
|
||||
type ServerConfiguration,
|
||||
} from './nip96.ts'
|
||||
|
||||
describe('validateServerConfiguration', () => {
|
||||
it("should return true if 'api_url' is valid URL", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if 'api_url' is empty", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: '',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
delegated_to_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readServerConfig', () => {
|
||||
it('should return a valid ServerConfiguration object', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const validConfig: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(validConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const result = await readServerConfig('http://example.com/')
|
||||
|
||||
expect(result).toEqual(validConfig)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not valid', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const invalidConfig = {
|
||||
// missing api_url
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(invalidConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not proper json', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(null)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response status is not 200', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if input url is not valid', async () => {
|
||||
expect(readServerConfig('invalid-url')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFileUploadResponse', () => {
|
||||
it('should return true if response is valid', () => {
|
||||
const mockResponse: FileUploadResponse = {
|
||||
status: 'error',
|
||||
message: 'File uploaded failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if status is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'status'> = {
|
||||
// status: 'error',
|
||||
message: 'File upload failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if message is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'message'> = {
|
||||
status: 'error',
|
||||
// message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is not valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'something else',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "message" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'error',
|
||||
message: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
processing_url: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "success" and "nip94_event" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are invalid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
// missing url
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are empty', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true if "nip94_event" tags are valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should return a valid FileUploadResponse object', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 413', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 413 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('File too large!')
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 400', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Bad request! Some fields are missing or invalid!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 403', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 403 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Forbidden! Payload tag does not match the requested file!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 402', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 402 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('Payment required!')
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 500 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Unknown error in uploading file!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDownloadUrl', () => {
|
||||
it('should generate a download URL without file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const expectedUrl = 'http://example.com/download/abc123'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
|
||||
it('should generate a download URL with file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const fileExtension = '.jpg'
|
||||
const expectedUrl = 'http://example.com/download/abc123.jpg'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should return a basic json response for successful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual({ status: 'success', message: 'File deleted.' })
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error for unsuccessful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDelayedProcessingResponse', () => {
|
||||
it('should return false for non-object input', () => {
|
||||
expect(validateDelayedProcessingResponse('not an object')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(validateDelayedProcessingResponse(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for object missing required properties', () => {
|
||||
const missingStatus: Omit<DelayedProcessingResponse, 'status'> = {
|
||||
// missing status
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
|
||||
status: 'processing',
|
||||
// missing message
|
||||
percentage: 50,
|
||||
}
|
||||
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
// missing percentage
|
||||
}
|
||||
|
||||
expect(validateDelayedProcessingResponse(missingStatus)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingMessage)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid status', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-string message', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-number percentage', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for percentage out of range', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for valid input', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkFileProcessingStatus', () => {
|
||||
it('should throw an error if response is not ok', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not a valid json', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.text('not a json', { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid DelayedProcessingResponse object if response status is 200', async () => {
|
||||
// setup mock server
|
||||
const validDelayedProcessingResponse: DelayedProcessingResponse = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validDelayedProcessingResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validDelayedProcessingResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid FileUploadResponse object if response status is 201', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 201 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFSPEventTemplate', () => {
|
||||
it('should generate FSP event template', () => {
|
||||
const serverUrls = ['http://example.com', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.kind).toBe(FileServerPreference)
|
||||
expect(eventTemplate.content).toBe('')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
expect(typeof eventTemplate.created_at).toBe('number')
|
||||
})
|
||||
|
||||
it('should filter invalid server URLs', () => {
|
||||
const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty server URLs', () => {
|
||||
const serverUrls: string[] = []
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFileHash', () => {
|
||||
it('should calculate file hash', async () => {
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9')
|
||||
})
|
||||
|
||||
it('should calculate file hash with empty file', async () => {
|
||||
const file = new File([], 'empty.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
})
|
||||
582
nip96.ts
Normal file
582
nip96.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { EventTemplate } from './core'
|
||||
import { FileServerPreference } from './kinds'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
/**
|
||||
* Represents the configuration for a server compliant with NIP-96.
|
||||
*/
|
||||
export type ServerConfiguration = {
|
||||
/**
|
||||
* The base URL from which file upload and deletion operations are served.
|
||||
* Also used for downloads if "download_url" is not specified.
|
||||
*/
|
||||
api_url: string
|
||||
|
||||
/**
|
||||
* Optional. The base URL from which files are downloaded.
|
||||
* Used if different from the "api_url".
|
||||
*/
|
||||
download_url?: string
|
||||
|
||||
/**
|
||||
* Optional. URL of another HTTP file storage server's configuration.
|
||||
* Used by nostr relays to delegate to another server.
|
||||
* In this case, "api_url" must be an empty string.
|
||||
*/
|
||||
delegated_to_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of NIP numbers that this server supports.
|
||||
*/
|
||||
supported_nips?: number[]
|
||||
|
||||
/**
|
||||
* Optional. URL to the server's Terms of Service.
|
||||
*/
|
||||
tos_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of MIME types supported by the server.
|
||||
*/
|
||||
content_types?: string[]
|
||||
|
||||
/**
|
||||
* Optional. Defines various storage plans offered by the server.
|
||||
*/
|
||||
plans?: {
|
||||
[planKey: string]: {
|
||||
/**
|
||||
* The name of the storage plan.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Optional. Indicates whether NIP-98 is required for uploads in this plan.
|
||||
*/
|
||||
is_nip98_required?: boolean
|
||||
|
||||
/**
|
||||
* Optional. URL to a landing page providing more information about the plan.
|
||||
*/
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Optional. The maximum file size allowed under this plan, in bytes.
|
||||
*/
|
||||
max_byte_size?: number
|
||||
|
||||
/**
|
||||
* Optional. Defines the range of file expiration in days.
|
||||
* The first value indicates the minimum expiration time, and the second value indicates the maximum.
|
||||
* A value of 0 indicates no expiration.
|
||||
*/
|
||||
file_expiration?: [number, number]
|
||||
|
||||
/**
|
||||
* Optional. Specifies the types of media transformations supported under this plan.
|
||||
* Currently, only image transformations are considered.
|
||||
*/
|
||||
media_transformations?: {
|
||||
/**
|
||||
* Optional. An array of supported image transformation types.
|
||||
*/
|
||||
image?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the optional form data fields for file upload in accordance with NIP-96.
|
||||
*/
|
||||
export type OptionalFormDataFields = {
|
||||
/**
|
||||
* Specifies the desired expiration time of the file on the server.
|
||||
* It should be a string representing a UNIX timestamp in seconds.
|
||||
* An empty string indicates that the file should be stored indefinitely.
|
||||
*/
|
||||
expiration?: string
|
||||
|
||||
/**
|
||||
* Indicates the size of the file in bytes.
|
||||
* This field can be used by the server to pre-validate the file size before processing the upload.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Provides a strict description of the file for accessibility purposes,
|
||||
* particularly useful for visibility-impaired users.
|
||||
*/
|
||||
alt?: string
|
||||
|
||||
/**
|
||||
* A loose, more descriptive caption for the file.
|
||||
* This can be used for additional context or commentary about the file.
|
||||
*/
|
||||
caption?: string
|
||||
|
||||
/**
|
||||
* Specifies the intended use of the file.
|
||||
* Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner.
|
||||
* Absence of this field suggests standard file upload without special treatment.
|
||||
*/
|
||||
media_type?: 'avatar' | 'banner'
|
||||
|
||||
/**
|
||||
* The MIME type of the file being uploaded.
|
||||
* This can be used for early rejection by the server if the file type isn't supported.
|
||||
*/
|
||||
content_type?: string
|
||||
|
||||
/**
|
||||
* Other custom form data fields.
|
||||
*/
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a file upload request.
|
||||
*/
|
||||
export type FileUploadResponse = {
|
||||
/**
|
||||
* The status of the upload request.
|
||||
* - 'success': Indicates the file was successfully uploaded.
|
||||
* - 'error': Indicates there was an error in the upload process.
|
||||
* - 'processing': Indicates the file is still being processed (used in cases of delayed processing).
|
||||
*/
|
||||
status: 'success' | 'error' | 'processing'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message, error description, or processing status.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* Optional. A URL provided by the server where the upload processing status can be checked.
|
||||
* This is relevant in cases where the file upload involves delayed processing.
|
||||
*/
|
||||
processing_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An event object conforming to NIP-94, which includes details about the uploaded file.
|
||||
* This object is typically provided in the response for a successful upload and contains
|
||||
* essential information such as the download URL and file metadata.
|
||||
*/
|
||||
nip94_event?: {
|
||||
/**
|
||||
* A collection of key-value pairs (tags) providing metadata about the uploaded file.
|
||||
* Standard tags include:
|
||||
* - 'url': The URL where the file can be accessed.
|
||||
* - 'ox': The SHA-256 hash of the original file before any server-side transformations.
|
||||
* Additional optional tags might include file dimensions, MIME type, etc.
|
||||
*/
|
||||
tags: Array<[string, string]>
|
||||
|
||||
/**
|
||||
* A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure.
|
||||
*/
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a delayed processing request.
|
||||
*/
|
||||
export type DelayedProcessingResponse = {
|
||||
/**
|
||||
* The status of the delayed processing request.
|
||||
* - 'processing': Indicates the file is still being processed.
|
||||
* - 'error': Indicates there was an error in the processing.
|
||||
*/
|
||||
status: 'processing' | 'error'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message or error description.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* The percentage of the file that has been processed. This is a number between 0 and 100.
|
||||
*/
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server configuration.
|
||||
*
|
||||
* @param config - The server configuration object.
|
||||
* @returns True if the configuration is valid, false otherwise.
|
||||
*/
|
||||
export function validateServerConfiguration(config: ServerConfiguration): boolean {
|
||||
if (Boolean(config.api_url) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, parses, and validates the server configuration from the given URL.
|
||||
*
|
||||
* @param serverUrl The URL of the server.
|
||||
* @returns The server configuration, or an error if the configuration could not be fetched or parsed.
|
||||
*/
|
||||
export async function readServerConfig(serverUrl: string): Promise<ServerConfiguration> {
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
let fetchUrl = ''
|
||||
|
||||
try {
|
||||
const { origin } = new URL(serverUrl)
|
||||
fetchUrl = origin + HTTPROUTE
|
||||
} catch (error) {
|
||||
throw new Error('Invalid URL')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: any = await response.json()
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data')
|
||||
}
|
||||
|
||||
if (!validateServerConfiguration(data)) {
|
||||
throw new Error('Invalid configuration data')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (_) {
|
||||
throw new Error(`Error fetching.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given object is a valid FileUploadResponse.
|
||||
*
|
||||
* @param response - The object to validate.
|
||||
* @returns true if the object is a valid FileUploadResponse, otherwise false.
|
||||
*/
|
||||
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 'processing' && !response.processing_url) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.processing_url) {
|
||||
if (typeof response.processing_url !== 'string') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 'success' && !response.nip94_event) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.nip94_event) {
|
||||
if (
|
||||
!response.nip94_event.tags ||
|
||||
!Array.isArray(response.nip94_event.tags) ||
|
||||
response.nip94_event.tags.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const tag of response.nip94_event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length !== 2) return false
|
||||
|
||||
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to a NIP-96 compliant server.
|
||||
*
|
||||
* @param file - The file to be uploaded.
|
||||
* @param serverApiUrl - The API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @param optionalFormDataFields - Optional form data fields.
|
||||
* @returns A promise that resolves to the server's response.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
optionalFormDataFields?: OptionalFormDataFields,
|
||||
): Promise<FileUploadResponse> {
|
||||
// Create FormData object
|
||||
const formData = new FormData()
|
||||
|
||||
// Append the authorization header to HTML Form Data
|
||||
formData.append('Authorization', nip98AuthorizationHeader)
|
||||
|
||||
// Append optional fields to FormData
|
||||
optionalFormDataFields &&
|
||||
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
formData.append(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Append the file to FormData as the last field
|
||||
formData.append('file', file)
|
||||
|
||||
// Make the POST request to the server
|
||||
const response = await fetch(serverApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok === false) {
|
||||
// 413 Payload Too Large
|
||||
if (response.status === 413) {
|
||||
throw new Error('File too large!')
|
||||
}
|
||||
|
||||
// 400 Bad Request
|
||||
if (response.status === 400) {
|
||||
throw new Error('Bad request! Some fields are missing or invalid!')
|
||||
}
|
||||
|
||||
// 403 Forbidden
|
||||
if (response.status === 403) {
|
||||
throw new Error('Forbidden! Payload tag does not match the requested file!')
|
||||
}
|
||||
|
||||
// 402 Payment Required
|
||||
if (response.status === 402) {
|
||||
throw new Error('Payment required!')
|
||||
}
|
||||
|
||||
// unknown error
|
||||
throw new Error('Unknown error in uploading file!')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the URL for downloading a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration.
|
||||
* @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png').
|
||||
* @returns A string representing the complete URL to download the file.
|
||||
*
|
||||
*/
|
||||
export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string {
|
||||
// Construct the base download URL using the file hash
|
||||
let downloadUrl = `${serverDownloadUrl}/${fileHash}`
|
||||
|
||||
// Append the file extension if provided
|
||||
if (fileExtension) {
|
||||
downloadUrl += fileExtension
|
||||
}
|
||||
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to delete a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @returns A promise that resolves to the server's response to the deletion request.
|
||||
*
|
||||
*/
|
||||
export async function deleteFile(
|
||||
fileHash: string,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
): Promise<any> {
|
||||
// make sure the serverApiUrl ends with a slash
|
||||
if (!serverApiUrl.endsWith('/')) {
|
||||
serverApiUrl += '/'
|
||||
}
|
||||
|
||||
// Construct the URL for the delete request
|
||||
const deleteUrl = `${serverApiUrl}${fileHash}`
|
||||
|
||||
// Send the DELETE request
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
},
|
||||
})
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error('Error deleting file!')
|
||||
}
|
||||
|
||||
// Return the response from the server
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server's response to a delayed processing request.
|
||||
*
|
||||
* @param response - The server's response to a delayed processing request.
|
||||
* @returns A boolean indicating whether the response is valid.
|
||||
*/
|
||||
export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message || !response.percentage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'processing' && response.status !== 'error') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.percentage !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Number(response.percentage) < 0 || Number(response.percentage) > 100) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the processing status of a file when delayed processing is used.
|
||||
*
|
||||
* @param processingUrl - The URL provided by the server where the processing status can be checked.
|
||||
* @returns A promise that resolves to an object containing the processing status and other relevant information.
|
||||
*/
|
||||
export async function checkFileProcessingStatus(
|
||||
processingUrl: string,
|
||||
): Promise<FileUploadResponse | DelayedProcessingResponse> {
|
||||
// Make the GET request to the processing URL
|
||||
const response = await fetch(processingUrl)
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
// 201 Created: Indicates the processing is over.
|
||||
if (response.status === 201) {
|
||||
// Validate the response
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
// 200 OK: Indicates the processing is still ongoing.
|
||||
if (response.status === 200) {
|
||||
// Validate the response
|
||||
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from the server!')
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template to indicate a user's File Server Preferences.
|
||||
* This event is of kind 10096 and is used to specify one or more preferred servers for file uploads.
|
||||
*
|
||||
* @param serverUrls - An array of URLs representing the user's preferred file storage servers.
|
||||
* @returns An object representing a Nostr event template for setting file server preferences.
|
||||
*/
|
||||
export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate {
|
||||
serverUrls = serverUrls.filter(serverUrl => {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: FileServerPreference,
|
||||
content: '',
|
||||
tags: serverUrls.map(serverUrl => ['server', serverUrl]),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations,
|
||||
* such as file upload, download, and deletion, to uniquely identify files.
|
||||
*
|
||||
* @param file - The file for which the SHA-256 hash needs to be calculated.
|
||||
* @returns A promise that resolves to the SHA-256 hash of the file.
|
||||
*/
|
||||
export async function calculateFileHash(file: Blob): Promise<string> {
|
||||
return bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
|
||||
}
|
||||
382
nip98.test.ts
382
nip98.test.ts
@@ -1,130 +1,350 @@
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, Kind, finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const sk = generatePrivateKey()
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import {
|
||||
getToken,
|
||||
hashPayload,
|
||||
unpackEventFromToken,
|
||||
validateEvent,
|
||||
validateEventKind,
|
||||
validateEventMethodTag,
|
||||
validateEventPayloadTag,
|
||||
validateEventTimestamp,
|
||||
validateEventUrlTag,
|
||||
validateToken,
|
||||
} from './nip98.ts'
|
||||
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
test('returns without authorization scheme for GET', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
|
||||
test('returns token without authorization scheme for POST', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
test('returns token WITH authorization scheme for POST', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken missing loginUrl throws an error', async () => {
|
||||
const result = getToken('', 'get', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
test('returns token with a valid payload tag when payload is present', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const payload = { test: 'payload' }
|
||||
const payloadHash = hashPayload(payload)
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
test('returns true for valid token without authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
test('returns true for valid token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for invalid token', async () => {
|
||||
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for missing token', async () => {
|
||||
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.kind = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.created_at = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid url', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid method', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('returns true for valid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventTimestamp', () => {
|
||||
test('returns true for valid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventKind', () => {
|
||||
test('returns true for valid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventUrlTag', () => {
|
||||
test('returns true for valid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventMethodTag', () => {
|
||||
test('returns true for valid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventPayloadTag', () => {
|
||||
test('returns true for valid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for missing payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPayload', () => {
|
||||
test('returns hash for valid payload', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
|
||||
test('returns hash for empty payload', async () => {
|
||||
const payload = {}
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
})
|
||||
|
||||
162
nip98.ts
162
nip98.ts
@@ -1,6 +1,10 @@
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
|
||||
import { utf8Decoder, utf8Encoder } from './utils'
|
||||
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
@@ -14,22 +18,27 @@ const _authorizationScheme = 'Nostr '
|
||||
export async function getToken(
|
||||
loginUrl: string,
|
||||
httpMethod: string,
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
|
||||
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')
|
||||
const event: EventTemplate = {
|
||||
kind: HTTPAuth,
|
||||
tags: [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod],
|
||||
],
|
||||
created_at: Math.round(new Date().getTime() / 1000),
|
||||
content: '',
|
||||
}
|
||||
|
||||
const event = getBlankEvent(Kind.HttpAuth)
|
||||
|
||||
event.tags = [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod],
|
||||
]
|
||||
event.created_at = Math.round(new Date().getTime() / 1000)
|
||||
if (payload) {
|
||||
event.tags.push(['payload', hashPayload(payload)])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
|
||||
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||
|
||||
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
}
|
||||
|
||||
@@ -43,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
@@ -50,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks an event from a token.
|
||||
*
|
||||
* @param token - The token to unpack.
|
||||
* @returns A promise that resolves to the unpacked event.
|
||||
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||
*/
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
@@ -66,35 +84,123 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
/**
|
||||
* Validates the timestamp of an event.
|
||||
* @param event - The event object to validate.
|
||||
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||
*/
|
||||
export function validateEventTimestamp(event: Event): boolean {
|
||||
if (!event.created_at) {
|
||||
return false
|
||||
}
|
||||
if (!verifySignature(event)) {
|
||||
|
||||
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the kind of an event.
|
||||
* @param event The event to validate.
|
||||
* @returns A boolean indicating whether the event kind is valid.
|
||||
*/
|
||||
export function validateEventKind(event: Event): boolean {
|
||||
return event.kind === HTTPAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given URL matches the URL tag of the event.
|
||||
* @param event - The event object.
|
||||
* @param url - The URL to validate.
|
||||
* @returns A boolean indicating whether the URL is valid or not.
|
||||
*/
|
||||
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
|
||||
if (!urlTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return urlTag.length > 0 && urlTag[1] === url
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given event has a method tag that matches the specified method.
|
||||
* @param event - The event to validate.
|
||||
* @param method - The method to match against the method tag.
|
||||
* @returns A boolean indicating whether the event has a matching method tag.
|
||||
*/
|
||||
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
|
||||
if (!methodTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the hash of a payload.
|
||||
* @param payload - The payload to be hashed.
|
||||
* @returns The hash value as a string.
|
||||
*/
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the event payload tag against the provided payload.
|
||||
* @param event The event object.
|
||||
* @param payload The payload to validate.
|
||||
* @returns A boolean indicating whether the payload tag is valid.
|
||||
*/
|
||||
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
|
||||
if (!payloadTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payloadHash = hashPayload(payload)
|
||||
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Nostr event for the NIP-98 flow.
|
||||
*
|
||||
* @param event - The Nostr event to validate.
|
||||
* @param url - The URL associated with the event.
|
||||
* @param method - The HTTP method associated with the event.
|
||||
* @param body - The request body associated with the event (optional).
|
||||
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||
* @throws An error if the event is invalid.
|
||||
*/
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== Kind.HttpAuth) {
|
||||
|
||||
if (!validateEventKind(event)) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
if (!validateEventTimestamp(event)) {
|
||||
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
if (!validateEventUrlTag(event, url)) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
if (!validateEventMethodTag(event, method)) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||
if (!validateEventPayloadTag(event, body)) {
|
||||
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Event } from './core'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
|
||||
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
|
||||
import { finalizeEvent, generateSecretKey } from './pure'
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid classified listing event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when the "d" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "title" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
// Missing 'title' tag
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "summary" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
// Missing 'summary' tag
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
// Missing 'published_at' tag
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "location" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
// Missing 'location' tag
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
// Missing 'price' tag
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', 'not-a-valid-timestamp'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid price', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', 'not-a-valid-price', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid currency', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'not-a-valid-currency'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['a', 'extra1'],
|
||||
['a', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const event2: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['e', 'extra1'],
|
||||
['e', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
expect(validateEvent(event2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
test('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const expectedListing = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
e: ['value1', 'value2'],
|
||||
a: ['value1', 'value2'],
|
||||
},
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
expect(parseEvent(event)).toEqual(expectedListing)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
test('should generate the correct event template for a classified listing', () => {
|
||||
const listing: ClassifiedListingObject = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
extra1: 'value1',
|
||||
extra2: 'value2',
|
||||
},
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
const expectedEventTemplate = {
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['published_at', '1296962229'],
|
||||
['summary', 'Sample Summary'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['extra1', 'value1'],
|
||||
['extra2', 'value2'],
|
||||
],
|
||||
created_at: expect.any(Number),
|
||||
}
|
||||
|
||||
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents the details of a price.
|
||||
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||
* @example { amount: '100', currency: 'EUR' }
|
||||
*/
|
||||
export type PriceDetails = {
|
||||
/**
|
||||
* The amount of the price.
|
||||
*/
|
||||
amount: string
|
||||
/**
|
||||
* The currency of the price in 3-letter ISO 4217 format.
|
||||
* @example 'USD'
|
||||
*/
|
||||
currency: string
|
||||
/**
|
||||
* The optional frequency of payment.
|
||||
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||
*/
|
||||
frequency?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a classified listing object.
|
||||
*/
|
||||
export type ClassifiedListingObject = {
|
||||
/**
|
||||
* Whether the listing is a draft or not.
|
||||
*/
|
||||
isDraft: boolean
|
||||
/**
|
||||
* A title of the listing.
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* A short summary or tagline.
|
||||
*/
|
||||
summary: string
|
||||
/**
|
||||
* A description in Markdown format.
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Timestamp in unix seconds of when the listing was published.
|
||||
*/
|
||||
publishedAt: string
|
||||
/**
|
||||
* Location of the listing.
|
||||
* @example 'NYC'
|
||||
*/
|
||||
location: string
|
||||
/**
|
||||
* Price details.
|
||||
*/
|
||||
price: PriceDetails
|
||||
/**
|
||||
* Images of the listing with optional dimensions.
|
||||
*/
|
||||
images: Array<{
|
||||
url: string
|
||||
dimensions?: string
|
||||
}>
|
||||
/**
|
||||
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||
*/
|
||||
hashtags: string[]
|
||||
/**
|
||||
* Other standard tags.
|
||||
* @example "g", a geohash for more precise location
|
||||
*/
|
||||
additionalTags: Record<string, string | string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid classified listing event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||
|
||||
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||
const requiredTagCount = requiredTags.length
|
||||
const tagCounts: Record<string, number> = {}
|
||||
|
||||
if (event.tags.length < requiredTagCount) return false
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag.length < 2) return false
|
||||
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'published_at') {
|
||||
const timestamp = parseInt(tagValues[0])
|
||||
if (isNaN(timestamp)) return false
|
||||
} else if (tagName == 'price') {
|
||||
if (tagValues.length < 2) return false
|
||||
|
||||
const price = parseInt(tagValues[0])
|
||||
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (requiredTags.includes(tagName)) {
|
||||
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a classified listing object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The classified listing object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const listing: ClassifiedListingObject = {
|
||||
isDraft: event.kind === DraftClassifiedListing,
|
||||
title: '',
|
||||
summary: '',
|
||||
content: event.content,
|
||||
publishedAt: '',
|
||||
location: '',
|
||||
price: {
|
||||
amount: '',
|
||||
currency: '',
|
||||
},
|
||||
images: [],
|
||||
hashtags: [],
|
||||
additionalTags: {},
|
||||
}
|
||||
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'title') {
|
||||
listing.title = tagValues[0]
|
||||
} else if (tagName == 'summary') {
|
||||
listing.summary = tagValues[0]
|
||||
} else if (tagName == 'published_at') {
|
||||
listing.publishedAt = tagValues[0]
|
||||
} else if (tagName == 'location') {
|
||||
listing.location = tagValues[0]
|
||||
} else if (tagName == 'price') {
|
||||
listing.price.amount = tagValues[0]
|
||||
listing.price.currency = tagValues[1]
|
||||
|
||||
if (tagValues.length == 3) {
|
||||
listing.price.frequency = tagValues[2]
|
||||
}
|
||||
} else if (tagName == 'image') {
|
||||
listing.images.push({
|
||||
url: tagValues[0],
|
||||
dimensions: tagValues?.[1] ?? undefined,
|
||||
})
|
||||
} else if (tagName == 't') {
|
||||
listing.hashtags.push(tagValues[0])
|
||||
} else if (tagName == 'e' || tagName == 'a') {
|
||||
listing.additionalTags[tagName] = [...tagValues]
|
||||
}
|
||||
}
|
||||
|
||||
return listing
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a classified listing object.
|
||||
*
|
||||
* @param listing - The classified listing object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||
['title', listing.title],
|
||||
['published_at', listing.publishedAt],
|
||||
['summary', listing.summary],
|
||||
['location', listing.location],
|
||||
priceTag,
|
||||
]
|
||||
|
||||
for (let i = 0; i < listing.images.length; i++) {
|
||||
const image = listing.images[i]
|
||||
const imageTag = ['image', image.url]
|
||||
if (image.dimensions) imageTag.push(image.dimensions)
|
||||
|
||||
tags.push(imageTag)
|
||||
}
|
||||
|
||||
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||
const t = listing.hashtags[i]
|
||||
|
||||
tags.push(['t', t])
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const val = value[i]
|
||||
|
||||
tags.push([key, val])
|
||||
}
|
||||
} else {
|
||||
tags.push([key, value])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||
content: listing.content,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
128
package.json
128
package.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "1.17.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -19,25 +20,45 @@
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./keys": {
|
||||
"import": "./lib/esm/keys.js",
|
||||
"require": "./lib/cjs/keys.js",
|
||||
"types": "./lib/types/keys.d.ts"
|
||||
"./core": {
|
||||
"import": "./lib/esm/core.js",
|
||||
"require": "./lib/cjs/core.js",
|
||||
"types": "./lib/types/core.d.ts"
|
||||
},
|
||||
"./pure": {
|
||||
"import": "./lib/esm/pure.js",
|
||||
"require": "./lib/cjs/pure.js",
|
||||
"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"
|
||||
},
|
||||
"./event": {
|
||||
"import": "./lib/esm/event.js",
|
||||
"require": "./lib/cjs/event.js",
|
||||
"types": "./lib/types/event.d.ts"
|
||||
},
|
||||
"./filter": {
|
||||
"import": "./lib/esm/filter.js",
|
||||
"require": "./lib/cjs/filter.js",
|
||||
"types": "./lib/types/filter.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",
|
||||
@@ -69,6 +90,11 @@
|
||||
"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",
|
||||
@@ -94,11 +120,6 @@
|
||||
"require": "./lib/cjs/nip25.js",
|
||||
"types": "./lib/types/nip25.d.ts"
|
||||
},
|
||||
"./nip26": {
|
||||
"import": "./lib/esm/nip26.js",
|
||||
"require": "./lib/cjs/nip26.js",
|
||||
"types": "./lib/types/nip26.d.ts"
|
||||
},
|
||||
"./nip27": {
|
||||
"import": "./lib/esm/nip27.js",
|
||||
"require": "./lib/cjs/nip27.js",
|
||||
@@ -109,6 +130,16 @@
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip29": {
|
||||
"import": "./lib/esm/nip29.js",
|
||||
"require": "./lib/cjs/nip29.js",
|
||||
"types": "./lib/types/nip29.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
"types": "./lib/types/nip30.d.ts"
|
||||
},
|
||||
"./nip39": {
|
||||
"import": "./lib/esm/nip39.js",
|
||||
"require": "./lib/cjs/nip39.js",
|
||||
@@ -124,16 +155,46 @@
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip46": {
|
||||
"import": "./lib/esm/nip46.js",
|
||||
"require": "./lib/cjs/nip46.js",
|
||||
"types": "./lib/types/nip46.d.ts"
|
||||
},
|
||||
"./nip49": {
|
||||
"import": "./lib/esm/nip49.js",
|
||||
"require": "./lib/cjs/nip49.js",
|
||||
"types": "./lib/types/nip49.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
"types": "./lib/types/nip57.d.ts"
|
||||
},
|
||||
"./nip75": {
|
||||
"import": "./lib/esm/nip75.js",
|
||||
"require": "./lib/cjs/nip75.js",
|
||||
"types": "./lib/types/nip75.d.ts"
|
||||
},
|
||||
"./nip94": {
|
||||
"import": "./lib/esm/nip94.js",
|
||||
"require": "./lib/cjs/nip94.js",
|
||||
"types": "./lib/types/nip94.d.ts"
|
||||
},
|
||||
"./nip96": {
|
||||
"import": "./lib/esm/nip96.js",
|
||||
"require": "./lib/cjs/nip96.js",
|
||||
"types": "./lib/types/nip96.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
"types": "./lib/types/nip98.d.ts"
|
||||
},
|
||||
"./nip99": {
|
||||
"import": "./lib/esm/nip99.js",
|
||||
"require": "./lib/cjs/nip99.js",
|
||||
"types": "./lib/types/nip99.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
@@ -147,13 +208,16 @@
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/curves": "1.1.0",
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "v0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
@@ -169,31 +233,27 @@
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build && tsc",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.5.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"msw": "^2.1.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build"
|
||||
}
|
||||
}
|
||||
|
||||
151
pool.test.ts
151
pool.test.ts
@@ -1,38 +1,31 @@
|
||||
import 'websocket-polyfill'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { finishEvent, type Event } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
|
||||
import { useWebSocketImplementation } from './relay.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
let relays = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.bg/',
|
||||
'wss://nostr.fmt.wiz.biz/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/',
|
||||
]
|
||||
let pool: SimplePool
|
||||
let mockRelays: MockRelay[]
|
||||
let relayURLs: string[]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
beforeEach(() => {
|
||||
pool = new SimplePool()
|
||||
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
|
||||
relayURLs = mockRelays.map(mr => mr.url)
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
afterEach(() => {
|
||||
pool.close(relayURLs)
|
||||
})
|
||||
|
||||
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 => {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
@@ -42,31 +35,40 @@ test('removing duplicates when querying', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(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(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relayURLs, [{ 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',
|
||||
@@ -76,55 +78,50 @@ test('same with double querying', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
test('query a bunch of events and cancel on eose', async () => {
|
||||
let events = new Set<string>()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
onclose: resolve as any,
|
||||
})
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await pool.list(
|
||||
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||
[
|
||||
{
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
test('querySync()', async () => {
|
||||
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||
|
||||
let events = await pool.querySync(relayURLs, {
|
||||
authors: authors,
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).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)
|
||||
expect(events).toHaveLength(uniqueEventCount)
|
||||
})
|
||||
|
||||
test('seenOnEnabled: false', async () => {
|
||||
const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
|
||||
test('get()', async () => {
|
||||
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||
|
||||
const event = await poolWithoutSeenOn.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
let event = await pool.get(relayURLs, {
|
||||
ids: [ids[0]],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
|
||||
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
|
||||
|
||||
expect(relaysForEvent).toHaveLength(0)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', ids[0])
|
||||
})
|
||||
|
||||
253
pool.ts
253
pool.ts
@@ -1,249 +1,10 @@
|
||||
import { eventsGenerator, relayInit, 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 { matchFilters, mergeFilters, type Filter } from './filter.ts'
|
||||
|
||||
type BatchedRequest = {
|
||||
filters: Filter<any>[]
|
||||
relays: string[]
|
||||
resolve: (events: Event<any>[]) => void
|
||||
events: Event<any>[]
|
||||
}
|
||||
|
||||
export class SimplePool {
|
||||
private _conn: { [url: string]: Relay }
|
||||
private _seenOn: { [id: string]: Set<string> } = {} // a map of all events we've seen in each relay
|
||||
private batchedByKey: { [batchKey: string]: BatchedRequest[] } = {}
|
||||
|
||||
private eoseSubTimeout: number
|
||||
private getTimeout: number
|
||||
private seenOnEnabled: boolean = true
|
||||
private batchInterval: number = 100
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
eoseSubTimeout?: number
|
||||
getTimeout?: number
|
||||
seenOnEnabled?: boolean
|
||||
batchInterval?: number
|
||||
} = {},
|
||||
) {
|
||||
this._conn = {}
|
||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||
this.getTimeout = options.getTimeout || 3400
|
||||
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||
this.batchInterval = options.batchInterval || 100
|
||||
}
|
||||
|
||||
close(relays: string[]): void {
|
||||
relays.forEach(url => {
|
||||
let relay = this._conn[normalizeURL(url)]
|
||||
if (relay) relay.close()
|
||||
})
|
||||
}
|
||||
|
||||
async ensureRelay(url: string): Promise<Relay> {
|
||||
const nm = normalizeURL(url)
|
||||
|
||||
if (!this._conn[nm]) {
|
||||
this._conn[nm] = relayInit(nm, {
|
||||
getTimeout: this.getTimeout * 0.9,
|
||||
listTimeout: this.getTimeout * 0.9,
|
||||
})
|
||||
}
|
||||
|
||||
const relay = this._conn[nm]
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
||||
let _knownIds: Set<string> = new Set()
|
||||
let modifiedOpts = { ...(opts || {}) }
|
||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||
return true
|
||||
}
|
||||
if (this.seenOnEnabled) {
|
||||
let set = this._seenOn[id] || new Set()
|
||||
set.add(url)
|
||||
this._seenOn[id] = set
|
||||
}
|
||||
return _knownIds.has(id)
|
||||
}
|
||||
|
||||
let subs: Sub[] = []
|
||||
let eventListeners: Set<any> = new Set()
|
||||
let eoseListeners: Set<() => void> = new Set()
|
||||
let eosesMissing = relays.length
|
||||
|
||||
let eoseSent = false
|
||||
let eoseTimeout = setTimeout(
|
||||
() => {
|
||||
eoseSent = true
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
},
|
||||
opts?.eoseSubTimeout || this.eoseSubTimeout,
|
||||
)
|
||||
|
||||
relays
|
||||
.filter((r, i, a) => a.indexOf(r) === i)
|
||||
.forEach(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
} catch (err) {
|
||||
handleEose()
|
||||
return
|
||||
}
|
||||
if (!r) return
|
||||
let s = r.sub(filters, modifiedOpts)
|
||||
s.on('event', event => {
|
||||
_knownIds.add(event.id as string)
|
||||
for (let cb of eventListeners.values()) cb(event)
|
||||
})
|
||||
s.on('eose', () => {
|
||||
if (eoseSent) return
|
||||
handleEose()
|
||||
})
|
||||
subs.push(s)
|
||||
|
||||
function handleEose() {
|
||||
eosesMissing--
|
||||
if (eosesMissing === 0) {
|
||||
clearTimeout(eoseTimeout)
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let greaterSub: Sub<K> = {
|
||||
sub(filters, opts) {
|
||||
subs.forEach(sub => sub.sub(filters, opts))
|
||||
return greaterSub as any
|
||||
},
|
||||
unsub() {
|
||||
subs.forEach(sub => sub.unsub())
|
||||
},
|
||||
on(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.add(cb)
|
||||
} else if (type === 'eose') {
|
||||
eoseListeners.add(cb as () => void | Promise<void>)
|
||||
}
|
||||
},
|
||||
off(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.delete(cb)
|
||||
} else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(greaterSub)
|
||||
},
|
||||
}
|
||||
|
||||
return greaterSub
|
||||
}
|
||||
|
||||
get<K extends number = number>(
|
||||
relays: string[],
|
||||
filter: Filter<K>,
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K> | null> {
|
||||
return new Promise(resolve => {
|
||||
let sub = this.sub(relays, [filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
sub.unsub()
|
||||
resolve(null)
|
||||
}, this.getTimeout)
|
||||
sub.on('event', event => {
|
||||
resolve(event)
|
||||
clearTimeout(timeout)
|
||||
sub.unsub()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
list<K extends number = number>(
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
let events: Event<K>[] = []
|
||||
let sub = this.sub(relays, filters, opts)
|
||||
|
||||
sub.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
// we can rely on an eose being emitted here because pool.sub() will fake one
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
resolve(events)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
batchedList<K extends number = number>(
|
||||
batchKey: string,
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
if (!this.batchedByKey[batchKey]) {
|
||||
this.batchedByKey[batchKey] = [
|
||||
{
|
||||
filters,
|
||||
relays,
|
||||
resolve,
|
||||
events: [],
|
||||
},
|
||||
]
|
||||
|
||||
setTimeout(() => {
|
||||
Object.keys(this.batchedByKey).forEach(async batchKey => {
|
||||
const batchedRequests = this.batchedByKey[batchKey]
|
||||
|
||||
const filters = [] as Filter[]
|
||||
const relays = [] as string[]
|
||||
batchedRequests.forEach(br => {
|
||||
filters.push(...br.filters)
|
||||
relays.push(...br.relays)
|
||||
})
|
||||
|
||||
const sub = this.sub(relays, [mergeFilters(...filters)])
|
||||
sub.on('event', event => {
|
||||
batchedRequests.forEach(br => matchFilters(br.filters, event) && br.events.push(event))
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
batchedRequests.forEach(br => br.resolve(br.events))
|
||||
})
|
||||
|
||||
delete this.batchedByKey[batchKey]
|
||||
})
|
||||
}, this.batchInterval)
|
||||
} else {
|
||||
this.batchedByKey[batchKey].push({
|
||||
filters,
|
||||
relays,
|
||||
resolve,
|
||||
events: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event<number>): Promise<void>[] {
|
||||
return relays.map(async relay => {
|
||||
let r = await this.ensureRelay(relay)
|
||||
return r.publish(event)
|
||||
})
|
||||
}
|
||||
|
||||
seenOn(id: string): string[] {
|
||||
return Array.from(this._seenOn[id]?.values?.() || [])
|
||||
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.ts'
|
||||
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: JS = 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,3 +1,4 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { parseReferences } from './references.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
|
||||
194
relay.test.ts
194
relay.test.ts
@@ -1,140 +1,94 @@
|
||||
import 'websocket-polyfill'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { relayInit } from './relay.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay, useWebSocketImplementation } from './relay.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
|
||||
let relay = relayInit('wss://relay.damus.io/')
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
beforeAll(() => {
|
||||
relay.connect()
|
||||
})
|
||||
test('connectivity', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
afterAll(() => {
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity', () => {
|
||||
return expect(
|
||||
new Promise(resolve => {
|
||||
relay.on('connect', () => {
|
||||
resolve(true)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
test('connectivity, with Relay.connect()', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
expect(relay.connected).toBeTrue()
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
test('querying', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
const kind = 0
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
authors: mockRelay.authors,
|
||||
kinds: [kind],
|
||||
},
|
||||
],
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
onevent(event) {
|
||||
expect(mockRelay.authors).toContain(event.pubkey)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
])
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let [t1, t2] = await Promise.all([
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
])
|
||||
|
||||
expect(t1).toEqual(true)
|
||||
expect(t2).toEqual(true)
|
||||
}, 10000)
|
||||
|
||||
test('async iterator', async () => {
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
|
||||
for await (const event of sub.events) {
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
break
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await relay.get({
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
test('listening and publishing and closing', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
})
|
||||
const sk = generateSecretKey()
|
||||
const pk = getPublicKey(sk)
|
||||
const kind = 23571
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await relay.list([
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [kind],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
expect(event).toHaveProperty('content', 'content')
|
||||
|
||||
sub.close() // close the subscription and will trigger onclose()
|
||||
},
|
||||
onclose() {
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(events.length).toEqual(2)
|
||||
})
|
||||
|
||||
test('listening (twice) and publishing', async () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [27572],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', 27572)
|
||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
kind: 27572,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
return expect(
|
||||
Promise.all([
|
||||
new Promise(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolve2 = resolve
|
||||
}),
|
||||
]),
|
||||
).resolves.toEqual([true, true])
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: 'content',
|
||||
created_at: 0,
|
||||
tags: [],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
405
relay.ts
405
relay.ts
@@ -1,398 +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
|
||||
}
|
||||
export type SubEvent<K extends number> = {
|
||||
event: (event: Event<K>) => void | Promise<void>
|
||||
count: (payload: CountPayload) => void | Promise<void>
|
||||
eose: () => void | Promise<void>
|
||||
}
|
||||
export type Relay = {
|
||||
url: string
|
||||
status: number
|
||||
connect: () => Promise<void>
|
||||
close: () => void
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
|
||||
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
|
||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
||||
count: (filters: Filter[], opts?: SubscriptionOptions) => Promise<CountPayload | null>
|
||||
publish: (event: Event<number>) => Promise<void>
|
||||
auth: (event: Event<number>) => Promise<void>
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
|
||||
}
|
||||
export type Sub<K extends number = number> = {
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
||||
unsub: () => void
|
||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
events: AsyncGenerator<Event<K>, void, unknown>
|
||||
/**
|
||||
* @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)
|
||||
eoseSubTimeout?: number
|
||||
}
|
||||
|
||||
const newListeners = (): { [TK in keyof RelayEvent]: RelayEvent[TK][] } => ({
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: [],
|
||||
auth: [],
|
||||
})
|
||||
|
||||
export function relayInit(
|
||||
url: string,
|
||||
options: {
|
||||
getTimeout?: number
|
||||
listTimeout?: number
|
||||
countTimeout?: number
|
||||
} = {},
|
||||
): Relay {
|
||||
let { listTimeout = 3000, getTimeout = 3000, countTimeout = 3000 } = options
|
||||
|
||||
var ws: WebSocket
|
||||
var openSubs: { [id: string]: { filters: Filter[] } & SubscriptionOptions } = {}
|
||||
var listeners = newListeners()
|
||||
var subListeners: {
|
||||
[subid: string]: { [TK in keyof SubEvent<any>]: SubEvent<any>[TK][] }
|
||||
} = {}
|
||||
var pubListeners: {
|
||||
[eventid: string]: {
|
||||
resolve: (_: unknown) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
} = {}
|
||||
|
||||
var connectionPromise: Promise<void> | undefined
|
||||
async function connectRelay(): Promise<void> {
|
||||
if (connectionPromise) return connectionPromise
|
||||
connectionPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
listeners.connect.forEach(cb => cb())
|
||||
resolve()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
connectionPromise = undefined
|
||||
listeners.error.forEach(cb => cb())
|
||||
reject()
|
||||
}
|
||||
ws.onclose = async () => {
|
||||
connectionPromise = undefined
|
||||
listeners.disconnect.forEach(cb => cb())
|
||||
}
|
||||
|
||||
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||
let handleNextInterval: any
|
||||
|
||||
ws.onmessage = e => {
|
||||
incomingMessageQueue.enqueue(e.data)
|
||||
if (!handleNextInterval) {
|
||||
handleNextInterval = setInterval(handleNext, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (incomingMessageQueue.size === 0) {
|
||||
clearInterval(handleNextInterval)
|
||||
handleNextInterval = null
|
||||
return
|
||||
}
|
||||
|
||||
var json = incomingMessageQueue.dequeue()
|
||||
if (!json) return
|
||||
|
||||
let subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
let so = openSubs[subid]
|
||||
if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, 'id'), url)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json)
|
||||
|
||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
let id = data[1]
|
||||
let event = data[2]
|
||||
if (
|
||||
validateEvent(event) &&
|
||||
openSubs[id] &&
|
||||
(openSubs[id].skipVerification || verifySignature(event)) &&
|
||||
matchFilters(openSubs[id].filters, event)
|
||||
) {
|
||||
openSubs[id]
|
||||
;(subListeners[id]?.event || []).forEach(cb => cb(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT':
|
||||
let id = data[1]
|
||||
let payload = data[2]
|
||||
if (openSubs[id]) {
|
||||
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
|
||||
}
|
||||
return
|
||||
case 'EOSE': {
|
||||
let id = data[1]
|
||||
if (id in subListeners) {
|
||||
subListeners[id].eose.forEach(cb => cb())
|
||||
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'OK': {
|
||||
let id: string = data[1]
|
||||
let ok: boolean = data[2]
|
||||
let reason: string = data[3] || ''
|
||||
if (id in pubListeners) {
|
||||
let { resolve, reject } = pubListeners[id]
|
||||
if (ok) resolve(null)
|
||||
else reject(new Error(reason))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'NOTICE':
|
||||
let notice = data[1]
|
||||
listeners.notice.forEach(cb => cb(notice))
|
||||
return
|
||||
case 'AUTH': {
|
||||
let challenge = data[1]
|
||||
listeners.auth?.forEach(cb => cb(challenge))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return connectionPromise
|
||||
export class Relay extends AbstractRelay {
|
||||
constructor(url: string) {
|
||||
super(url, { verifyEvent })
|
||||
}
|
||||
|
||||
function connected() {
|
||||
return ws?.readyState === 1
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (connected()) return // ws already open
|
||||
await connectRelay()
|
||||
}
|
||||
|
||||
async function trySend(params: [string, ...any]) {
|
||||
let msg = JSON.stringify(params)
|
||||
if (!connected()) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
if (!connected()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(msg)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const sub = <K extends number = number>(
|
||||
filters: Filter<K>[],
|
||||
{
|
||||
verb = 'REQ',
|
||||
skipVerification = false,
|
||||
alreadyHaveEvent = null,
|
||||
id = Math.random().toString().slice(2),
|
||||
}: SubscriptionOptions = {},
|
||||
): Sub<K> => {
|
||||
let subid = id
|
||||
|
||||
openSubs[subid] = {
|
||||
id: subid,
|
||||
filters,
|
||||
skipVerification,
|
||||
alreadyHaveEvent,
|
||||
}
|
||||
trySend([verb, subid, ...filters])
|
||||
|
||||
let subscription: Sub<K> = {
|
||||
sub: (newFilters, newOpts = {}) =>
|
||||
sub(newFilters || filters, {
|
||||
skipVerification: newOpts.skipVerification || skipVerification,
|
||||
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
|
||||
id: subid,
|
||||
}),
|
||||
unsub: () => {
|
||||
delete openSubs[subid]
|
||||
delete subListeners[subid]
|
||||
trySend(['CLOSE', subid])
|
||||
},
|
||||
on: (type, cb) => {
|
||||
subListeners[subid] = subListeners[subid] || {
|
||||
event: [],
|
||||
count: [],
|
||||
eose: [],
|
||||
}
|
||||
subListeners[subid][type].push(cb)
|
||||
},
|
||||
off: (type, cb): void => {
|
||||
let listeners = subListeners[subid]
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(subscription)
|
||||
},
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
function _publishEvent(event: Event<number>, type: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!event.id) {
|
||||
reject(new Error(`event ${event} has no id`))
|
||||
return
|
||||
}
|
||||
|
||||
let id = event.id
|
||||
trySend([type, event])
|
||||
pubListeners[id] = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
listeners[type].push(cb)
|
||||
if (type === 'connect' && ws?.readyState === 1) {
|
||||
// i would love to know why we need this
|
||||
;(cb as () => void)()
|
||||
}
|
||||
},
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
let index = listeners[type].indexOf(cb)
|
||||
if (index !== -1) listeners[type].splice(index, 1)
|
||||
},
|
||||
list: (filters, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, opts)
|
||||
let events: Event<any>[] = []
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(events)
|
||||
}, listTimeout)
|
||||
s.on('eose', () => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(events)
|
||||
})
|
||||
s.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
}),
|
||||
get: (filter, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub([filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, getTimeout)
|
||||
s.on('event', event => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
count: (filters: Filter[]): Promise<CountPayload | null> =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, { ...sub, verb: 'COUNT' })
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, countTimeout)
|
||||
s.on('count', (event: CountPayload) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
async publish(event): Promise<void> {
|
||||
await _publishEvent(event, 'EVENT')
|
||||
},
|
||||
async auth(event): Promise<void> {
|
||||
await _publishEvent(event, 'AUTH')
|
||||
},
|
||||
connect,
|
||||
close(): void {
|
||||
listeners = newListeners()
|
||||
subListeners = {}
|
||||
pubListeners = {}
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
get status() {
|
||||
return ws?.readyState ?? 3
|
||||
},
|
||||
static async connect(url: string): Promise<Relay> {
|
||||
const relay = new Relay(url)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
}
|
||||
|
||||
export async function* eventsGenerator<K extends number>(sub: Sub<K>): AsyncGenerator<Event<K>, void, unknown> {
|
||||
let nextResolve: ((event: Event<K>) => void) | undefined
|
||||
const eventQueue: Event<K>[] = []
|
||||
|
||||
const pushToQueue = (event: Event<K>) => {
|
||||
if (nextResolve) {
|
||||
nextResolve(event)
|
||||
nextResolve = undefined
|
||||
} else {
|
||||
eventQueue.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
sub.on('event', pushToQueue)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (eventQueue.length > 0) {
|
||||
yield eventQueue.shift()!
|
||||
} else {
|
||||
const event = await new Promise<Event<K>>(resolve => {
|
||||
nextResolve = resolve
|
||||
})
|
||||
yield event
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sub.off('event', pushToQueue)
|
||||
}
|
||||
}
|
||||
export * from './abstract-relay.ts'
|
||||
|
||||
113
test-helpers.ts
113
test-helpers.ts
@@ -1,12 +1,14 @@
|
||||
import type { Event } from './event.ts'
|
||||
import { Server, WebSocket } from 'mock-socket'
|
||||
|
||||
type EventParams<K extends number> = Partial<Event<K>>
|
||||
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||
export const MockWebSocketClient = WebSocket
|
||||
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1 as K,
|
||||
kind: 1,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
@@ -15,3 +17,104 @@ export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
let serial = 0
|
||||
|
||||
export class MockRelay {
|
||||
private _server: Server
|
||||
|
||||
public url: string
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
this.url = url ?? `wss://random.mock.relay/${serial}`
|
||||
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||
this.preloadedEvents = this.secretKeys.map(sk =>
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
this._server = new Server(this.url)
|
||||
this._server.on('connection', (conn: any) => {
|
||||
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||
|
||||
conn.on('message', (message: string) => {
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data[0]) {
|
||||
case 'REQ': {
|
||||
let subId = data[1]
|
||||
let filters = data.slice(2)
|
||||
subs[subId] = { conn, filters }
|
||||
|
||||
this.preloadedEvents.forEach(event => {
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
|
||||
filters.forEach((filter: Filter) => {
|
||||
const kinds = filter.kinds?.length ? filter.kinds : [1]
|
||||
|
||||
kinds.forEach(kind => {
|
||||
this.secretKeys.forEach(sk => {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
conn.send(JSON.stringify(['EOSE', subId]))
|
||||
|
||||
break
|
||||
}
|
||||
case 'CLOSE': {
|
||||
let subId = data[1]
|
||||
delete subs[subId]
|
||||
|
||||
break
|
||||
}
|
||||
case 'EVENT': {
|
||||
let event = data[1]
|
||||
|
||||
conn.send(JSON.stringify(['OK', event.id, 'true']))
|
||||
|
||||
for (let subId in subs) {
|
||||
const { filters, conn: listener } = subs[subId]
|
||||
|
||||
if (matchFilters(filters, event)) {
|
||||
listener.send(JSON.stringify(['EVENT', subId, event]))
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get authors() {
|
||||
return this.secretKeys.map(getPublicKey)
|
||||
}
|
||||
|
||||
get ids() {
|
||||
return this.preloadedEvents.map(evt => evt.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"outDir": "lib/types",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { MessageQueue, insertEventIntoAscendingList, insertEventIntoDescendingList } from './utils.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 () => {
|
||||
@@ -213,29 +214,27 @@ describe('inserting into a asc sorted list of events', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -245,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])
|
||||
})
|
||||
|
||||
212
utils.ts
212
utils.ts
@@ -1,9 +1,10 @@
|
||||
import type { Event } from './event.ts'
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder: TextEncoder = new TextEncoder()
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
@@ -13,157 +14,104 @@ export function normalizeURL(url: string): string {
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
//
|
||||
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
||||
//
|
||||
export function insertEventIntoDescendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at < sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at >= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return b.created_at - event.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
let position = start
|
||||
|
||||
if (end < 0) {
|
||||
position = 0
|
||||
} else if (event.created_at > sortedArray[end].created_at) {
|
||||
position = end + 1
|
||||
} else if (event.created_at <= sortedArray[start].created_at) {
|
||||
position = start
|
||||
} else
|
||||
while (true) {
|
||||
if (end <= start + 1) {
|
||||
position = end
|
||||
break
|
||||
}
|
||||
midPoint = Math.floor(start + (end - start) / 2)
|
||||
if (sortedArray[midPoint].created_at < event.created_at) {
|
||||
start = midPoint
|
||||
} else if (sortedArray[midPoint].created_at > event.created_at) {
|
||||
end = midPoint
|
||||
} else {
|
||||
// aMidPoint === num
|
||||
position = midPoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event): Event[] {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
return event.created_at - b.created_at
|
||||
})
|
||||
if (!found) {
|
||||
sortedArray.splice(idx, 0, event)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
if (cmp < 0) {
|
||||
end = mid - 1
|
||||
} else {
|
||||
start = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
constructor(message: string) {
|
||||
this._value = message
|
||||
this._next = null
|
||||
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