mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -45,7 +45,6 @@
|
|||||||
"curly": [0, "multi-line"],
|
"curly": [0, "multi-line"],
|
||||||
"dot-location": [2, "property"],
|
"dot-location": [2, "property"],
|
||||||
"eol-last": 2,
|
"eol-last": 2,
|
||||||
"eqeqeq": [2, "allow-null"],
|
|
||||||
"handle-callback-err": [2, "^(err|error)$"],
|
"handle-callback-err": [2, "^(err|error)$"],
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
"jsx-quotes": [2, "prefer-double"],
|
"jsx-quotes": [2, "prefer-double"],
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ package-lock.json
|
|||||||
.envrc
|
.envrc
|
||||||
lib
|
lib
|
||||||
test.html
|
test.html
|
||||||
|
bench.js
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -21,7 +21,7 @@ If using TypeScript, this package requires TypeScript >= 5.0.
|
|||||||
```js
|
```js
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
let sk = generateSecretKey() // `sk` is a hex string
|
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ let isGood = verifyEvent(event)
|
|||||||
### Interacting with a relay
|
### Interacting with a relay
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { relayConnect, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
const relay = await relayConnect('wss://relay.example.com')
|
const relay = await Relay.connect('wss://relay.example.com')
|
||||||
console.log(`connected to ${relay.url}`)
|
console.log(`connected to ${relay.url}`)
|
||||||
|
|
||||||
// let's query for an event that exists
|
// let's query for an event that exists
|
||||||
@@ -66,18 +66,18 @@ const sub = relay.subscribe([
|
|||||||
let sk = generateSecretKey()
|
let sk = generateSecretKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
let sub = relay.sub([
|
relay.sub([
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
])
|
], {
|
||||||
|
onevent(event) {
|
||||||
sub.on('event', event => {
|
console.log('got event:', event)
|
||||||
console.log('got event:', event)
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let eventTemplate = {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -85,14 +85,9 @@ let event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||||
const signedEvent = finalizeEvent(event, sk)
|
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||||
await relay.publish(signedEvent)
|
await relay.publish(signedEvent)
|
||||||
|
|
||||||
let events = await relay.list([{ kinds: [0, 1] }])
|
|
||||||
let event = await relay.get({
|
|
||||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
|
||||||
})
|
|
||||||
|
|
||||||
relay.close()
|
relay.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -210,6 +205,8 @@ Importing the entirety of `nostr-tools` may bloat your build, so you should prob
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||||
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
|
import { Relay, Subscription } from 'nostr-tools/relay'
|
||||||
import { matchFilter } from 'nostr-tools/filter'
|
import { matchFilter } from 'nostr-tools/filter'
|
||||||
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
||||||
// and so on and so forth
|
// and so on and so forth
|
||||||
@@ -230,7 +227,36 @@ initNostrWasm().then(setNostrWasm)
|
|||||||
// see https://www.npmjs.com/package/nostr-wasm for options
|
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||||
```
|
```
|
||||||
|
|
||||||
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`.
|
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||||
|
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
|
initNostrWasm().then(setNostrWasm)
|
||||||
|
|
||||||
|
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||||
|
const pool = new AbstractSimplePool({ verifyEvent })
|
||||||
|
```
|
||||||
|
|
||||||
|
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
|
||||||
|
|
||||||
|
```
|
||||||
|
benchmark time (avg) (min … max) p75 p99 p995
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
• relay read message and verify event (many events)
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
|
||||||
|
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
|
||||||
|
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
|
||||||
|
|
||||||
|
summary for relay read message and verify event
|
||||||
|
wasm
|
||||||
|
86.77x slower than trusted
|
||||||
|
6.86x faster than pure js
|
||||||
|
```
|
||||||
|
|
||||||
### Using from the browser (if you don't want to use a bundler)
|
### Using from the browser (if you don't want to use a bundler)
|
||||||
|
|
||||||
|
|||||||
190
abstract-pool.ts
Normal file
190
abstract-pool.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||||
|
import { normalizeURL } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event, Nostr } from './core.ts'
|
||||||
|
import { type Filter } from './filter.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
export type SubCloser = { close: () => void }
|
||||||
|
|
||||||
|
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||||
|
maxWait?: number
|
||||||
|
onclose?: (reasons: string[]) => void
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractSimplePool {
|
||||||
|
private relays = new Map<string, AbstractRelay>()
|
||||||
|
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||||
|
public trackRelays: boolean = false
|
||||||
|
|
||||||
|
public verifyEvent: Nostr['verifyEvent']
|
||||||
|
public trustedRelayURLs = new Set<string>()
|
||||||
|
|
||||||
|
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||||
|
url = normalizeURL(url)
|
||||||
|
|
||||||
|
let relay = this.relays.get(url)
|
||||||
|
if (!relay) {
|
||||||
|
relay = new AbstractRelay(url, {
|
||||||
|
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||||
|
})
|
||||||
|
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||||
|
this.relays.set(url, relay)
|
||||||
|
}
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
close(relays: string[]) {
|
||||||
|
relays.map(normalizeURL).forEach(url => {
|
||||||
|
this.relays.get(url)?.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||||
|
if (this.trackRelays) {
|
||||||
|
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||||
|
let set = this.seenOn.get(id)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.seenOn.set(id, set)
|
||||||
|
}
|
||||||
|
set.add(relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _knownIds = new Set<string>()
|
||||||
|
const subs: Subscription[] = []
|
||||||
|
|
||||||
|
// batch all EOSEs into a single
|
||||||
|
const eosesReceived: boolean[] = []
|
||||||
|
let handleEose = (i: number) => {
|
||||||
|
eosesReceived[i] = true
|
||||||
|
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.oneose?.()
|
||||||
|
handleEose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// batch all closes into a single
|
||||||
|
const closesReceived: string[] = []
|
||||||
|
let handleClose = (i: number, reason: string) => {
|
||||||
|
handleEose(i)
|
||||||
|
closesReceived[i] = reason
|
||||||
|
if (closesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.onclose?.(closesReceived)
|
||||||
|
handleClose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAlreadyHaveEventHandler = (id: string) => {
|
||||||
|
if (params.alreadyHaveEvent?.(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const have = _knownIds.has(id)
|
||||||
|
_knownIds.add(id)
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
// open a subscription in all given relays
|
||||||
|
const allOpened = Promise.all(
|
||||||
|
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
handleClose(i, 'duplicate url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay: AbstractRelay
|
||||||
|
try {
|
||||||
|
relay = await this.ensureRelay(url, {
|
||||||
|
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
handleClose(i, (err as any)?.message || String(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = relay.subscribe(filters, {
|
||||||
|
...params,
|
||||||
|
oneose: () => handleEose(i),
|
||||||
|
onclose: reason => handleClose(i, reason),
|
||||||
|
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||||
|
eoseTimeout: params.maxWait,
|
||||||
|
})
|
||||||
|
|
||||||
|
subs.push(subscription)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
async close() {
|
||||||
|
await allOpened
|
||||||
|
subs.forEach(sub => {
|
||||||
|
sub.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeManyEose(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter[],
|
||||||
|
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||||
|
): SubCloser {
|
||||||
|
const subcloser = this.subscribeMany(relays, filters, {
|
||||||
|
...params,
|
||||||
|
oneose() {
|
||||||
|
subcloser.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return subcloser
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySync(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
const events: Event[] = []
|
||||||
|
this.subscribeManyEose(relays, [filter], {
|
||||||
|
...params,
|
||||||
|
onevent(event: Event) {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
onclose(_: string[]) {
|
||||||
|
resolve(events)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event | null> {
|
||||||
|
filter.limit = 1
|
||||||
|
const events = await this.querySync(relays, filter, params)
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
return events[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(relays: string[], event: Event): Promise<string>[] {
|
||||||
|
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
return Promise.reject('duplicate url')
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = await this.ensureRelay(url)
|
||||||
|
return r.publish(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
359
abstract-relay.ts
Normal file
359
abstract-relay.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/* global WebSocket */
|
||||||
|
|
||||||
|
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||||
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
|
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||||
|
import { Queue, normalizeURL } from './utils.ts'
|
||||||
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
|
import { yieldThread } from './helpers.ts'
|
||||||
|
|
||||||
|
export class AbstractRelay {
|
||||||
|
public readonly url: string
|
||||||
|
private _connected: boolean = false
|
||||||
|
|
||||||
|
public onclose: (() => void) | null = null
|
||||||
|
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||||
|
|
||||||
|
public baseEoseTimeout: number = 4400
|
||||||
|
public connectionTimeout: number = 4400
|
||||||
|
public openSubs = new Map<string, Subscription>()
|
||||||
|
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
private connectionPromise: Promise<void> | undefined
|
||||||
|
private openCountRequests = new Map<string, CountResolver>()
|
||||||
|
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||||
|
private ws: WebSocket | undefined
|
||||||
|
private incomingMessageQueue = new Queue<string>()
|
||||||
|
private queueRunning = false
|
||||||
|
private challenge: string | undefined
|
||||||
|
private serial: number = 0
|
||||||
|
private verifyEvent: Nostr['verifyEvent']
|
||||||
|
|
||||||
|
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.url = normalizeURL(url)
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
const relay = new AbstractRelay(url, opts)
|
||||||
|
await relay.connect()
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeAllSubscriptions(reason: string) {
|
||||||
|
for (let [_, sub] of this.openSubs) {
|
||||||
|
sub.close(reason)
|
||||||
|
}
|
||||||
|
this.openSubs.clear()
|
||||||
|
|
||||||
|
for (let [_, ep] of this.openEventPublishes) {
|
||||||
|
ep.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openEventPublishes.clear()
|
||||||
|
|
||||||
|
for (let [_, cr] of this.openCountRequests) {
|
||||||
|
cr.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openCountRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
public get connected(): boolean {
|
||||||
|
return this._connected
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (this.connectionPromise) return this.connectionPromise
|
||||||
|
|
||||||
|
this.challenge = undefined
|
||||||
|
this.connectionPromise = new Promise((resolve, reject) => {
|
||||||
|
this.connectionTimeoutHandle = setTimeout(() => {
|
||||||
|
reject('connection timed out')
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection timed out')
|
||||||
|
}, this.connectionTimeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
this._connected = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = ev => {
|
||||||
|
reject((ev as any).message)
|
||||||
|
if (this._connected) {
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection errored')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = async () => {
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection closed')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = this._onmessage.bind(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.connectionPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runQueue() {
|
||||||
|
this.queueRunning = true
|
||||||
|
while (true) {
|
||||||
|
if (false === this.handleNext()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await yieldThread()
|
||||||
|
}
|
||||||
|
this.queueRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNext(): undefined | false {
|
||||||
|
const json = this.incomingMessageQueue.dequeue()
|
||||||
|
if (!json) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const subid = getSubscriptionId(json)
|
||||||
|
if (subid) {
|
||||||
|
const so = this.openSubs.get(subid as string)
|
||||||
|
if (!so) {
|
||||||
|
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// this will be called only when this message is a EVENT message for a subscription we have
|
||||||
|
// we do this before parsing the JSON to not have to do that for duplicate events
|
||||||
|
// since JSON parsing is slow
|
||||||
|
const id = getHex64(json, 'id')
|
||||||
|
const alreadyHave = so.alreadyHaveEvent?.(id)
|
||||||
|
|
||||||
|
// notify any interested client that the relay has this event
|
||||||
|
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
||||||
|
so.receivedEvent?.(this, id)
|
||||||
|
|
||||||
|
if (alreadyHave) {
|
||||||
|
// if we had already seen this event we can just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(json)
|
||||||
|
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||||
|
// will naturally be caught by the encompassing try..catch block
|
||||||
|
|
||||||
|
switch (data[0]) {
|
||||||
|
case 'EVENT': {
|
||||||
|
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||||
|
const event = data[2] as Event
|
||||||
|
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
|
||||||
|
so.onevent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'COUNT': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const payload = data[2] as { count: number }
|
||||||
|
const cr = this.openCountRequests.get(id) as CountResolver
|
||||||
|
if (cr) {
|
||||||
|
cr.resolve(payload.count)
|
||||||
|
this.openCountRequests.delete(id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'EOSE': {
|
||||||
|
const so = this.openSubs.get(data[1] as string)
|
||||||
|
if (!so) return
|
||||||
|
so.receivedEose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'OK': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const ok: boolean = data[2]
|
||||||
|
const reason: string = data[3]
|
||||||
|
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||||
|
if (ok) ep.resolve(reason)
|
||||||
|
else ep.reject(new Error(reason))
|
||||||
|
this.openEventPublishes.delete(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'CLOSED': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const so = this.openSubs.get(id)
|
||||||
|
if (!so) return
|
||||||
|
so.closed = true
|
||||||
|
so.close(data[2] as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'NOTICE':
|
||||||
|
this.onnotice(data[1] as string)
|
||||||
|
return
|
||||||
|
case 'AUTH': {
|
||||||
|
this.challenge = data[1] as string
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(message: string) {
|
||||||
|
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||||
|
|
||||||
|
this.connectionPromise.then(() => {
|
||||||
|
this.ws?.send(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||||
|
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
|
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async publish(event: Event): Promise<string> {
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
||||||
|
this.serial++
|
||||||
|
const id = params?.id || 'count:' + this.serial
|
||||||
|
const ret = new Promise<number>((resolve, reject) => {
|
||||||
|
this.openCountRequests.set(id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||||
|
const subscription = this.prepareSubscription(filters, params)
|
||||||
|
subscription.fire()
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||||
|
this.serial++
|
||||||
|
const id = params.id || 'sub:' + this.serial
|
||||||
|
const subscription = new Subscription(this, id, filters, params)
|
||||||
|
this.openSubs.set(id, subscription)
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.closeAllSubscriptions('relay connection closed by us')
|
||||||
|
this._connected = false
|
||||||
|
this.ws?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is the function assigned to this.ws.onmessage
|
||||||
|
// it's exposed for testing and debugging purposes
|
||||||
|
public _onmessage(ev: MessageEvent<any>) {
|
||||||
|
this.incomingMessageQueue.enqueue(ev.data as string)
|
||||||
|
if (!this.queueRunning) {
|
||||||
|
this.runQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Subscription {
|
||||||
|
public readonly relay: AbstractRelay
|
||||||
|
public readonly id: string
|
||||||
|
|
||||||
|
public closed: boolean = false
|
||||||
|
public eosed: boolean = false
|
||||||
|
public filters: Filter[]
|
||||||
|
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
||||||
|
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
|
||||||
|
|
||||||
|
public onevent: (evt: Event) => void
|
||||||
|
public oneose: (() => void) | undefined
|
||||||
|
public onclose: ((reason: string) => void) | undefined
|
||||||
|
|
||||||
|
public eoseTimeout: number
|
||||||
|
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||||
|
this.relay = relay
|
||||||
|
this.filters = filters
|
||||||
|
this.id = id
|
||||||
|
this.alreadyHaveEvent = params.alreadyHaveEvent
|
||||||
|
this.receivedEvent = params.receivedEvent
|
||||||
|
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
||||||
|
|
||||||
|
this.oneose = params.oneose
|
||||||
|
this.onclose = params.onclose
|
||||||
|
this.onevent =
|
||||||
|
params.onevent ||
|
||||||
|
(event => {
|
||||||
|
console.warn(
|
||||||
|
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public fire() {
|
||||||
|
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
||||||
|
|
||||||
|
// only now we start counting the eoseTimeout
|
||||||
|
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
public receivedEose() {
|
||||||
|
if (this.eosed) return
|
||||||
|
clearTimeout(this.eoseTimeoutHandle)
|
||||||
|
this.eosed = true
|
||||||
|
this.oneose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(reason: string = 'closed by caller') {
|
||||||
|
if (!this.closed) {
|
||||||
|
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||||
|
// otherwise this._open will be already set to false so we will skip this
|
||||||
|
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||||
|
this.closed = true
|
||||||
|
}
|
||||||
|
this.relay.openSubs.delete(this.id)
|
||||||
|
this.onclose?.(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionParams = {
|
||||||
|
onevent?: (evt: Event) => void
|
||||||
|
oneose?: () => void
|
||||||
|
onclose?: (reason: string) => void
|
||||||
|
alreadyHaveEvent?: (id: string) => boolean
|
||||||
|
receivedEvent?: (relay: AbstractRelay, id: string) => void
|
||||||
|
eoseTimeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountResolver = {
|
||||||
|
resolve: (count: number) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPublishResolver = {
|
||||||
|
resolve: (reason: string) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
61
benchmarks.ts
Normal file
61
benchmarks.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { run, bench, group, baseline } from 'mitata'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
import { NostrEvent } from './core'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
import { setNostrWasm, verifyEvent } from './wasm'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
import { Relay as PureRelay } from './relay.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
// benchmarking relay reads with verifyEvent
|
||||||
|
const EVENTS = 200
|
||||||
|
let messages: string[] = []
|
||||||
|
let baseContent = ''
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
baseContent += 'a'
|
||||||
|
}
|
||||||
|
const secretKey = generateSecretKey()
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
const tags = []
|
||||||
|
for (let t = 0; t < i; t++) {
|
||||||
|
tags.push(['t', 'nada'])
|
||||||
|
}
|
||||||
|
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
|
||||||
|
const signed = finalizeEvent(event, secretKey)
|
||||||
|
messages.push(JSON.stringify(['EVENT', '_', signed]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setNostrWasm(await initNostrWasm())
|
||||||
|
|
||||||
|
const pureRelay = new PureRelay('wss://pure.com/')
|
||||||
|
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
|
||||||
|
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
|
||||||
|
|
||||||
|
const runWith = (relay: AbstractRelay) => async () => {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
let received = 0
|
||||||
|
let sub = relay.prepareSubscription([{}], {
|
||||||
|
id: '_',
|
||||||
|
onevent(_: NostrEvent) {
|
||||||
|
received++
|
||||||
|
if (received === messages.length - 1) {
|
||||||
|
resolve()
|
||||||
|
sub.closed = true
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (let e = 0; e < messages.length; e++) {
|
||||||
|
relay._onmessage({ data: messages[e] } as any)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
group(`relay read ${EVENTS} messages and verify its events`, () => {
|
||||||
|
baseline('wasm', runWith(wasmRelay))
|
||||||
|
bench('pure js', runWith(pureRelay))
|
||||||
|
bench('trusted', runWith(trustedRelay))
|
||||||
|
})
|
||||||
|
|
||||||
|
// actually running the thing
|
||||||
|
await run()
|
||||||
15
build.js
15
build.js
@@ -10,6 +10,7 @@ const entryPoints = fs
|
|||||||
file !== 'core.ts' &&
|
file !== 'core.ts' &&
|
||||||
file !== 'test-helpers.ts' &&
|
file !== 'test-helpers.ts' &&
|
||||||
file !== 'helpers.ts' &&
|
file !== 'helpers.ts' &&
|
||||||
|
file !== 'benchmarks.ts' &&
|
||||||
!file.endsWith('.test.ts') &&
|
!file.endsWith('.test.ts') &&
|
||||||
fs.statSync(join(process.cwd(), file)).isFile(),
|
fs.statSync(join(process.cwd(), file)).isFile(),
|
||||||
)
|
)
|
||||||
@@ -27,12 +28,7 @@ esbuild
|
|||||||
format: 'esm',
|
format: 'esm',
|
||||||
packages: 'external',
|
packages: 'external',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => console.log('esm build success.'))
|
||||||
const packageJson = JSON.stringify({ type: 'module' })
|
|
||||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
|
||||||
|
|
||||||
console.log('esm build success.')
|
|
||||||
})
|
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
@@ -41,7 +37,12 @@ esbuild
|
|||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
packages: 'external',
|
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
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
|
|||||||
1
core.ts
1
core.ts
@@ -19,6 +19,7 @@ export interface Event {
|
|||||||
[verifiedSymbol]?: boolean
|
[verifiedSymbol]?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NostrEvent = Event
|
||||||
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||||
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||||
import { buildEvent } from './test-helpers.ts'
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
@@ -241,4 +241,27 @@ describe('Filter', () => {
|
|||||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
18
filter.ts
18
filter.ts
@@ -1,4 +1,5 @@
|
|||||||
import { Event } from './pure.ts'
|
import { Event } from './core.ts'
|
||||||
|
import { isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
|||||||
|
|
||||||
return result
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
16
helpers.ts
16
helpers.ts
@@ -1,9 +1,21 @@
|
|||||||
|
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||||
|
|
||||||
export async function yieldThread() {
|
export async function yieldThread() {
|
||||||
return new Promise(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
const ch = new MessageChannel()
|
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`)
|
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||||
ch.port1.addEventListener('message', resolve)
|
ch.port1.addEventListener('message', handler)
|
||||||
ch.port2.postMessage(0)
|
ch.port2.postMessage(0)
|
||||||
ch.port1.start()
|
ch.port1.start()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
|
||||||
|
t[verifiedSymbol] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
3
index.ts
3
index.ts
@@ -1,6 +1,5 @@
|
|||||||
export * from './pure.ts'
|
export * from './pure.ts'
|
||||||
export * from './relay.ts'
|
export * from './relay.ts'
|
||||||
export * from './pure.ts'
|
|
||||||
export * from './filter.ts'
|
export * from './filter.ts'
|
||||||
export * from './pool.ts'
|
export * from './pool.ts'
|
||||||
export * from './references.ts'
|
export * from './references.ts'
|
||||||
@@ -19,9 +18,11 @@ export * as nip28 from './nip28.ts'
|
|||||||
export * as nip30 from './nip30.ts'
|
export * as nip30 from './nip30.ts'
|
||||||
export * as nip39 from './nip39.ts'
|
export * as nip39 from './nip39.ts'
|
||||||
export * as nip42 from './nip42.ts'
|
export * as nip42 from './nip42.ts'
|
||||||
|
export * as nip44 from './nip44.ts'
|
||||||
export * as nip47 from './nip47.ts'
|
export * as nip47 from './nip47.ts'
|
||||||
export * as nip57 from './nip57.ts'
|
export * as nip57 from './nip57.ts'
|
||||||
export * as nip98 from './nip98.ts'
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
|
export * as kinds from './kinds.ts'
|
||||||
export * as fj from './fakejson.ts'
|
export * as fj from './fakejson.ts'
|
||||||
export * as utils from './utils.ts'
|
export * as utils from './utils.ts'
|
||||||
|
|||||||
27
justfile
27
justfile
@@ -1,25 +1,32 @@
|
|||||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||||
|
|
||||||
build:
|
build:
|
||||||
rm -rf lib
|
rm -rf lib
|
||||||
bun run build.js
|
bun run build.js
|
||||||
|
|
||||||
test:
|
test:
|
||||||
bun test --timeout 20000
|
bun test --timeout 20000
|
||||||
|
|
||||||
test-only file:
|
test-only file:
|
||||||
bun test {{file}}
|
bun test {{file}}
|
||||||
|
|
||||||
emit-types:
|
emit-types:
|
||||||
tsc # see tsconfig.json
|
tsc # see tsconfig.json
|
||||||
|
|
||||||
publish: build emit-types
|
publish: build emit-types
|
||||||
npm publish
|
npm publish
|
||||||
|
|
||||||
format:
|
format:
|
||||||
eslint --ext .ts --fix *.ts
|
eslint --ext .ts --fix *.ts
|
||||||
prettier --write *.ts
|
prettier --write *.ts
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
eslint --ext .ts *.ts
|
eslint --ext .ts *.ts
|
||||||
prettier --check *.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
kinds.ts
18
kinds.ts
@@ -35,27 +35,22 @@ export const ShortTextNote = 1
|
|||||||
export const RecommendRelay = 2
|
export const RecommendRelay = 2
|
||||||
export const Contacts = 3
|
export const Contacts = 3
|
||||||
export const EncryptedDirectMessage = 4
|
export const EncryptedDirectMessage = 4
|
||||||
|
export const EncryptedDirectMessages = 4
|
||||||
export const EventDeletion = 5
|
export const EventDeletion = 5
|
||||||
export const Repost = 6
|
export const Repost = 6
|
||||||
export const Reaction = 7
|
export const Reaction = 7
|
||||||
export const BadgeAward = 8
|
export const BadgeAward = 8
|
||||||
|
export const GenericRepost = 16
|
||||||
export const ChannelCreation = 40
|
export const ChannelCreation = 40
|
||||||
export const ChannelMetadata = 41
|
export const ChannelMetadata = 41
|
||||||
export const ChannelMessage = 42
|
export const ChannelMessage = 42
|
||||||
export const ChannelHideMessage = 43
|
export const ChannelHideMessage = 43
|
||||||
export const ChannelMuteUser = 44
|
export const ChannelMuteUser = 44
|
||||||
export const Report = 1984
|
|
||||||
export const ZapRequest = 9734
|
|
||||||
export const Zap = 9735
|
|
||||||
export const RelayList = 10002
|
|
||||||
export const ClientAuth = 22242
|
|
||||||
export const BadgeDefinition = 30009
|
|
||||||
export const FileMetadata = 1063
|
|
||||||
export const EncryptedDirectMessages = 4
|
|
||||||
export const GenericRepost = 16
|
|
||||||
export const OpenTimestamps = 1040
|
export const OpenTimestamps = 1040
|
||||||
|
export const FileMetadata = 1063
|
||||||
export const LiveChatMessage = 1311
|
export const LiveChatMessage = 1311
|
||||||
export const ProblemTracker = 1971
|
export const ProblemTracker = 1971
|
||||||
|
export const Report = 1984
|
||||||
export const Reporting = 1984
|
export const Reporting = 1984
|
||||||
export const Label = 1985
|
export const Label = 1985
|
||||||
export const CommunityPostApproval = 4550
|
export const CommunityPostApproval = 4550
|
||||||
@@ -63,9 +58,12 @@ export const JobRequest = 5999
|
|||||||
export const JobResult = 6999
|
export const JobResult = 6999
|
||||||
export const JobFeedback = 7000
|
export const JobFeedback = 7000
|
||||||
export const ZapGoal = 9041
|
export const ZapGoal = 9041
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export const Zap = 9735
|
||||||
export const Highlights = 9802
|
export const Highlights = 9802
|
||||||
export const Mutelist = 10000
|
export const Mutelist = 10000
|
||||||
export const Pinlist = 10001
|
export const Pinlist = 10001
|
||||||
|
export const RelayList = 10002
|
||||||
export const BookmarkList = 10003
|
export const BookmarkList = 10003
|
||||||
export const CommunitiesList = 10004
|
export const CommunitiesList = 10004
|
||||||
export const PublicChatsList = 10005
|
export const PublicChatsList = 10005
|
||||||
@@ -75,6 +73,7 @@ export const InterestsList = 10015
|
|||||||
export const UserEmojiList = 10030
|
export const UserEmojiList = 10030
|
||||||
export const NWCWalletInfo = 13194
|
export const NWCWalletInfo = 13194
|
||||||
export const LightningPubRPC = 21000
|
export const LightningPubRPC = 21000
|
||||||
|
export const ClientAuth = 22242
|
||||||
export const NWCWalletRequest = 23194
|
export const NWCWalletRequest = 23194
|
||||||
export const NWCWalletResponse = 23195
|
export const NWCWalletResponse = 23195
|
||||||
export const NostrConnect = 24133
|
export const NostrConnect = 24133
|
||||||
@@ -85,6 +84,7 @@ export const Relaysets = 30002
|
|||||||
export const Bookmarksets = 30003
|
export const Bookmarksets = 30003
|
||||||
export const Curationsets = 30004
|
export const Curationsets = 30004
|
||||||
export const ProfileBadges = 30008
|
export const ProfileBadges = 30008
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
export const Interestsets = 30015
|
export const Interestsets = 30015
|
||||||
export const CreateOrUpdateStall = 30017
|
export const CreateOrUpdateStall = 30017
|
||||||
export const CreateOrUpdateProduct = 30018
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
|||||||
2
nip10.ts
2
nip10.ts
@@ -1,4 +1,4 @@
|
|||||||
import type { Event } from './pure.ts'
|
import type { Event } from './core.ts'
|
||||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
export type NIP10Result = {
|
export type NIP10Result = {
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ test('encode and decode naddr', () => {
|
|||||||
test('encode and decode nevent', () => {
|
test('encode and decode nevent', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -95,13 +95,13 @@ test('encode and decode nevent', () => {
|
|||||||
test('encode and decode nevent with kind 0', () => {
|
test('encode and decode nevent with kind 0', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -109,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
|
|||||||
expect(pointer.kind).toEqual(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', () => {
|
test('decode naddr from habla.news', () => {
|
||||||
let { type, data } = decode(
|
let { type, data } = decode(
|
||||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||||
|
|||||||
19
nip19.ts
19
nip19.ts
@@ -149,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
while (rest.length > 0) {
|
while (rest.length > 0) {
|
||||||
let t = rest[0]
|
let t = rest[0]
|
||||||
let l = rest[1]
|
let l = rest[1]
|
||||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
|
||||||
let v = rest.slice(2, 2 + l)
|
let v = rest.slice(2, 2 + l)
|
||||||
rest = rest.slice(2 + l)
|
rest = rest.slice(2 + l)
|
||||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
@@ -227,15 +226,17 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
|
|||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
let entries: Uint8Array[] = []
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
Object.entries(tlv).forEach(([t, vs]) => {
|
Object.entries(tlv)
|
||||||
vs.forEach(v => {
|
.reverse()
|
||||||
let entry = new Uint8Array(v.length + 2)
|
.forEach(([t, vs]) => {
|
||||||
entry.set([parseInt(t)], 0)
|
vs.forEach(v => {
|
||||||
entry.set([v.length], 1)
|
let entry = new Uint8Array(v.length + 2)
|
||||||
entry.set(v, 2)
|
entry.set([parseInt(t)], 0)
|
||||||
entries.push(entry)
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
return concatBytes(...entries)
|
return concatBytes(...entries)
|
||||||
}
|
}
|
||||||
|
|||||||
60
nip29.ts
Normal file
60
nip29.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Event } from './pure'
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
picture?: string
|
||||||
|
about?: string
|
||||||
|
relay?: string
|
||||||
|
public?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroup(event: Event): Group {
|
||||||
|
const chan: Partial<Group> = {}
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
switch (tag[0]) {
|
||||||
|
case 'd':
|
||||||
|
chan.id = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
chan.name = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'about':
|
||||||
|
chan.about = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'picture':
|
||||||
|
chan.picture = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
chan.open = true
|
||||||
|
break
|
||||||
|
case 'public':
|
||||||
|
chan.public = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chan 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
|
||||||
|
}
|
||||||
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,10 +1,10 @@
|
|||||||
import { test, expect } from 'bun:test'
|
import { test, expect } from 'bun:test'
|
||||||
|
|
||||||
import { makeAuthEvent } from './nip42.ts'
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
import { relayConnect } from './relay.ts'
|
import { Relay } from './relay.ts'
|
||||||
|
|
||||||
test('auth flow', () => {
|
test('auth flow', async () => {
|
||||||
const relay = relayConnect('wss://nostr.wine')
|
const relay = await Relay.connect('wss://nostr.wine')
|
||||||
|
|
||||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||||
expect(auth.tags).toHaveLength(2)
|
expect(auth.tags).toHaveLength(2)
|
||||||
|
|||||||
2
nip42.ts
2
nip42.ts
@@ -1,4 +1,4 @@
|
|||||||
import { EventTemplate } from './pure.ts'
|
import { EventTemplate } from './core.ts'
|
||||||
import { ClientAuth } from './kinds.ts'
|
import { ClientAuth } from './kinds.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
44
nip44.test.ts
Normal file
44
nip44.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { v2 } from './nip44.js'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||||
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
|
const v2vec = vec.v2
|
||||||
|
|
||||||
|
test('get_conversation_key', () => {
|
||||||
|
for (const v of v2vec.valid.get_conversation_key) {
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encrypt_decrypt', () => {
|
||||||
|
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||||
|
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||||
|
expect(ciphertext).toEqual(v.payload)
|
||||||
|
const decrypted = v2.decrypt(ciphertext, key)
|
||||||
|
expect(decrypted).toEqual(v.plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calc_padded_len', () => {
|
||||||
|
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||||
|
const actual = v2.utils.calcPaddedLen(len)
|
||||||
|
expect(actual).toEqual(shouldBePaddedTo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt', async () => {
|
||||||
|
for (const v of v2vec.invalid.decrypt) {
|
||||||
|
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get_conversation_key', async () => {
|
||||||
|
for (const v of v2vec.invalid.get_conversation_key) {
|
||||||
|
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
|
||||||
|
}
|
||||||
|
})
|
||||||
131
nip44.ts
Normal file
131
nip44.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { chacha20 } from '@noble/ciphers/chacha'
|
||||||
|
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
|
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||||
|
import { hmac } from '@noble/hashes/hmac'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const u = {
|
||||||
|
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||||
|
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||||
|
|
||||||
|
utf8Encode: utf8ToBytes,
|
||||||
|
utf8Decode(bytes: Uint8Array) {
|
||||||
|
return decoder.decode(bytes)
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||||
|
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||||
|
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||||
|
ensureBytes(conversationKey, 32)
|
||||||
|
ensureBytes(nonce, 32)
|
||||||
|
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||||
|
return {
|
||||||
|
chacha_key: keys.subarray(0, 32),
|
||||||
|
chacha_nonce: keys.subarray(32, 44),
|
||||||
|
hmac_key: keys.subarray(44, 76),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
calcPaddedLen(len: number): number {
|
||||||
|
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||||
|
if (len <= 32) return 32
|
||||||
|
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||||
|
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||||
|
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
writeU16BE(num: number) {
|
||||||
|
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||||
|
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||||
|
const arr = new Uint8Array(2)
|
||||||
|
new DataView(arr.buffer).setUint16(0, num, false)
|
||||||
|
return arr
|
||||||
|
},
|
||||||
|
|
||||||
|
pad(plaintext: string): Uint8Array {
|
||||||
|
const unpadded = u.utf8Encode(plaintext)
|
||||||
|
const unpaddedLen = unpadded.length
|
||||||
|
const prefix = u.writeU16BE(unpaddedLen)
|
||||||
|
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||||
|
return concatBytes(prefix, unpadded, suffix)
|
||||||
|
},
|
||||||
|
|
||||||
|
unpad(padded: Uint8Array): string {
|
||||||
|
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||||
|
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||||
|
if (
|
||||||
|
unpaddedLen < u.minPlaintextSize ||
|
||||||
|
unpaddedLen > u.maxPlaintextSize ||
|
||||||
|
unpadded.length !== unpaddedLen ||
|
||||||
|
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||||
|
)
|
||||||
|
throw new Error('invalid padding')
|
||||||
|
return u.utf8Decode(unpadded)
|
||||||
|
},
|
||||||
|
|
||||||
|
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||||
|
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||||
|
const combined = concatBytes(aad, message)
|
||||||
|
return hmac(sha256, key, combined)
|
||||||
|
},
|
||||||
|
|
||||||
|
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||||
|
// plaintext: 1b to 0xffff
|
||||||
|
// padded plaintext: 32b to 0xffff
|
||||||
|
// ciphertext: 32b+2 to 0xffff+2
|
||||||
|
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||||
|
// compressed payload (base64): 132b to 87472b
|
||||||
|
decodePayload(payload: string) {
|
||||||
|
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||||
|
const plen = payload.length
|
||||||
|
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||||
|
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||||
|
let data: Uint8Array
|
||||||
|
try {
|
||||||
|
data = base64.decode(payload)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('invalid base64: ' + (error as any).message)
|
||||||
|
}
|
||||||
|
const dlen = data.length
|
||||||
|
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||||
|
const vers = data[0]
|
||||||
|
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||||
|
return {
|
||||||
|
nonce: data.subarray(1, 33),
|
||||||
|
ciphertext: data.subarray(33, -32),
|
||||||
|
mac: data.subarray(-32),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const padded = u.pad(plaintext)
|
||||||
|
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||||
|
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||||
|
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||||
|
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||||
|
return u.unpad(padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const v2 = {
|
||||||
|
utils: u,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { v2 }
|
||||||
605
nip44.vectors.json
Normal file
605
nip44.vectors.json
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
{
|
||||||
|
"v2": {
|
||||||
|
"valid": {
|
||||||
|
"get_conversation_key": [
|
||||||
|
{
|
||||||
|
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
|
||||||
|
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
|
||||||
|
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
|
||||||
|
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
|
||||||
|
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
|
||||||
|
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
|
||||||
|
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
|
||||||
|
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
|
||||||
|
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
|
||||||
|
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
|
||||||
|
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
|
||||||
|
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
|
||||||
|
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
|
||||||
|
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
|
||||||
|
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
|
||||||
|
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
|
||||||
|
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
|
||||||
|
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
|
||||||
|
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
|
||||||
|
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
|
||||||
|
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
|
||||||
|
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
|
||||||
|
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
|
||||||
|
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
|
||||||
|
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
|
||||||
|
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
|
||||||
|
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
|
||||||
|
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
|
||||||
|
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
|
||||||
|
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
|
||||||
|
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
|
||||||
|
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
|
||||||
|
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
|
||||||
|
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
|
||||||
|
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
|
||||||
|
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
|
||||||
|
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
|
||||||
|
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
|
||||||
|
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
|
||||||
|
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
|
||||||
|
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
|
||||||
|
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
|
||||||
|
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
|
||||||
|
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
|
||||||
|
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
|
||||||
|
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
|
||||||
|
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
|
||||||
|
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
|
||||||
|
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
|
||||||
|
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
|
||||||
|
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
|
||||||
|
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
|
||||||
|
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
|
||||||
|
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
|
||||||
|
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
|
||||||
|
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
|
||||||
|
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
|
||||||
|
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
|
||||||
|
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
|
||||||
|
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
|
||||||
|
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
|
||||||
|
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
|
||||||
|
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
|
||||||
|
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
|
||||||
|
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||||
|
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
|
||||||
|
"note": "sec1 = n-2, pub2: random, 0x02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
|
||||||
|
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
|
||||||
|
"note": "sec1 = 2, pub2: rand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||||
|
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
|
||||||
|
"note": "sec1 == pub2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"get_message_keys": {
|
||||||
|
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
|
||||||
|
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
|
||||||
|
"chacha_nonce": "c4ad129bb01180c0933a160c",
|
||||||
|
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
|
||||||
|
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
|
||||||
|
"chacha_nonce": "22925e920cee4a50a478be90",
|
||||||
|
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
|
||||||
|
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
|
||||||
|
"chacha_nonce": "d3594987af769a52904656ac",
|
||||||
|
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
|
||||||
|
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
|
||||||
|
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
|
||||||
|
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
|
||||||
|
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
|
||||||
|
"chacha_nonce": "400224ba67fc2f1b76736916",
|
||||||
|
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
|
||||||
|
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
|
||||||
|
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
|
||||||
|
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
|
||||||
|
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
|
||||||
|
"chacha_nonce": "72f69a5a5f795465cee59da8",
|
||||||
|
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
|
||||||
|
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
|
||||||
|
"chacha_nonce": "6e69be92d61c04a276021565",
|
||||||
|
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
|
||||||
|
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
|
||||||
|
"chacha_nonce": "3dda53569cfcb7fac1805c35",
|
||||||
|
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
|
||||||
|
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
|
||||||
|
"chacha_nonce": "65064239186e50304cc0f156",
|
||||||
|
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
|
||||||
|
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
|
||||||
|
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
|
||||||
|
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
|
||||||
|
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
|
||||||
|
"chacha_nonce": "cbb2530ea653766e5a37a83a",
|
||||||
|
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
|
||||||
|
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
|
||||||
|
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
|
||||||
|
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
|
||||||
|
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
|
||||||
|
"chacha_nonce": "67803605a7e5010d0f63f8c8",
|
||||||
|
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
|
||||||
|
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
|
||||||
|
"chacha_nonce": "4e62a0073087ed808be62469",
|
||||||
|
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
|
||||||
|
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
|
||||||
|
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
|
||||||
|
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
|
||||||
|
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
|
||||||
|
"chacha_nonce": "329bb3024461e84b2e1c489b",
|
||||||
|
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
|
||||||
|
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
|
||||||
|
"chacha_nonce": "653d759042b85194d4d8c0a7",
|
||||||
|
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
|
||||||
|
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
|
||||||
|
"chacha_nonce": "b822e2c959df32b3cb772a7c",
|
||||||
|
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
|
||||||
|
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
|
||||||
|
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
|
||||||
|
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
|
||||||
|
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
|
||||||
|
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
|
||||||
|
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
|
||||||
|
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
|
||||||
|
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
|
||||||
|
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
|
||||||
|
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
|
||||||
|
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
|
||||||
|
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
|
||||||
|
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
|
||||||
|
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
|
||||||
|
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
|
||||||
|
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
|
||||||
|
"chacha_nonce": "263830a065af33d9c6c5aa1f",
|
||||||
|
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
|
||||||
|
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
|
||||||
|
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
|
||||||
|
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
|
||||||
|
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
|
||||||
|
"chacha_nonce": "ccdaad5b3b7645be430992eb",
|
||||||
|
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
|
||||||
|
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
|
||||||
|
"chacha_nonce": "ed02dece5fc3a186f123420b",
|
||||||
|
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
|
||||||
|
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
|
||||||
|
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
|
||||||
|
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
|
||||||
|
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
|
||||||
|
"chacha_nonce": "26b450612ca5e905b937e147",
|
||||||
|
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
|
||||||
|
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
|
||||||
|
"chacha_nonce": "4a296a1fb0048e5020d3b129",
|
||||||
|
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
|
||||||
|
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
|
||||||
|
"chacha_nonce": "a8188daff807a1182200b39d",
|
||||||
|
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"calc_padded_len": [
|
||||||
|
[16, 32],
|
||||||
|
[32, 32],
|
||||||
|
[33, 64],
|
||||||
|
[37, 64],
|
||||||
|
[45, 64],
|
||||||
|
[49, 64],
|
||||||
|
[64, 64],
|
||||||
|
[65, 96],
|
||||||
|
[100, 128],
|
||||||
|
[111, 128],
|
||||||
|
[200, 224],
|
||||||
|
[250, 256],
|
||||||
|
[320, 320],
|
||||||
|
[383, 384],
|
||||||
|
[384, 384],
|
||||||
|
[400, 448],
|
||||||
|
[500, 512],
|
||||||
|
[512, 512],
|
||||||
|
[515, 640],
|
||||||
|
[700, 768],
|
||||||
|
[800, 896],
|
||||||
|
[900, 1024],
|
||||||
|
[1020, 1024],
|
||||||
|
[65536, 65536]
|
||||||
|
],
|
||||||
|
"encrypt_decrypt": [
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||||
|
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"plaintext": "a",
|
||||||
|
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||||
|
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||||
|
"plaintext": "🍕🫃",
|
||||||
|
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||||
|
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||||
|
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
|
||||||
|
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||||
|
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||||
|
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
|
||||||
|
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
|
||||||
|
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
|
||||||
|
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
|
||||||
|
"plaintext": "ability🤝的 ȺȾ",
|
||||||
|
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
|
||||||
|
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
|
||||||
|
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
|
||||||
|
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
|
||||||
|
"plaintext": "pepper👀їжак",
|
||||||
|
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||||
|
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||||
|
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
|
||||||
|
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||||
|
"plaintext": "( ͡° ͜ʖ ͡°)",
|
||||||
|
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
|
||||||
|
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
|
||||||
|
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
|
||||||
|
"plaintext": "الكل في المجمو عة (5)",
|
||||||
|
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
|
||||||
|
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
|
||||||
|
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||||
|
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||||
|
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"encrypt_decrypt_long_msg": [
|
||||||
|
{
|
||||||
|
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
|
||||||
|
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"letter": "ф",
|
||||||
|
"repeat": 65535,
|
||||||
|
"payload_checksum_sha256": "",
|
||||||
|
"note": "фффф... (65535 times)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"invalid": {
|
||||||
|
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
|
||||||
|
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
|
||||||
|
"get_conversation_key": [
|
||||||
|
{
|
||||||
|
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 higher than curve.n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 is 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||||
|
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
"note": "pub2 is invalid, no sqrt, all-ff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 == curve.n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "pub2 is invalid, no sqrt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"note": "pub2 is point of order 3 on twist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
|
||||||
|
"note": "pub2 is point of order 13 on twist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
|
||||||
|
"note": "pub2 is point of order 3319 on twist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decrypt": [
|
||||||
|
{
|
||||||
|
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||||
|
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||||
|
"plaintext": "n o b l e",
|
||||||
|
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
|
||||||
|
"note": "unknown encryption version"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
|
||||||
|
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
|
||||||
|
"plaintext": "⚠️",
|
||||||
|
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
|
||||||
|
"note": "unknown encryption version 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||||
|
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||||
|
"plaintext": "n o s t r",
|
||||||
|
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
|
||||||
|
"note": "invalid base64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
|
||||||
|
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
|
||||||
|
"plaintext": "¯\\_(ツ)_/¯",
|
||||||
|
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"note": "invalid MAC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
|
||||||
|
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
|
||||||
|
"plaintext": "🥎",
|
||||||
|
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
|
||||||
|
"note": "invalid MAC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
|
||||||
|
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
|
||||||
|
"plaintext": "elliptic-curve cryptography",
|
||||||
|
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
|
||||||
|
"note": "invalid padding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
|
||||||
|
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
|
||||||
|
"plaintext": "noble",
|
||||||
|
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
|
||||||
|
"note": "invalid padding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
|
||||||
|
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
|
||||||
|
"plaintext": "censorship-resistant and global social network",
|
||||||
|
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
|
||||||
|
"note": "invalid padding"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,10 +242,11 @@ describe('validateZapRequest', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('makeZapReceipt', () => {
|
describe('makeZapReceipt', () => {
|
||||||
test('returns a valid Zap receipt with a preimage', () => {
|
const privateKey = generateSecretKey()
|
||||||
const privateKey = generateSecretKey()
|
const publicKey = getPublicKey(privateKey)
|
||||||
const publicKey = getPublicKey(privateKey)
|
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt with a preimage', () => {
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -253,7 +254,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -274,16 +275,14 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
['preimage', preimage],
|
['preimage', preimage],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns a valid Zap receipt without a preimage', () => {
|
test('returns a valid Zap receipt without a preimage', () => {
|
||||||
const privateKey = generateSecretKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -291,7 +290,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -311,7 +310,8 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||||
|
|||||||
4
nip57.ts
4
nip57.ts
@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
|||||||
lnurl = utf8Decoder.decode(data)
|
lnurl = utf8Decoder.decode(data)
|
||||||
} else if (lud16) {
|
} else if (lud16) {
|
||||||
let [name, domain] = lud16.split('@')
|
let [name, domain] = lud16.split('@')
|
||||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export function makeZapReceipt({
|
|||||||
kind: 9735,
|
kind: 9735,
|
||||||
created_at: Math.round(paidAt.getTime() / 1000),
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preimage) {
|
if (preimage) {
|
||||||
|
|||||||
397
nip98.test.ts
397
nip98.test.ts
@@ -1,76 +1,83 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
|
||||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
|
||||||
import { Event, finalizeEvent } from './pure.ts'
|
|
||||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
import { utf8Encoder } from './utils.ts'
|
|
||||||
import { bytesToHex } from '@noble/hashes/utils'
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { HTTPAuth } from './kinds.ts'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
const sk = generateSecretKey()
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
hashPayload,
|
||||||
|
unpackEventFromToken,
|
||||||
|
validateEvent,
|
||||||
|
validateEventKind,
|
||||||
|
validateEventMethodTag,
|
||||||
|
validateEventPayloadTag,
|
||||||
|
validateEventTimestamp,
|
||||||
|
validateEventUrlTag,
|
||||||
|
validateToken,
|
||||||
|
} from './nip98.ts'
|
||||||
|
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
describe('getToken', () => {
|
describe('getToken', () => {
|
||||||
test('getToken GET returns without authorization scheme', async () => {
|
test('returns without authorization scheme for GET', async () => {
|
||||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'get'],
|
['method', 'get'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken POST returns token without authorization scheme', async () => {
|
test('returns token without authorization scheme for POST', async () => {
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
test('returns token WITH authorization scheme for POST', async () => {
|
||||||
const authorizationScheme = 'Nostr '
|
const authorizationScheme = 'Nostr '
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
|
||||||
expect(decodedResult.content).toBe('')
|
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
test('returns token with a valid payload tag when payload is present', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
const payload = { test: 'payload' }
|
const payload = { test: 'payload' }
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
const payloadHash = hashPayload(payload)
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
['payload', payloadHash],
|
['payload', payloadHash],
|
||||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('validateToken', () => {
|
describe('validateToken', () => {
|
||||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
test('returns true for valid token without authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
|
||||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
expect(result).toBe(true)
|
expect(isTokenValid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
test('returns true for valid token with authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
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(isTokenValid).toBe(true)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for invalid token', async () => {
|
test('throws an error for invalid token', async () => {
|
||||||
const result = validateToken('fake', 'http://test.com', 'get')
|
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for missing token', async () => {
|
test('throws an error for missing token', async () => {
|
||||||
const result = validateToken('', 'http://test.com', 'get')
|
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong url', async () => {
|
test('throws an error for invalid event kind', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.kind = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong method', async () => {
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.created_at = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
test('throws an error for invalid url', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
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(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong url', async () => {
|
test('throws an error for invalid method', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||||
|
|
||||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong method', async () => {
|
describe('validateEvent', () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
expect(result).rejects.toThrow(Error)
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
})
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
})
|
||||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
expect(result).toBe(true)
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
})
|
unpackedEvent.kind = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
|
||||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
})
|
||||||
|
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
expect(result).rejects.toThrow(Error)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventTimestamp', () => {
|
||||||
|
test('returns true for valid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventKind', () => {
|
||||||
|
test('returns true for valid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventUrlTag', () => {
|
||||||
|
test('returns true for valid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventMethodTag', () => {
|
||||||
|
test('returns true for valid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventPayloadTag', () => {
|
||||||
|
test('returns true for valid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for missing payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPayload', () => {
|
||||||
|
test('returns hash for valid payload', async () => {
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns hash for empty payload', async () => {
|
||||||
|
const payload = {}
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
141
nip98.ts
141
nip98.ts
@@ -1,17 +1,13 @@
|
|||||||
import { bytesToHex } from '@noble/hashes/utils'
|
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { base64 } from '@scure/base'
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
import { HTTPAuth } from './kinds.ts'
|
|
||||||
|
|
||||||
const _authorizationScheme = 'Nostr '
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
export function hashPayload(payload: any): string {
|
|
||||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
|
||||||
return bytesToHex(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate token for NIP-98 flow.
|
* Generate token for NIP-98 flow.
|
||||||
*
|
*
|
||||||
@@ -37,7 +33,7 @@ export async function getToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
event.tags.push(['payload', hashPayload(payload)])
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedEvent = await sign(event)
|
const signedEvent = await sign(event)
|
||||||
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
|||||||
const event = await unpackEventFromToken(token).catch(error => {
|
const event = await unpackEventFromToken(token).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
|
||||||
const valid = await validateEvent(event, url, method).catch(error => {
|
const valid = await validateEvent(event, url, method).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
|||||||
return valid
|
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> {
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Missing token')
|
throw new Error('Missing token')
|
||||||
}
|
}
|
||||||
|
|
||||||
token = token.replace(_authorizationScheme, '')
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
/**
|
||||||
if (!event) {
|
* Validates the timestamp of an event.
|
||||||
throw new Error('Invalid nostr event')
|
* @param event - The event object to validate.
|
||||||
|
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||||
|
*/
|
||||||
|
export function validateEventTimestamp(event: Event): boolean {
|
||||||
|
if (!event.created_at) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the kind of an event.
|
||||||
|
* @param event The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event kind is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventKind(event: Event): boolean {
|
||||||
|
return event.kind === HTTPAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given URL matches the URL tag of the event.
|
||||||
|
* @param event - The event object.
|
||||||
|
* @param url - The URL to validate.
|
||||||
|
* @returns A boolean indicating whether the URL is valid or not.
|
||||||
|
*/
|
||||||
|
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||||
|
|
||||||
|
if (!urlTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlTag.length > 0 && urlTag[1] === url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given event has a method tag that matches the specified method.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @param method - The method to match against the method tag.
|
||||||
|
* @returns A boolean indicating whether the event has a matching method tag.
|
||||||
|
*/
|
||||||
|
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||||
|
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||||
|
|
||||||
|
if (!methodTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the hash of a payload.
|
||||||
|
* @param payload - The payload to be hashed.
|
||||||
|
* @returns The hash value as a string.
|
||||||
|
*/
|
||||||
|
export function hashPayload(payload: any): string {
|
||||||
|
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||||
|
return bytesToHex(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the event payload tag against the provided payload.
|
||||||
|
* @param event The event object.
|
||||||
|
* @param payload The payload to validate.
|
||||||
|
* @returns A boolean indicating whether the payload tag is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||||
|
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||||
|
|
||||||
|
if (!payloadTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Nostr event for the NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @param event - The Nostr event to validate.
|
||||||
|
* @param url - The URL associated with the event.
|
||||||
|
* @param method - The HTTP method associated with the event.
|
||||||
|
* @param body - The request body associated with the event (optional).
|
||||||
|
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||||
|
* @throws An error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||||
if (!verifyEvent(event)) {
|
if (!verifyEvent(event)) {
|
||||||
throw new Error('Invalid nostr event, signature invalid')
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
}
|
}
|
||||||
if (event.kind !== HTTPAuth) {
|
|
||||||
|
if (!validateEventKind(event)) {
|
||||||
throw new Error('Invalid nostr event, kind invalid')
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.created_at) {
|
if (!validateEventTimestamp(event)) {
|
||||||
throw new Error('Invalid nostr event, created_at invalid')
|
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event must be less than 60 seconds old
|
if (!validateEventUrlTag(event, url)) {
|
||||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
|
||||||
throw new Error('Invalid nostr event, expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
|
||||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
|
||||||
throw new Error('Invalid nostr event, url tag invalid')
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
if (!validateEventMethodTag(event, method)) {
|
||||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
|
||||||
throw new Error('Invalid nostr event, method tag invalid')
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
if (!validateEventPayloadTag(event, body)) {
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||||
if (payloadTag?.[1] !== payloadHash) {
|
|
||||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Event } from './core'
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
36
package.json
36
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "2.0.1",
|
"version": "2.1.4",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -29,16 +30,31 @@
|
|||||||
"require": "./lib/cjs/wasm.js",
|
"require": "./lib/cjs/wasm.js",
|
||||||
"types": "./lib/types/wasm.d.ts"
|
"types": "./lib/types/wasm.d.ts"
|
||||||
},
|
},
|
||||||
|
"./kinds": {
|
||||||
|
"import": "./lib/esm/kinds.js",
|
||||||
|
"require": "./lib/cjs/kinds.js",
|
||||||
|
"types": "./lib/types/kinds.d.ts"
|
||||||
|
},
|
||||||
"./filter": {
|
"./filter": {
|
||||||
"import": "./lib/esm/filter.js",
|
"import": "./lib/esm/filter.js",
|
||||||
"require": "./lib/cjs/filter.js",
|
"require": "./lib/cjs/filter.js",
|
||||||
"types": "./lib/types/filter.d.ts"
|
"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": {
|
"./relay": {
|
||||||
"import": "./lib/esm/relay.js",
|
"import": "./lib/esm/relay.js",
|
||||||
"require": "./lib/cjs/relay.js",
|
"require": "./lib/cjs/relay.js",
|
||||||
"types": "./lib/types/relay.d.ts"
|
"types": "./lib/types/relay.d.ts"
|
||||||
},
|
},
|
||||||
|
"./abstract-pool": {
|
||||||
|
"import": "./lib/esm/abstract-pool.js",
|
||||||
|
"require": "./lib/cjs/abstract-pool.js",
|
||||||
|
"types": "./lib/types/abstract-pool.d.ts"
|
||||||
|
},
|
||||||
"./pool": {
|
"./pool": {
|
||||||
"import": "./lib/esm/pool.js",
|
"import": "./lib/esm/pool.js",
|
||||||
"require": "./lib/cjs/pool.js",
|
"require": "./lib/cjs/pool.js",
|
||||||
@@ -54,6 +70,11 @@
|
|||||||
"require": "./lib/cjs/nip04.js",
|
"require": "./lib/cjs/nip04.js",
|
||||||
"types": "./lib/types/nip04.d.ts"
|
"types": "./lib/types/nip04.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip44": {
|
||||||
|
"import": "./lib/esm/nip44.js",
|
||||||
|
"require": "./lib/cjs/nip44.js",
|
||||||
|
"types": "./lib/types/nip44.d.ts"
|
||||||
|
},
|
||||||
"./nip05": {
|
"./nip05": {
|
||||||
"import": "./lib/esm/nip05.js",
|
"import": "./lib/esm/nip05.js",
|
||||||
"require": "./lib/cjs/nip05.js",
|
"require": "./lib/cjs/nip05.js",
|
||||||
@@ -109,6 +130,11 @@
|
|||||||
"require": "./lib/cjs/nip28.js",
|
"require": "./lib/cjs/nip28.js",
|
||||||
"types": "./lib/types/nip28.d.ts"
|
"types": "./lib/types/nip28.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip29": {
|
||||||
|
"import": "./lib/esm/nip29.js",
|
||||||
|
"require": "./lib/cjs/nip29.js",
|
||||||
|
"types": "./lib/types/nip29.d.ts"
|
||||||
|
},
|
||||||
"./nip30": {
|
"./nip30": {
|
||||||
"import": "./lib/esm/nip30.js",
|
"import": "./lib/esm/nip30.js",
|
||||||
"require": "./lib/cjs/nip30.js",
|
"require": "./lib/cjs/nip30.js",
|
||||||
@@ -152,8 +178,10 @@
|
|||||||
"@noble/hashes": "1.3.1",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "1.3.1",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "1.2.1",
|
"@scure/bip39": "1.2.1"
|
||||||
"nostr-wasm": "v0.0.3"
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"nostr-wasm": "v0.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
@@ -183,6 +211,8 @@
|
|||||||
"eslint-plugin-babel": "^5.3.1",
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
"esm-loader-typescript": "^1.0.3",
|
"esm-loader-typescript": "^1.0.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"mitata": "^0.1.6",
|
||||||
|
"mock-socket": "^9.3.1",
|
||||||
"node-fetch": "^2.6.9",
|
"node-fetch": "^2.6.9",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"tsd": "^0.22.0",
|
"tsd": "^0.22.0",
|
||||||
|
|||||||
48
pool.test.ts
48
pool.test.ts
@@ -3,23 +3,27 @@ import { test, expect, afterAll } from 'bun:test'
|
|||||||
import { finalizeEvent, type Event } from './pure.ts'
|
import { finalizeEvent, type Event } from './pure.ts'
|
||||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
import { SimplePool } from './pool.ts'
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
let pool = new SimplePool()
|
let pool = new SimplePool()
|
||||||
|
|
||||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
let mockRelays = [newMockRelay(), newMockRelay(), newMockRelay(), newMockRelay()]
|
||||||
|
let relays = mockRelays.map(mr => mr.url)
|
||||||
|
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||||
|
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
pool.close(relays)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('removing duplicates when querying', async () => {
|
test('removing duplicates when subscribing', async () => {
|
||||||
let priv = generateSecretKey()
|
let priv = generateSecretKey()
|
||||||
let pub = getPublicKey(priv)
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||||
onevent(event: Event) {
|
onevent(event: Event) {
|
||||||
// this should be called only once even though we're listening
|
// this should be called only once even though we're listening
|
||||||
// to multiple relays because the events will be catched and
|
// to multiple relays because the events will be caught and
|
||||||
// deduplicated efficiently (without even being parsed)
|
// deduplicated efficiently (without even being parsed)
|
||||||
received.push(event)
|
received.push(event)
|
||||||
},
|
},
|
||||||
@@ -43,7 +47,7 @@ test('removing duplicates when querying', async () => {
|
|||||||
expect(received[0]).toEqual(event)
|
expect(received[0]).toEqual(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('same with double querying', async () => {
|
test('same with double subs', async () => {
|
||||||
let priv = generateSecretKey()
|
let priv = generateSecretKey()
|
||||||
let pub = getPublicKey(priv)
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
@@ -76,12 +80,32 @@ test('same with double querying', async () => {
|
|||||||
expect(received).toHaveLength(2)
|
expect(received).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('querySync()', async () => {
|
test('query a bunch of events and cancel on eose', async () => {
|
||||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
let events = new Set<string>()
|
||||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
await new Promise<void>(resolve => {
|
||||||
kinds: [1],
|
pool.subscribeManyEose(
|
||||||
limit: 2,
|
[...relays, ...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||||
|
[{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
events.add(event.id)
|
||||||
|
},
|
||||||
|
onclose: resolve as any,
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
expect(events.size).toBeGreaterThan(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('querySync()', async () => {
|
||||||
|
let events = await pool.querySync(
|
||||||
|
[...relays.slice(0, 2), ...relays.slice(0, 2), 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||||
|
{
|
||||||
|
authors: authors.slice(0, 2),
|
||||||
|
kinds: [1],
|
||||||
|
limit: 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// the actual received number will be greater than 2, but there will be no duplicates
|
// the actual received number will be greater than 2, but there will be no duplicates
|
||||||
expect(events.length).toBeGreaterThan(2)
|
expect(events.length).toBeGreaterThan(2)
|
||||||
@@ -91,9 +115,9 @@ test('querySync()', async () => {
|
|||||||
|
|
||||||
test('get()', async () => {
|
test('get()', async () => {
|
||||||
let event = await pool.get(relays, {
|
let event = await pool.get(relays, {
|
||||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
ids: [ids[0]],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(event).not.toBeNull()
|
expect(event).not.toBeNull()
|
||||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
})
|
})
|
||||||
|
|||||||
187
pool.ts
187
pool.ts
@@ -1,183 +1,10 @@
|
|||||||
import { Relay, SubscriptionParams, Subscription } from './relay.ts'
|
import { verifyEvent } from './pure.ts'
|
||||||
import { normalizeURL } from './utils.ts'
|
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||||
|
|
||||||
import type { Event } from './pure.ts'
|
export class SimplePool extends AbstractSimplePool {
|
||||||
import { type Filter } from './filter.ts'
|
constructor() {
|
||||||
|
super({ verifyEvent })
|
||||||
export type SubCloser = { close: () => void }
|
|
||||||
|
|
||||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
|
||||||
maxWait?: number
|
|
||||||
onclose?: (reasons: string[]) => void
|
|
||||||
id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SimplePool {
|
|
||||||
private relays = new Map<string, Relay>()
|
|
||||||
public seenOn = new Map<string, Set<Relay>>()
|
|
||||||
public trackRelays: boolean = false
|
|
||||||
|
|
||||||
public trustedRelayURLs = new Set<string>()
|
|
||||||
|
|
||||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<Relay> {
|
|
||||||
url = normalizeURL(url)
|
|
||||||
|
|
||||||
let relay = this.relays.get(url)
|
|
||||||
if (!relay) {
|
|
||||||
relay = new Relay(url)
|
|
||||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
|
||||||
if (this.trustedRelayURLs.has(relay.url)) relay.trusted = true
|
|
||||||
this.relays.set(url, relay)
|
|
||||||
await relay.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
return relay
|
|
||||||
}
|
|
||||||
|
|
||||||
close(relays: string[]) {
|
|
||||||
relays.map(normalizeURL).forEach(url => {
|
|
||||||
this.relays.get(url)?.close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
|
||||||
if (this.trackRelays) {
|
|
||||||
params.receivedEvent = (relay: Relay, id: string) => {
|
|
||||||
let set = this.seenOn.get(id)
|
|
||||||
if (!set) {
|
|
||||||
set = new Set()
|
|
||||||
this.seenOn.set(id, set)
|
|
||||||
}
|
|
||||||
set.add(relay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _knownIds = new Set<string>()
|
|
||||||
const subs: Subscription[] = []
|
|
||||||
|
|
||||||
// batch all EOSEs into a single
|
|
||||||
const eosesReceived: boolean[] = []
|
|
||||||
let handleEose = (i: number) => {
|
|
||||||
eosesReceived[i] = true
|
|
||||||
if (eosesReceived.filter(a => a).length === relays.length) {
|
|
||||||
params.oneose?.()
|
|
||||||
handleEose = () => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// batch all closes into a single
|
|
||||||
const closesReceived: string[] = []
|
|
||||||
let handleClose = (i: number, reason: string) => {
|
|
||||||
handleEose(i)
|
|
||||||
closesReceived[i] = reason
|
|
||||||
if (closesReceived.filter(a => a).length === relays.length) {
|
|
||||||
params.onclose?.(closesReceived)
|
|
||||||
handleClose = () => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localAlreadyHaveEventHandler = (id: string) => {
|
|
||||||
if (params.alreadyHaveEvent?.(id)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const have = _knownIds.has(id)
|
|
||||||
_knownIds.add(id)
|
|
||||||
return have
|
|
||||||
}
|
|
||||||
|
|
||||||
// open a subscription in all given relays
|
|
||||||
const allOpened = Promise.all(
|
|
||||||
relays.map(normalizeURL).map(async (url, i, arr) => {
|
|
||||||
if (arr.indexOf(url) !== i) {
|
|
||||||
// duplicate
|
|
||||||
handleClose(i, 'duplicate url')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let relay: Relay
|
|
||||||
try {
|
|
||||||
relay = await this.ensureRelay(url, {
|
|
||||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
handleClose(i, (err as any)?.message || String(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let subscription = relay.subscribe(filters, {
|
|
||||||
...params,
|
|
||||||
oneose: () => handleEose(i),
|
|
||||||
onclose: reason => handleClose(i, reason),
|
|
||||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
|
||||||
eoseTimeout: params.maxWait,
|
|
||||||
})
|
|
||||||
|
|
||||||
subs.push(subscription)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
async close() {
|
|
||||||
await allOpened
|
|
||||||
subs.forEach(sub => {
|
|
||||||
sub.close()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeManyEose(
|
|
||||||
relays: string[],
|
|
||||||
filters: Filter[],
|
|
||||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
|
||||||
): SubCloser {
|
|
||||||
const subcloser = this.subscribeMany(relays, filters, {
|
|
||||||
...params,
|
|
||||||
oneose() {
|
|
||||||
subcloser.close()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return subcloser
|
|
||||||
}
|
|
||||||
|
|
||||||
async querySync(
|
|
||||||
relays: string[],
|
|
||||||
filter: Filter,
|
|
||||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
|
||||||
): Promise<Event[]> {
|
|
||||||
return new Promise(async resolve => {
|
|
||||||
const events: Event[] = []
|
|
||||||
this.subscribeManyEose(relays, [filter], {
|
|
||||||
...params,
|
|
||||||
onevent(event: Event) {
|
|
||||||
events.push(event)
|
|
||||||
},
|
|
||||||
onclose(_: string[]) {
|
|
||||||
resolve(events)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(
|
|
||||||
relays: string[],
|
|
||||||
filter: Filter,
|
|
||||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
|
||||||
): Promise<Event | null> {
|
|
||||||
filter.limit = 1
|
|
||||||
const events = await this.querySync(relays, filter, params)
|
|
||||||
events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
return events[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
publish(relays: string[], event: Event): Promise<string>[] {
|
|
||||||
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
|
||||||
if (arr.indexOf(url) !== i) {
|
|
||||||
// duplicate
|
|
||||||
return Promise.reject('duplicate url')
|
|
||||||
}
|
|
||||||
|
|
||||||
let r = await this.ensureRelay(url)
|
|
||||||
return r.publish(event)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './abstract-pool.ts'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
import type { Event } from './pure.ts'
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
type Reference = {
|
type Reference = {
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
118
relay.test.ts
118
relay.test.ts
@@ -1,110 +1,90 @@
|
|||||||
import { test, expect, afterEach, beforeEach } from 'bun:test'
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { finalizeEvent } from './pure.ts'
|
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
|
||||||
import { Relay } from './relay.ts'
|
import { Relay } from './relay.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
let relay = new Relay('wss://public.relaying.io')
|
test('connectivity', async () => {
|
||||||
|
const { url } = newMockRelay()
|
||||||
beforeEach(() => {
|
const relay = new Relay(url)
|
||||||
relay.connect()
|
await relay.connect()
|
||||||
})
|
expect(relay.connected).toBeTrue()
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
relay.close()
|
relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('connectivity', async () => {
|
test('connectivity, with Relay.connect()', async () => {
|
||||||
await relay.connect()
|
const { url } = newMockRelay()
|
||||||
|
const relay = await Relay.connect(url)
|
||||||
expect(relay.connected).toBeTrue()
|
expect(relay.connected).toBeTrue()
|
||||||
|
relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('querying', async () => {
|
test('querying', async done => {
|
||||||
let resolve1: () => void
|
const { url, authors } = newMockRelay()
|
||||||
let resolve2: () => void
|
const kind = 0
|
||||||
|
|
||||||
let waiting = Promise.all([
|
const relay = new Relay(url)
|
||||||
new Promise<void>(resolve => {
|
await relay.connect()
|
||||||
resolve1 = resolve
|
|
||||||
}),
|
|
||||||
new Promise<void>(resolve => {
|
|
||||||
resolve2 = resolve
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
relay.subscribe(
|
relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
ids: ['3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4'],
|
authors: authors,
|
||||||
|
kinds: [kind],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
expect(event).toHaveProperty('id', '3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4')
|
expect(authors).toContain(event.pubkey)
|
||||||
expect(event).toHaveProperty('content', '+')
|
expect(event).toHaveProperty('kind', kind)
|
||||||
expect(event).toHaveProperty('kind', 7)
|
|
||||||
resolve1()
|
relay.close()
|
||||||
},
|
done()
|
||||||
oneose() {
|
|
||||||
resolve2()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
let [t1, t2] = await waiting
|
test('listening and publishing and closing', async done => {
|
||||||
expect(t1).toBeUndefined()
|
const sk = generateSecretKey()
|
||||||
expect(t2).toBeUndefined()
|
const pk = getPublicKey(sk)
|
||||||
}, 10000)
|
const kind = 23571
|
||||||
|
|
||||||
test('listening and publishing and closing', async () => {
|
const { url } = newMockRelay()
|
||||||
let sk = generateSecretKey()
|
const relay = new Relay(url)
|
||||||
let pk = getPublicKey(sk)
|
await relay.connect()
|
||||||
var resolve1: (_: void) => void
|
|
||||||
var resolve2: (_: void) => void
|
|
||||||
|
|
||||||
let waiting = Promise.all([
|
|
||||||
new Promise(resolve => {
|
|
||||||
resolve1 = resolve
|
|
||||||
}),
|
|
||||||
new Promise(resolve => {
|
|
||||||
resolve2 = resolve
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
let sub = relay.subscribe(
|
let sub = relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [23571],
|
kinds: [kind],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
expect(event).toHaveProperty('pubkey', pk)
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
expect(event).toHaveProperty('kind', 23571)
|
expect(event).toHaveProperty('kind', kind)
|
||||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
expect(event).toHaveProperty('content', 'content')
|
||||||
resolve1()
|
sub.close()
|
||||||
},
|
},
|
||||||
|
oneose() {},
|
||||||
onclose() {
|
onclose() {
|
||||||
resolve2()
|
relay.close()
|
||||||
|
done()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
let event = finalizeEvent(
|
relay.publish(
|
||||||
{
|
finalizeEvent(
|
||||||
kind: 23571,
|
{
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
kind,
|
||||||
tags: [],
|
content: 'content',
|
||||||
content: 'nostr-tools test suite',
|
created_at: 0,
|
||||||
},
|
tags: [],
|
||||||
sk,
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await relay.publish(event)
|
|
||||||
sub.close()
|
|
||||||
|
|
||||||
let [t1, t2] = await waiting
|
|
||||||
expect(t1).toBeUndefined()
|
|
||||||
expect(t2).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|||||||
356
relay.ts
356
relay.ts
@@ -1,351 +1,23 @@
|
|||||||
/* global WebSocket */
|
import { verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
|
||||||
import { verifyEvent, validateEvent, type Event, EventTemplate } from './pure.ts'
|
/**
|
||||||
import { matchFilters, type Filter } from './filter.ts'
|
* @deprecated use Relay.connect() instead.
|
||||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
*/
|
||||||
import { Queue, normalizeURL } from './utils.ts'
|
export function relayConnect(url: string): Promise<Relay> {
|
||||||
import { nip42 } from './index.ts'
|
return Relay.connect(url)
|
||||||
import { yieldThread } from './helpers.ts'
|
|
||||||
|
|
||||||
export function relayConnect(url: string) {
|
|
||||||
const relay = new Relay(url)
|
|
||||||
relay.connect()
|
|
||||||
return relay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Relay {
|
export class Relay extends AbstractRelay {
|
||||||
public readonly url: string
|
|
||||||
private _connected: boolean = false
|
|
||||||
|
|
||||||
public trusted: boolean = false
|
|
||||||
public onclose: (() => void) | null = null
|
|
||||||
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
|
||||||
|
|
||||||
public baseEoseTimeout: number = 4400
|
|
||||||
public connectionTimeout: number = 4400
|
|
||||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
private connectionPromise: Promise<void> | undefined
|
|
||||||
private openSubs = new Map<string, Subscription>()
|
|
||||||
private openCountRequests = new Map<string, CountResolver>()
|
|
||||||
private openEventPublishes = new Map<string, EventPublishResolver>()
|
|
||||||
private ws: WebSocket | undefined
|
|
||||||
private incomingMessageQueue = new Queue<string>()
|
|
||||||
private queueRunning = false
|
|
||||||
private challenge: string | undefined
|
|
||||||
private serial: number = 0
|
|
||||||
|
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
this.url = normalizeURL(url)
|
super(url, { verifyEvent })
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeAllSubscriptions(reason: string) {
|
static async connect(url: string) {
|
||||||
for (let [_, sub] of this.openSubs) {
|
const relay = new Relay(url)
|
||||||
sub.close(reason)
|
await relay.connect()
|
||||||
}
|
return relay
|
||||||
this.openSubs.clear()
|
|
||||||
|
|
||||||
for (let [_, ep] of this.openEventPublishes) {
|
|
||||||
ep.reject(new Error(reason))
|
|
||||||
}
|
|
||||||
this.openEventPublishes.clear()
|
|
||||||
|
|
||||||
for (let [_, cr] of this.openCountRequests) {
|
|
||||||
cr.reject(new Error(reason))
|
|
||||||
}
|
|
||||||
this.openCountRequests.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
public get connected(): boolean {
|
|
||||||
return this._connected
|
|
||||||
}
|
|
||||||
|
|
||||||
public async connect(): Promise<void> {
|
|
||||||
if (this.connectionPromise) return this.connectionPromise
|
|
||||||
|
|
||||||
this.challenge = undefined
|
|
||||||
this.connectionPromise = new Promise((resolve, reject) => {
|
|
||||||
this.connectionTimeoutHandle = setTimeout(() => {
|
|
||||||
reject('connection timed out')
|
|
||||||
this.connectionPromise = undefined
|
|
||||||
this.onclose?.()
|
|
||||||
this.closeAllSubscriptions('relay connection timed out')
|
|
||||||
}, this.connectionTimeout)
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(this.url)
|
|
||||||
} catch (err) {
|
|
||||||
reject(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
clearTimeout(this.connectionTimeoutHandle)
|
|
||||||
this._connected = true
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onerror = ev => {
|
|
||||||
reject((ev as any).message)
|
|
||||||
if (this._connected) {
|
|
||||||
this.onclose?.()
|
|
||||||
this.closeAllSubscriptions('relay connection errored')
|
|
||||||
this._connected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onclose = async () => {
|
|
||||||
this.connectionPromise = undefined
|
|
||||||
this.onclose?.()
|
|
||||||
this.closeAllSubscriptions('relay connection closed')
|
|
||||||
this._connected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onmessage = ev => {
|
|
||||||
this.incomingMessageQueue.enqueue(ev.data as string)
|
|
||||||
if (!this.queueRunning) {
|
|
||||||
this.runQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return this.connectionPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runQueue() {
|
|
||||||
this.queueRunning = true
|
|
||||||
while (true) {
|
|
||||||
if (false === this.handleNext()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
await yieldThread()
|
|
||||||
}
|
|
||||||
this.queueRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNext(): undefined | false {
|
|
||||||
const json = this.incomingMessageQueue.dequeue()
|
|
||||||
if (!json) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const subid = getSubscriptionId(json)
|
|
||||||
if (subid) {
|
|
||||||
const so = this.openSubs.get(subid as string)
|
|
||||||
if (!so) {
|
|
||||||
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// this will be called only when this message is a EVENT message for a subscription we have
|
|
||||||
// we do this before parsing the JSON to not have to do that for duplicate events
|
|
||||||
// since JSON parsing is slow
|
|
||||||
const id = getHex64(json, 'id')
|
|
||||||
const alreadyHave = so.alreadyHaveEvent?.(id)
|
|
||||||
|
|
||||||
// notify any interested client that the relay has this event
|
|
||||||
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
|
||||||
so.receivedEvent?.(this, id)
|
|
||||||
|
|
||||||
if (alreadyHave) {
|
|
||||||
// if we had already seen this event we can just stop here
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let data = JSON.parse(json)
|
|
||||||
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
|
||||||
// will naturally be caught by the encompassing try..catch block
|
|
||||||
|
|
||||||
switch (data[0]) {
|
|
||||||
case 'EVENT': {
|
|
||||||
const so = this.openSubs.get(data[1] as string) as Subscription
|
|
||||||
const event = data[2] as Event
|
|
||||||
if ((this.trusted || (validateEvent(event) && verifyEvent(event))) && matchFilters(so.filters, event)) {
|
|
||||||
so.onevent(event)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'COUNT': {
|
|
||||||
const id: string = data[1]
|
|
||||||
const payload = data[2] as { count: number }
|
|
||||||
const cr = this.openCountRequests.get(id) as CountResolver
|
|
||||||
if (cr) {
|
|
||||||
cr.resolve(payload.count)
|
|
||||||
this.openCountRequests.delete(id)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'EOSE': {
|
|
||||||
const so = this.openSubs.get(data[1] as string)
|
|
||||||
if (!so) return
|
|
||||||
so.receivedEose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'OK': {
|
|
||||||
const id: string = data[1]
|
|
||||||
const ok: boolean = data[2]
|
|
||||||
const reason: string = data[3]
|
|
||||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
|
||||||
if (ok) ep.resolve(reason)
|
|
||||||
else ep.reject(new Error(reason))
|
|
||||||
this.openEventPublishes.delete(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'CLOSED': {
|
|
||||||
const id: string = data[1]
|
|
||||||
const so = this.openSubs.get(id)
|
|
||||||
if (!so) return
|
|
||||||
so.closed = true
|
|
||||||
so.close(data[2] as string)
|
|
||||||
this.openSubs.delete(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'NOTICE':
|
|
||||||
this.onnotice(data[1] as string)
|
|
||||||
return
|
|
||||||
case 'AUTH': {
|
|
||||||
this.challenge = data[1] as string
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async send(message: string) {
|
|
||||||
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
|
||||||
|
|
||||||
this.connectionPromise.then(() => {
|
|
||||||
this.ws?.send(message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
|
|
||||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
|
||||||
const evt = nip42.makeAuthEvent(this.url, this.challenge)
|
|
||||||
await signAuthEvent(evt)
|
|
||||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
|
||||||
}
|
|
||||||
|
|
||||||
public async publish(event: Event): Promise<string> {
|
|
||||||
const ret = new Promise<string>((resolve, reject) => {
|
|
||||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
|
||||||
})
|
|
||||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
|
||||||
this.serial++
|
|
||||||
const id = params?.id || 'count:' + this.serial
|
|
||||||
const ret = new Promise<number>((resolve, reject) => {
|
|
||||||
this.openCountRequests.set(id, { resolve, reject })
|
|
||||||
})
|
|
||||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
|
||||||
const subscription = this.prepareSubscription(filters, params)
|
|
||||||
subscription.fire()
|
|
||||||
return subscription
|
|
||||||
}
|
|
||||||
|
|
||||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
|
||||||
this.serial++
|
|
||||||
const id = params.id || 'sub:' + this.serial
|
|
||||||
const subscription = new Subscription(this, id, filters, params)
|
|
||||||
this.openSubs.set(id, subscription)
|
|
||||||
return subscription
|
|
||||||
}
|
|
||||||
|
|
||||||
public close() {
|
|
||||||
this.closeAllSubscriptions('relay connection closed by us')
|
|
||||||
this._connected = false
|
|
||||||
this.ws?.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Subscription {
|
export * from './abstract-relay.ts'
|
||||||
public readonly relay: Relay
|
|
||||||
public readonly id: string
|
|
||||||
|
|
||||||
public closed: boolean = false
|
|
||||||
public eosed: boolean = false
|
|
||||||
public filters: Filter[]
|
|
||||||
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
|
||||||
public receivedEvent: ((relay: Relay, id: string) => void) | undefined
|
|
||||||
|
|
||||||
public onevent: (evt: Event) => void
|
|
||||||
public oneose: (() => void) | undefined
|
|
||||||
public onclose: ((reason: string) => void) | undefined
|
|
||||||
|
|
||||||
public eoseTimeout: number
|
|
||||||
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
constructor(relay: Relay, id: string, filters: Filter[], params: SubscriptionParams) {
|
|
||||||
this.relay = relay
|
|
||||||
this.filters = filters
|
|
||||||
this.id = id
|
|
||||||
this.alreadyHaveEvent = params.alreadyHaveEvent
|
|
||||||
this.receivedEvent = params.receivedEvent
|
|
||||||
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
|
||||||
|
|
||||||
this.oneose = params.oneose
|
|
||||||
this.onclose = params.onclose
|
|
||||||
this.onevent =
|
|
||||||
params.onevent ||
|
|
||||||
(event => {
|
|
||||||
console.warn(
|
|
||||||
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
|
||||||
event,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public fire() {
|
|
||||||
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
|
||||||
|
|
||||||
// only now we start counting the eoseTimeout
|
|
||||||
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
public receivedEose() {
|
|
||||||
if (this.eosed) return
|
|
||||||
clearTimeout(this.eoseTimeoutHandle)
|
|
||||||
this.eosed = true
|
|
||||||
this.oneose?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
public close(reason: string = 'closed by caller') {
|
|
||||||
if (!this.closed) {
|
|
||||||
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
|
||||||
// otherwise this._open will be already set to false so we will skip this
|
|
||||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
|
||||||
this.closed = true
|
|
||||||
}
|
|
||||||
this.onclose?.(reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubscriptionParams = {
|
|
||||||
onevent?: (evt: Event) => void
|
|
||||||
oneose?: () => void
|
|
||||||
onclose?: (reason: string) => void
|
|
||||||
alreadyHaveEvent?: (id: string) => boolean
|
|
||||||
receivedEvent?: (relay: Relay, id: string) => void
|
|
||||||
eoseTimeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CountResolver = {
|
|
||||||
resolve: (count: number) => void
|
|
||||||
reject: (err: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventPublishResolver = {
|
|
||||||
resolve: (reason: string) => void
|
|
||||||
reject: (err: Error) => void
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Event } from './pure.ts'
|
import { Server } from 'mock-socket'
|
||||||
|
|
||||||
|
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(params: Partial<Event>): Event {
|
export function buildEvent(params: Partial<Event>): Event {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -13,3 +15,81 @@ export function buildEvent(params: Partial<Event>): Event {
|
|||||||
...params,
|
...params,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let serial = 0
|
||||||
|
|
||||||
|
// the mock relay will always return some events before eose and then be ok with everything
|
||||||
|
export function newMockRelay(): { url: string; authors: string[]; ids: string[] } {
|
||||||
|
serial++
|
||||||
|
const url = `wss://mock.relay.url/${serial}`
|
||||||
|
const relay = new Server(url)
|
||||||
|
const secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||||
|
const preloadedEvents = secretKeys.map(sk =>
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
relay.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 }
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url, authors: secretKeys.map(getPublicKey), ids: preloadedEvents.map(evt => evt.id) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"outDir": "lib/types",
|
"outDir": "lib/types",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, test, expect } from 'bun:test'
|
|||||||
import { buildEvent } from './test-helpers.ts'
|
import { buildEvent } from './test-helpers.ts'
|
||||||
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||||
|
|
||||||
import type { Event } from './pure.ts'
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
describe('inserting into a desc sorted list of events', () => {
|
describe('inserting into a desc sorted list of events', () => {
|
||||||
test('insert into an empty list', async () => {
|
test('insert into an empty list', async () => {
|
||||||
|
|||||||
2
utils.ts
2
utils.ts
@@ -1,4 +1,4 @@
|
|||||||
import type { Event } from './pure.ts'
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
export const utf8Decoder = new TextDecoder('utf-8')
|
export const utf8Decoder = new TextDecoder('utf-8')
|
||||||
export const utf8Encoder = new TextEncoder()
|
export const utf8Encoder = new TextEncoder()
|
||||||
|
|||||||
Reference in New Issue
Block a user