Compare commits

...

84 Commits

Author SHA1 Message Date
fiatjaf
75df47421f v2.1.1 2023-12-26 07:57:36 -03:00
fiatjaf
1cfe705baf auth() returns a promise that resolves on OK.
fixes https://github.com/nbd-wtf/nostr-tools/issues/336
2023-12-26 07:56:55 -03:00
fiatjaf
566437fe2e nip19: length 0 on TLV is not forbidden. 2023-12-26 07:56:55 -03:00
fiatjaf
5d6c2b9e5d nip19: reverse TLV ordering just to keep other implementations honest. 2023-12-26 07:56:55 -03:00
jiftechnify
a43f2a708c fix problem when required from CommonJS 2023-12-23 08:31:39 -03:00
fiatjaf
f727058a3a rename benchmark.ts -> benchmarks.ts 2023-12-22 11:48:07 -03:00
fiatjaf
1de54838d3 changed the relay in the test, must also change the event queried for. 2023-12-22 11:46:02 -03:00
fiatjaf
703c29a311 fix things so relays tests work. 2023-12-22 11:38:35 -03:00
fiatjaf
ddf1064da9 adjust benchmarks to be done in a more realistic scenario. 2023-12-22 11:11:04 -03:00
fiatjaf
f719d99a11 rename this._push to this._onmessage and use it internally. 2023-12-22 10:54:03 -03:00
fiatjaf
6152238d65 update nostr-wasm to fix memory leak bug. 2023-12-22 10:53:08 -03:00
fiatjaf
9ac1b63994 a test on pool subscribing to many relays, getting many events then closing on eose. 2023-12-22 08:21:58 -03:00
fiatjaf
1890c91ae3 comments. 2023-12-22 08:02:39 -03:00
fiatjaf
7067b47cd4 remove last remains of pool-pure.ts 2023-12-22 07:51:17 -03:00
fiatjaf
397931f847 mention benchmark results in readme. 2023-12-22 06:59:32 -03:00
fiatjaf
5d795c291f fix relay.ts imports after 7f11c0c618. 2023-12-22 06:58:01 -03:00
fiatjaf
7adbd30799 streamline and improve benchmarks. 2023-12-22 06:57:23 -03:00
fiatjaf
83b6dd7ec3 remove pool-wasm.ts that I had forgotten. 2023-12-21 21:04:27 -03:00
fiatjaf
d61cc6c9bf just benchmark 2023-12-21 20:59:45 -03:00
fiatjaf
d7dad8e204 reduce spaces on justfile. 2023-12-21 20:50:42 -03:00
fiatjaf
daaa2ef0a1 bring back relayConnect() as deprecated. 2023-12-21 19:59:12 -03:00
fiatjaf
7f11c0c618 unsplit, backwards-compatibility, wasm relay and pool must be configured manually from the abstract classes. 2023-12-21 19:57:28 -03:00
fiatjaf
a4ae964ee6 split relay and pool into pure and wasm modules. 2023-12-21 17:27:42 -03:00
fiatjaf
1f7378ca49 import from core.ts instead of pure.ts whenever possible. 2023-12-21 17:27:32 -03:00
fiatjaf
d155bcdcda tag v2.0.3 2023-12-21 17:27:25 -03:00
Shusui MOYATANI
919d29363e export kinds 2023-12-21 16:42:30 -03:00
Shusui MOYATANI
ef12a451be fix ensureRelay 2023-12-21 16:42:00 -03:00
fiatjaf
a9acdada19 fix nip-42 test await. 2023-12-21 08:56:03 -03:00
Jon Staab
bf3818e434 Add nip44 v2 2023-12-21 08:55:23 -03:00
jiftechnify
b7389be5c7 correctly wait until connection to a relay is established 2023-12-20 14:43:33 -03:00
Asai Toshiya
7552a36ff2 Update README.md 2023-12-20 13:41:52 -03:00
fiatjaf
1b31a27d89 ensure types are emitted when publishing. 2023-12-20 10:51:41 -03:00
fiatjaf
0cc3c02d84 fix fix yield. 2023-12-20 10:49:08 -03:00
Shusui MOYATANI
8625d45152 fix yield 2023-12-20 09:27:49 -03:00
fiatjaf
8f03116687 tweak readme. 2023-12-19 14:21:04 -03:00
fiatjaf
e6d1808fda update readme to mention fragment importing and nostr-wasm. 2023-12-19 14:14:40 -03:00
fiatjaf
9648de3470 update build process and list of exports. 2023-12-19 14:01:28 -03:00
fiatjaf
fe87529646 change tests and nips to use the new api. 2023-12-19 13:58:37 -03:00
fiatjaf
1908e1ee0d revamp core api + option to use nostr-wasm instead of noble-curves. 2023-12-19 12:20:42 -03:00
fiatjaf
2571db9afc fix validateEvent() signature. 2023-12-19 10:36:54 -03:00
fiatjaf
f77b9eab10 remove auto-publishing to npm. 2023-12-19 10:33:06 -03:00
fiatjaf
71b412657f .subscribe() is not async. 2023-12-19 10:22:29 -03:00
fiatjaf
8840c4d8e2 final adjustments and now even the flaky tests that depend on others's relay should pass most of the time. 2023-12-19 10:01:52 -03:00
fiatjaf
804403f574 change the way eose and connection timeouts work. 2023-12-18 17:11:16 -03:00
fiatjaf
965ebdb6d1 higher time limit for tests on github. 2023-12-18 13:15:19 -03:00
fiatjaf
c54fd95b3e decrease default eoseTimeout to 3400ms. 2023-12-18 10:18:34 -03:00
fiatjaf
7a6c0754ad fix github actions again and put a badge in the readme. 2023-12-18 09:53:44 -03:00
fiatjaf
9e4911160a make pool.subscribe_ methods return synchronously. 2023-12-18 09:53:06 -03:00
fiatjaf
73c6630cf7 fix github actions test. 2023-12-17 22:49:58 -03:00
fiatjaf
88703e9ea2 update readme with new api. 2023-12-17 22:46:35 -03:00
fiatjaf
07d208308f remove broken useless tests. 2023-12-17 22:41:22 -03:00
fiatjaf
f56f2ae709 pool tests and pool.ts tweaks. 2023-12-17 22:19:28 -03:00
fiatjaf
a0cb2eecae get rid of RelayTrackingPool, merge it into SimplePool. 2023-12-17 19:15:27 -03:00
fiatjaf
2a7fd83be8 rewrite binarySearch so it doesn't have to compare values of the same type. 2023-12-17 18:13:09 -03:00
fiatjaf
1ebe098805 binarySearch and improve insertEventInto___List() to use that and .splice() 2023-12-17 18:06:58 -03:00
fiatjaf
3bfb50e267 rewrite pool.ts to be much simpler. 2023-12-17 11:19:50 -03:00
fiatjaf
420a6910e9 fix Queue, tweaks on relay.ts and make relay.test.ts pass. 2023-12-17 00:27:03 -03:00
fiatjaf
7a640092d0 rewrite relay.ts to be much simpler. 2023-12-16 18:56:18 -03:00
fiatjaf
3d541e537e move to bun and bun:test and remove jest. 2023-12-16 14:53:32 -03:00
fiatjaf
1357642575 adjust packages exported. 2023-12-16 13:08:37 -03:00
fiatjaf
d16f3f77c3 prettify and lint. 2023-12-16 12:39:24 -03:00
fiatjaf
0108e3b605 remove nip-44 stuff. 2023-12-16 12:39:07 -03:00
fiatjaf
2ac69278ce simplify nip-42. 2023-12-16 11:21:49 -03:00
fiatjaf
bf31f2eba3 fix nip-47 by removing some useless time checks. 2023-12-16 11:08:51 -03:00
fiatjaf
39cfc5c09e cleanup nip-11. 2023-12-16 11:00:46 -03:00
futpib
3d767beeb9 NIP-06: Support multiple account private keys derived from seed words (#219)
Co-authored-by: fiatjaf_ <fiatjaf@gmail.com>
2023-12-16 10:15:37 -03:00
Alex Gleason
36e0de2a68 Add NIP-30 module for custom emojis 2023-12-16 10:13:40 -03:00
Giacomo Gagliano
9cd4f16e45 nip11 - Types, requestRelayInfos() and tests 2023-12-16 10:13:21 -03:00
fiatjaf
6a07e7c1cc remove the kind type parameter from events and filters. 2023-12-16 10:10:37 -03:00
fiatjaf
1939c46eaa turn kinds enum into simple constants in kinds.ts, bring more kind numbers from the nips readme. 2023-12-16 09:27:59 -03:00
fiatjaf
93538d2373 update dependencies. 2023-12-16 08:51:43 -03:00
fiatjaf
19b3faea17 fix nip05 test. 2023-12-16 08:51:33 -03:00
fiatjaf
867aa11d12 remove all the NIP-26 stuff. 2023-12-13 15:24:57 -03:00
fiatjaf
4fcf925387 nip04: augment tests with cross-compatibility vectors. 2023-12-02 13:13:16 -03:00
Yijia Su
40c5337ef0 Update @noble/curves to 1.2.0 2023-11-28 15:50:54 -03:00
fiatjaf
350d8ec3b6 remove nip06 from main export bundle. 2023-11-13 17:35:32 -03:00
Josh Remaley
c5f3c8052e update to test for body payload and payload hash 2023-11-13 14:30:42 -03:00
Josh Remaley
dc04d1eb85 update to support body payload and hash 2023-11-13 14:30:42 -03:00
William Connatser
a2a15567b7 clean up test with a minor refactor to delete the ts-ignore 2023-10-24 08:41:40 -03:00
fiatjaf
318e3f8c88 we don't have to bump to 2.0.0 since this will not break backwards-compatibility. 2023-10-15 17:58:42 -03:00
fiatjaf
894ffff1f0 prefix exported modules with ./ (esbuild requires this apparently). 2023-10-14 07:57:30 -03:00
franzap
ce11a5fc89 Organize build, allow one entrypoint per file (#305) 2023-10-01 18:20:53 -03:00
Paul Miller
5e85bbc2ed Fix nip44 vectors (#308)
* Fix nip44 vectors

* Update vectors

* Update vectors
2023-09-30 18:46:45 -03:00
Paul Miller
eb0a9093f2 Implement NIP-44: secure versioned replacement for NIP4 (#221) 2023-09-29 20:43:48 -03:00
76 changed files with 3299 additions and 6301 deletions

View File

@@ -139,13 +139,5 @@
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": [0]
},
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
}
}

View File

@@ -1,23 +0,0 @@
name: publish npm package
on:
push:
tags: [v*]
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: just build
- run: just test
- run: just emit-types
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

View File

@@ -10,19 +10,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: bun i
- run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: bun i
- run: just lint

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ package-lock.json
.envrc
lib
test.html
bench.js

209
README.md
View File

@@ -1,4 +1,4 @@
# nostr-tools
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
@@ -19,62 +19,51 @@ If using TypeScript, this package requires TypeScript >= 5.0.
### Generating a private key and a public key
```js
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string
let sk = generateSecretKey() // `sk` is a Uint8Array
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import { validateEvent, verifySignature, getSignature, getEventHash, getPublicKey } from 'nostr-tools'
import { finalizeEvent, verifyEvent } from 'nostr-tools'
let event = {
let event = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
pubkey: getPublicKey(privateKey),
}
}, sk)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
let ok = validateEvent(event)
let veryOk = verifySignature(event)
let isGood = verifyEvent(event)
```
### Interacting with a relay
```js
import { relayInit, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
await relay.connect()
const relay = await Relay.connect('wss://relay.example.com')
console.log(`connected to ${relay.url}`)
// let's query for an event that exists
let sub = relay.sub([
const sub = relay.subscribe([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
], {
onevent(event) {
console.log('we got the event we wanted:', event)
},
oneose() {
sub.close()
}
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generatePrivateKey()
let sk = generateSecretKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
@@ -96,7 +85,7 @@ let event = {
}
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finishEvent(event, sk)
const signedEvent = finalizeEvent(event, sk)
await relay.publish(signedEvent)
let events = await relay.list([{ kinds: [0, 1] }])
@@ -122,41 +111,33 @@ const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let sub = pool.sub(
let h = pool.subscribeMany(
[...relays, 'wss://relay.example3.com'],
[
{
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
},
],
{
onevent(event) {
// this will only be called once the first time the event is received
// ...
},
oneose() {
h.close()
}
}
)
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!')
let pubs = pool.publish(relays, newEvent)
await Promise.all(pubs)
let events = await pool.list(relays, [{ kinds: [0, 1] }])
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
let batchedEvents = await pool.batchedList('notes', relays, [{ kinds: [1] }])
// `batchedList` will wait for other function calls with the same `batchKey`
// (e.g. 'notes', 'authors', etc) within a fixed amount of time (default: `100ms`) before sending
// next ws request, and batch all requests with similar `batchKey`s together in a single request.
let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
// relaysForEvent will be an array of URLs from relays a given event was seen on
pool.close()
```
read more details about `batchedList` on this pr: [https://github.com/nbd-wtf/nostr-tools/pull/279](https://github.com/nbd-wtf/nostr-tools/pull/279#issue-1859315757)
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
@@ -198,21 +179,21 @@ nip05.useFetchImplementation(require('node-fetch'))
### Encoding and decoding NIP-19 codes
```js
import { nip19, generatePrivateKey, getPublicKey } from 'nostr-tools'
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey()
let sk = generateSecretKey()
let nsec = nip19.nsecEncode(sk)
let { type, data } = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let npub = nip19.npubEncode(pk)
let { type, data } = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
let { type, data } = nip19.decode(nprofile)
@@ -221,91 +202,79 @@ assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages
## Import modes
### Using just the packages you want
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
```js
import { nip04, getPublicKey, generatePrivateKey } from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = await nip04.encrypt(sk1, pk2, message)
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties,
}
sendEvent(event)
// on the receiver side
sub.on('event', async event => {
let sender = event.pubkey
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { Relay, Subscription } from 'nostr-tools/relay'
import { matchFilter } from 'nostr-tools/filter'
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
// and so on and so forth
```
### Performing and checking for delegation
### Using it with `nostr-wasm`
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
```js
import { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
initNostrWasm().then(setNostrWasm)
// delegatee
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// generate delegation
let delegation = nip26.createDelegation(sk1, {
pubkey: pk2,
kind: 1,
since: Math.round(Date.now() / 1000),
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */,
})
// the delegatee uses the delegation when building an event
let event = {
pubkey: pk2,
kind: 1,
created_at: Math.round(Date.now() / 1000),
content: 'hello from a delegated key',
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
}
// finally any receiver of this event can check for the presence of a valid delegation tag
let delegator = nip26.getDelegator(event)
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
// see https://www.npmjs.com/package/nostr-wasm for options
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
```js
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
import { initNostrWasm } from 'nostr-wasm'
initNostrWasm().then(setNostrWasm)
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
const pool = new AbstractSimplePool({ verifyEvent })
```
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
```
benchmark time (avg) (min … max) p75 p99 p995
------------------------------------------------- -----------------------------
• relay read message and verify event (many events)
------------------------------------------------- -----------------------------
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
summary for relay read message and verify event
wasm
86.77x slower than trusted
6.86x faster than pure js
```
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
window.NostrTools.generateSecretKey('...') // and so on
</script>
```
## Plumbing
1. Install [`just`](https://just.systems/)
2. `just -l`
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
## License

190
abstract-pool.ts Normal file
View 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
View 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
View 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()

View File

@@ -1,10 +1,22 @@
#!/usr/bin/env node
const fs = require('fs')
const fs = require('node:fs')
const esbuild = require('esbuild')
const { join } = require('path')
const entryPoints = fs
.readdirSync(process.cwd())
.filter(
file =>
file.endsWith('.ts') &&
file !== 'core.ts' &&
file !== 'test-helpers.ts' &&
file !== 'helpers.ts' &&
file !== 'benchmarks.ts' &&
!file.endsWith('.test.ts') &&
fs.statSync(join(process.cwd(), file)).isFile(),
)
let common = {
entryPoints: ['index.ts'],
entryPoints,
bundle: true,
sourcemap: 'external',
}
@@ -12,29 +24,30 @@ let common = {
esbuild
.build({
...common,
outfile: 'lib/esm/nostr.mjs',
outdir: 'lib/esm',
format: 'esm',
packages: 'external',
})
.then(() => {
const packageJson = JSON.stringify({ type: 'module' })
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
outdir: 'lib/cjs',
format: 'cjs',
packages: 'external',
})
.then(() => console.log('cjs build success.'))
.then(() => {
const packageJson = JSON.stringify({ type: 'commonjs' })
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
console.log('cjs build success.')
})
esbuild
.build({
...common,
entryPoints: ['index.ts'],
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',

BIN
bun.lockb Executable file

Binary file not shown.

293
core.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import { describe, test, expect } from 'bun:test'
import {
finalizeEvent,
serializeEvent,
getEventHash,
validateEvent,
verifyEvent,
verifiedSymbol,
getPublicKey,
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
test('private key generation', () => {
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})
describe('finalizeEvent', () => {
test('should create a signed event from a template', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const template = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finalizeEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
test('should serialize a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
test('should throw an error for an invalid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
test('should return the correct event hash', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
test('should return true for a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
test('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
test('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid properties', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid tags', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifyEvent', () => {
test('should return true for a valid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const event = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifyEvent(event)
expect(isValid).toEqual(true)
})
test('should return false for an invalid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
test('should return false when verifying an event with a different private key', () => {
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifyEvent({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
test('should return false for an invalid event id', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
})

51
core.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface Nostr {
generateSecretKey(): Uint8Array
getPublicKey(secretKey: Uint8Array): string
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
verifyEvent(event: Event): event is VerifiedEvent
}
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
export interface Event {
kind: number
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
[verifiedSymbol]?: boolean
}
export type NostrEvent = Event
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
/** An event whose signature has been verified. */
export interface VerifiedEvent extends Event {
[verifiedSymbol]: true
}
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}

View File

@@ -1,360 +0,0 @@
import {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
getSignature,
Kind,
verifiedSymbol,
} from './event.ts'
import { getPublicKey } from './keys.ts'
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
content: '',
tags: [],
created_at: 0,
})
})
it('should return a blank event object with defined kind', () => {
expect(getBlankEvent(Kind.Text)).toEqual({
kind: 1,
content: '',
tags: [],
created_at: 0,
})
})
})
describe('finishEvent', () => {
it('should create a signed event from a template', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finishEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
it('should serialize a valid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
it('should throw an error for an invalid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
it('should return false for an invalid event id', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
})
describe('getSignature', () => {
it('should produce the correct signature for an event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const sig = getSignature(unsignedEvent, privateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig,
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const sig = getSignature(unsignedEvent, wrongPrivateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig,
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

144
event.ts
View File

@@ -1,144 +0,0 @@
import { schnorr } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { getPublicKey } from './keys.ts'
import { utf8Encoder } from './utils.ts'
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
/** @deprecated Use numbers instead. */
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Repost = 6,
Reaction = 7,
BadgeAward = 8,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
Blank = 255,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
NwcRequest = 23194,
HttpAuth = 27235,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023,
FileMetadata = 1063,
}
export interface Event<K extends number = number> {
kind: K
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
[verifiedSymbol]?: boolean
}
export type EventTemplate<K extends number = number> = Pick<Event<K>, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent<K extends number = number> = Pick<
Event<K>,
'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'
>
/** An event whose signature has been verified. */
export interface VerifiedEvent<K extends number = number> extends Event<K> {
[verifiedSymbol]: true
}
export function getBlankEvent(): EventTemplate<Kind.Blank>
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
return {
kind,
content: '',
tags: [],
created_at: 0,
}
}
export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
const event = t as VerifiedEvent<K>
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
event[verifiedSymbol] = true
return event
}
export function serializeEvent(evt: UnsignedEvent<number>): string {
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
}
export function getEventHash(event: UnsignedEvent<number>): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
/** Verify the event's signature. This function mutates the event with a `verified` symbol, making it idempotent. */
export function verifySignature<K extends number>(event: Event<K>): event is VerifiedEvent<K> {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
return (event[verifiedSymbol] = false)
}
try {
return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
} catch (err) {
return (event[verifiedSymbol] = false)
}
}
/** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
)
return getSignature(event, key)
}
/** Calculate the signature for an event. */
export function getSignature(event: UnsignedEvent<number>, key: string): string {
return bytesToHex(schnorr.sign(getEventHash(event), key))
}

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
test('match id', () => {

View File

@@ -1,9 +1,10 @@
import { describe, test, expect } from 'bun:test'
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { buildEvent } from './test-helpers.ts'
describe('Filter', () => {
describe('matchFilter', () => {
it('should return true when all filter conditions are met', () => {
test('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
@@ -26,7 +27,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return false when the event id is not in the filter', () => {
test('should return false when the event id is not in the filter', () => {
const filter = { ids: ['123', '456'] }
const event = buildEvent({ id: '789' })
@@ -36,7 +37,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return true when the event id starts with a prefix', () => {
test('should return true when the event id starts with a prefix', () => {
const filter = { ids: ['22', '00'] }
const event = buildEvent({ id: '001' })
@@ -46,7 +47,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return false when the event kind is not in the filter', () => {
test('should return false when the event kind is not in the filter', () => {
const filter = { kinds: [1, 2, 3] }
const event = buildEvent({ kind: 4 })
@@ -56,7 +57,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return false when the event author is not in the filter', () => {
test('should return false when the event author is not in the filter', () => {
const filter = { authors: ['abc', 'def'] }
const event = buildEvent({ pubkey: 'ghi' })
@@ -66,7 +67,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return false when a tag is not present in the event', () => {
test('should return false when a tag is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['not_tag', 'value1']] })
@@ -76,7 +77,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return false when a tag value is not present in the event', () => {
test('should return false when a tag value is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['tag', 'value3']] })
@@ -86,7 +87,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return true when filter has tags that is present in the event', () => {
test('should return true when filter has tags that is present in the event', () => {
const filter = { '#tag1': ['foo'] }
const event = buildEvent({
@@ -105,7 +106,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return false when the event is before the filter since value', () => {
test('should return false when the event is before the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 50 })
@@ -115,7 +116,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter since value', () => {
test('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 100 })
@@ -125,7 +126,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return false when the event is after the filter until value', () => {
test('should return false when the event is after the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 150 })
@@ -135,7 +136,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter until value', () => {
test('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 100 })
@@ -147,7 +148,7 @@ describe('Filter', () => {
})
describe('matchFilters', () => {
it('should return true when at least one filter matches the event', () => {
test('should return true when at least one filter matches the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
@@ -161,7 +162,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return true when at least one prefix matches the event', () => {
test('should return true when at least one prefix matches the event', () => {
const filters = [
{ ids: ['1'], kinds: [1], authors: ['a'] },
{ ids: ['4'], kinds: [2], authors: ['d'] },
@@ -175,7 +176,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return true when event matches one or more filters and some have limit set', () => {
test('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
@@ -194,7 +195,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
it('should return false when no filters match the event', () => {
test('should return false when no filters match the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
@@ -208,7 +209,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return false when event matches none of the filters and some have limit set', () => {
test('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
@@ -228,7 +229,7 @@ describe('Filter', () => {
})
describe('mergeFilters', () => {
it('should merge filters', () => {
test('should merge filters', () => {
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
ids: ['a', 'b', 'c'],
limit: 3,

View File

@@ -1,8 +1,8 @@
import { Event } from './event.ts'
import { Event } from './core.ts'
export type Filter<K extends number = number> = {
export type Filter = {
ids?: string[]
kinds?: K[]
kinds?: number[]
authors?: string[]
since?: number
until?: number
@@ -11,7 +11,7 @@ export type Filter<K extends number = number> = {
[key: `#${string}`]: string[] | undefined
}
export function matchFilter(filter: Filter<number>, event: Event<number>): boolean {
export function matchFilter(filter: Filter, event: Event): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
@@ -38,15 +38,15 @@ export function matchFilter(filter: Filter<number>, event: Event<number>): boole
return true
}
export function matchFilters(filters: Filter<number>[], event: Event<number>): boolean {
export function matchFilters(filters: Filter[], event: Event): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
let result: Filter<number> = {}
export function mergeFilters(...filters: Filter[]): Filter {
let result: Filter = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {

16
helpers.ts Normal file
View File

@@ -0,0 +1,16 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() {
return new Promise(resolve => {
const ch = new MessageChannel()
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', resolve)
ch.port2.postMessage(0)
ch.port1.start()
})
}
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
t[verifiedSymbol] = true
return true
}

View File

@@ -1,22 +1,21 @@
export * from './keys.ts'
export * from './pure.ts'
export * from './relay.ts'
export * from './event.ts'
export * from './filter.ts'
export * from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip06 from './nip06.ts'
export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip30 from './nip30.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
@@ -24,5 +23,6 @@ export * as nip47 from './nip47.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

View File

@@ -1,5 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -1,28 +1,32 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
node build.js
rm -rf lib
bun run build.js
test:
jest
bun test --timeout 20000
test-only file:
jest {{file}}
bun test {{file}}
emit-types:
tsc # see tsconfig.json
tsc # see tsconfig.json
publish: build emit-types
npm publish
npm publish
format:
eslint --ext .ts --fix *.ts
prettier --write *.ts
eslint --ext .ts --fix *.ts
prettier --write *.ts
lint:
eslint --ext .ts *.ts
prettier --check *.ts
eslint --ext .ts *.ts
prettier --check *.ts
benchmark:
bun build --target=node --outfile=bench.js benchmarks.ts
timeout 60s deno run --allow-read bench.js || true
timeout 60s node bench.js || true
timeout 60s bun run benchmarks.ts || true
rm bench.js

View File

@@ -1,18 +0,0 @@
import { generatePrivateKey, getPublicKey } from './keys.ts'
test('private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

10
keys.ts
View File

@@ -1,10 +0,0 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
export function generatePrivateKey(): string {
return bytesToHex(schnorr.utils.randomPrivateKey())
}
export function getPublicKey(privateKey: string): string {
return bytesToHex(schnorr.getPublicKey(privateKey))
}

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { classifyKind } from './kinds.ts'
test('kind classification', () => {

View File

@@ -1,28 +1,28 @@
/** Events are **regular**, which means they're all expected to be stored by relays. */
function isRegularKind(kind: number) {
export function isRegularKind(kind: number) {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
}
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
function isReplaceableKind(kind: number) {
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind)
export function isReplaceableKind(kind: number) {
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
}
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
function isEphemeralKind(kind: number) {
export function isEphemeralKind(kind: number) {
return 20000 <= kind && kind < 30000
}
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
function isParameterizedReplaceableKind(kind: number) {
export function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000
}
/** Classification of the event kind. */
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
/** Determine the classification of this kind of event if known, or `unknown`. */
function classifyKind(kind: number): KindClassification {
export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral'
@@ -30,11 +30,76 @@ function classifyKind(kind: number): KindClassification {
return 'unknown'
}
export {
classifyKind,
isEphemeralKind,
isParameterizedReplaceableKind,
isRegularKind,
isReplaceableKind,
type KindClassification,
}
export const Metadata = 0
export const ShortTextNote = 1
export const RecommendRelay = 2
export const Contacts = 3
export const EncryptedDirectMessage = 4
export const EventDeletion = 5
export const Repost = 6
export const Reaction = 7
export const BadgeAward = 8
export const ChannelCreation = 40
export const ChannelMetadata = 41
export const ChannelMessage = 42
export const ChannelHideMessage = 43
export const ChannelMuteUser = 44
export const Report = 1984
export const ZapRequest = 9734
export const Zap = 9735
export const RelayList = 10002
export const ClientAuth = 22242
export const BadgeDefinition = 30009
export const FileMetadata = 1063
export const EncryptedDirectMessages = 4
export const GenericRepost = 16
export const OpenTimestamps = 1040
export const LiveChatMessage = 1311
export const ProblemTracker = 1971
export const Reporting = 1984
export const Label = 1985
export const CommunityPostApproval = 4550
export const JobRequest = 5999
export const JobResult = 6999
export const JobFeedback = 7000
export const ZapGoal = 9041
export const Highlights = 9802
export const Mutelist = 10000
export const Pinlist = 10001
export const BookmarkList = 10003
export const CommunitiesList = 10004
export const PublicChatsList = 10005
export const BlockedRelaysList = 10006
export const SearchRelaysList = 10007
export const InterestsList = 10015
export const UserEmojiList = 10030
export const NWCWalletInfo = 13194
export const LightningPubRPC = 21000
export const NWCWalletRequest = 23194
export const NWCWalletResponse = 23195
export const NostrConnect = 24133
export const HTTPAuth = 27235
export const Followsets = 30000
export const Genericlists = 30001
export const Relaysets = 30002
export const Bookmarksets = 30003
export const Curationsets = 30004
export const ProfileBadges = 30008
export const Interestsets = 30015
export const CreateOrUpdateStall = 30017
export const CreateOrUpdateProduct = 30018
export const LongFormArticle = 30023
export const DraftLong = 30024
export const Emojisets = 30030
export const Application = 30078
export const LiveEvent = 30311
export const UserStatuses = 30315
export const ClassifiedListing = 30402
export const DraftClassifiedListing = 30403
export const Date = 31922
export const Time = 31923
export const Calendar = 31924
export const CalendarEventRSVP = 31925
export const Handlerrecommendation = 31989
export const Handlerinformation = 31990
export const CommunityDefinition = 34550

View File

@@ -1,17 +1,50 @@
import { test, expect } from 'bun:test'
import crypto from 'node:crypto'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
try {
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
} catch (err) {
/***/
}
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let sk1 = generateSecretKey()
let sk2 = generateSecretKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))).toEqual('hello')
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
})
test('decrypt message from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
})
test('decrypt big payload from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext =
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
let plaintext = ''
for (let i = 0; i < 800; i++) {
plaintext += 'z'
}
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
})

View File

@@ -1,4 +1,4 @@
import { randomBytes } from '@noble/hashes/utils'
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { base64 } from '@scure/base'
@@ -10,7 +10,8 @@ if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(privkey: string, pubkey: string, text: string): Promise<string> {
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
@@ -24,7 +25,8 @@ export async function encrypt(privkey: string, pubkey: string, text: string): Pr
return `${ctb64}?iv=${ivb64}`
}
export async function decrypt(privkey: string, pubkey: string, data: string): Promise<string> {
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile } from './nip05.ts'
@@ -15,12 +16,5 @@ test('fetch nip05 profiles', async () => {
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(p3!.relays).toEqual([
'wss://relay.nostr.bg',
'wss://nos.lol',
'wss://nostr-verified.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
])
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
})

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { privateKeyFromSeedWords } from './nip06.ts'
test('generate private key from a mnemonic', async () => {
@@ -6,9 +7,22 @@ test('generate private key from a mnemonic', async () => {
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
})
test('generate private key for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
})
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
})

View File

@@ -3,9 +3,9 @@ import { wordlist } from '@scure/bip39/wordlists/english'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return bytesToHex(privateKey)
}

View File

@@ -1,3 +1,4 @@
import { describe, test, expect } from 'bun:test'
import { parse } from './nip10.ts'
describe('parse NIP10-referenced events', () => {
@@ -171,10 +172,6 @@ describe('parse NIP10-referenced events', () => {
})
})
test.todo('recommended + a lot of events')
test.todo('recommended + 3 events')
test.todo('recommended + 2 events')
test('recommended + 1 event', () => {
let event = {
tags: [

View File

@@ -1,4 +1,4 @@
import type { Event } from './event.ts'
import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts'
export type NIP10Result = {

16
nip11.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, fetchRelayInformation } from './nip11'
describe('requesting relay as for NIP11', () => {
useFetchImplementation(fetch)
test('testing a relay', async () => {
const info = await fetchRelayInformation('wss://atlas.nostr.land')
expect(info.name).toEqual('nostr.land')
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
expect(info.fees).toBeTruthy()
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
expect(info.software).toEqual('custom')
})
})

286
nip11.ts Normal file
View File

@@ -0,0 +1,286 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function fetchRelayInformation(url: string) {
return (await (
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' },
})
).json()) as RelayInformation
}
/**
* ## Relay Information Document
* Relays may provide server metadata to clients to inform
* them of capabilities, administrative contacts, and
* various server attributes. This is made available as a
* JSON document over HTTP, on the same URI as the relay's
* websocket.
* Any field may be omitted, and clients MUST ignore any
* additional fields they do not understand. Relays MUST
* accept CORS requests by sending
* `Access-Control-Allow-Origin`,
* `Access-Control-Allow-Headers`, and
* `Access-Control-Allow-Methods` headers.
* @param name string identifying relay
* @param description string with detailed information
* @param pubkey administrative contact pubkey
* @param contact: administrative alternate contact
* @param supported_nips a list of NIP numbers supported by
* the relay
* @param software identifying relay software URL
* @param version string version identifier
*/
export interface BasicRelayInformation {
// string identifying relay
name: string
description: string
pubkey: string
contact: string
supported_nips: number[]
software: string
version: string
// limitation?: Limitations<A, P>
}
/**
* * ## Extra Fields
* * ### Server Limitations
* These are limitations imposed by the relay on clients.
* Your client should expect that requests which exceed
* these practical_ limitations are rejected or fail immediately.
* @param max_message_length this is the maximum number of
* bytes for incoming JSON that the relay will attempt to
* decode and act upon. When you send large subscriptions,
* you will be limited by this value. It also effectively
* limits the maximum size of any event. Value is calculated
* from `[` to `]` and is after UTF-8 serialization (so some
* unicode characters will cost 2-3 bytes). It is equal to
* the maximum size of the WebSocket message frame.
* @param max_subscription total number of subscriptions
* that may be active on a single websocket connection to
* this relay. It's possible that authenticated clients with
* a (paid) relationship to the relay may have higher limits.
* @param max_filters maximum number of filter values in
* each subscription. Must be one or higher.
* @param max_limit the relay server will clamp each
* filter's `limit` value to this number.
* This means the client won't be able to get more than this
* number of events from a single subscription filter. This
* clamping is typically done silently by the relay, but
* with this number, you can know that there are additional
* results if you narrowed your filter's time range or other
* parameters.
* @param max_subid_length maximum length of subscription id as a
* string.
* @param min_prefix for `authors` and `ids` filters which
* are to match against a hex prefix, you must provide at
* least this many hex digits in the prefix.
* @param max_event_tags in any event, this is the maximum
* number of elements in the `tags` list.
* @param max_content_length maximum number of characters in
* the `content` field of any event. This is a count of
* unicode characters. After serializing into JSON it may be
* larger (in bytes), and is still subject to the
* max_message_length`, if defined.
* @param min_pow_difficulty new events will require at
* least this difficulty of PoW, based on [NIP-13](13.md),
* or they will be rejected by this server.
* @param auth_required this relay requires [NIP-42](42.md)
* authentication to happen before a new connection may
* perform any other action. Even if set to False,
* authentication may be required for specific actions.
* @param payment_required this relay requires payment
* before a new connection may perform any action.
*/
export interface Limitations {
max_message_length: number
max_subscription: number
max_filters: number
max_limit: number
max_subid_length: number
min_prefix: number
max_event_tags: number
max_content_length: number
min_pow_difficulty: number
auth_required: boolean
payment_required: boolean
}
interface RetentionDetails {
kinds: (number | number[])[]
time?: number | null
count?: number | null
}
type AnyRetentionDetails = RetentionDetails
/**
* ### Event Retention
* There may be a cost associated with storing data forever,
* so relays may wish to state retention times. The values
* stated here are defaults for unauthenticated users and
* visitors. Paid users would likely have other policies.
* Retention times are given in seconds, with `null`
* indicating infinity. If zero is provided, this means the
* event will not be stored at all, and preferably an error
* will be provided when those are received.
* ```json
{
...
"retention": [
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
{ "kinds": [[40000, 49999]], "time": 100 },
{ "kinds": [[30000, 39999]], "count": 1000 },
{ "time": 3600, "count": 10000 }
]
...
}
```
* @param retention is a list of specifications: each will
* apply to either all kinds, or a subset of kinds. Ranges
* may be specified for the kind field as a tuple of
* inclusive start and end values. Events of indicated kind
* (or all) are then limited to a `count` and/or time
* period.
* It is possible to effectively blacklist Nostr-based
* protocols that rely on a specific `kind` number, by
* giving a retention time of zero for those `kind` values.
* While that is unfortunate, it does allow clients to
* discover servers that will support their protocol quickly
* via a single HTTP fetch.
* There is no need to specify retention times for
* _ephemeral events_ as defined in [NIP-16](16.md) since
* they are not retained.
*/
export interface Retention {
retention: AnyRetentionDetails[]
}
/**
* Some relays may be governed by the arbitrary laws of a
* nation state. This may limit what content can be stored
* in cleartext on those relays. All clients are encouraged
* to use encryption to work around this limitation.
* It is not possible to describe the limitations of each
* country's laws and policies which themselves are
* typically vague and constantly shifting.
* Therefore, this field allows the relay operator to
* indicate which countries' laws might end up being
* enforced on them, and then indirectly on their users'
* content.
* Users should be able to avoid relays in countries they
* don't like, and/or select relays in more favourable
* zones. Exposing this flexibility is up to the client
* software.
* @param relay_countries a list of two-level ISO country
* codes (ISO 3166-1 alpha-2) whose laws and policies may
* affect this relay. `EU` may be used for European Union
* countries.
* Remember that a relay may be hosted in a country which is
* not the country of the legal entities who own the relay,
* so it's very likely a number of countries are involved.
*/
export interface ContentLimitations {
relay_countries: string[]
}
/**
* ### Community Preferences
* For public text notes at least, a relay may try to foster
* a local community. This would encourage users to follow
* the global feed on that relay, in addition to their usual
* individual follows. To support this goal, relays MAY
* specify some of the following values.
* @param language_tags is an ordered list of [IETF
* language
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
* indicating the major languages spoken on the relay.
* @param tags is a list of limitations on the topics to be
* discussed. For example `sfw-only` indicates that only
* "Safe For Work" content is encouraged on this relay. This
* relies on assumptions of what the "work" "community"
* feels "safe" talking about. In time, a common set of tags
* may emerge that allow users to find relays that suit
* their needs, and client software will be able to parse
* these tags easily. The `bitcoin-only` tag indicates that
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
* be ridiculed without mercy.
* @param posting_policy is a link to a human-readable page
* which specifies the community policies for the relay. In
* cases where `sfw-only` is True, it's important to link to
* a page which gets into the specifics of your posting
* policy.
* The `description` field should be used to describe your
* community goals and values, in brief. The
* `posting_policy` is for additional detail and legal
* terms. Use the `tags` field to signify limitations on
* content, or topics to be discussed, which could be
* machine processed by appropriate client software.
*/
export interface CommunityPreferences {
language_tags: string[]
tags: string[]
posting_policy: string
}
export interface Amount {
amount: number
unit: 'msat'
}
export interface PublicationAmount extends Amount {
kinds: number[]
}
export interface Subscription extends Amount {
period: number
}
export interface Fees {
admission: Amount[]
subscription: Subscription[]
publication: PublicationAmount[]
}
/**
* Relays that require payments may want to expose their fee
* schedules.
*/
export interface PayToRelay {
payments_url: string
fees: Fees
}
/**
* A URL pointing to an image to be used as an icon for the
* relay. Recommended to be squared in shape.
*/
export interface Icon {
icon: string
}
export type RelayInformation = BasicRelayInformation &
Partial<Retention> & {
limitation?: Partial<Limitations>
} & Partial<ContentLimitations> &
Partial<CommunityPreferences> &
Partial<PayToRelay> &
Partial<Icon>

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {

View File

@@ -1,4 +1,4 @@
import { type UnsignedEvent, type Event, getEventHash } from './event.ts'
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number {
@@ -23,10 +23,10 @@ export function getPow(hex: string): number {
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/
export function minePow<K extends number>(unsigned: UnsignedEvent<K>, difficulty: number): Omit<Event<K>, 'sig'> {
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0
const event = unsigned as Omit<Event<K>, 'sig'>
const event = unsigned as Omit<Event, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)

View File

@@ -1,18 +1,19 @@
import { finishEvent, Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const repostedEvent = finishEvent(
const repostedEvent = finalizeEvent(
{
kind: Kind.Text,
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -23,14 +24,14 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
privateKey,
)
it('should create a signed event from a minimal template', () => {
test('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Kind.Repost)
expect(event.kind).toEqual(Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
@@ -52,7 +53,7 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
expect(repostedEventFromContent).toEqual(repostedEvent)
})
it('should create a signed event from a filled template', () => {
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
@@ -61,7 +62,7 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Kind.Repost)
expect(event.kind).toEqual(Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
@@ -81,21 +82,21 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(undefined)
expect(repostedEventFromContent).toBeUndefined()
})
})
describe('getRepostedEventPointer', () => {
it('should parse an event with only an `e` tag', () => {
test('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Kind.Repost,
kind: Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toEqual(undefined)
expect(repostedEventPointer!.author).toBeUndefined()
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

View File

@@ -1,4 +1,5 @@
import { Event, finishEvent, Kind, verifySignature } from './event.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts'
export type RepostEventTemplate = {
@@ -20,13 +21,13 @@ export type RepostEventTemplate = {
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event<number>,
reposted: Event,
relayUrl: string,
privateKey: string,
): Event<Kind.Repost> {
return finishEvent(
privateKey: Uint8Array,
): Event {
return finalizeEvent(
{
kind: Kind.Repost,
kind: Repost,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
@@ -35,8 +36,8 @@ export function finishRepostEvent(
)
}
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Repost) {
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) {
return undefined
}
@@ -69,20 +70,17 @@ export type GetRepostedEventOptions = {
skipVerification?: boolean
}
export function getRepostedEvent(
event: Event<number>,
{ skipVerification }: GetRepostedEventOptions = {},
): undefined | Event<number> {
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event<number>
let repostedEvent: undefined | Event
try {
repostedEvent = JSON.parse(event.content) as Event<number>
repostedEvent = JSON.parse(event.content) as Event
} catch (error) {
return undefined
}
@@ -91,7 +89,7 @@ export function getRepostedEvent(
return undefined
}
if (!skipVerification && !verifySignature(repostedEvent)) {
if (!skipVerification && !verifyEvent(repostedEvent)) {
return undefined
}

View File

@@ -1,4 +1,5 @@
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { test, expect } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts'
import {
decode,
naddrEncode,
@@ -13,7 +14,7 @@ import {
} from './nip19.ts'
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let sk = generateSecretKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let { type, data } = decode(nsec)
@@ -22,7 +23,7 @@ test('encode and decode nsec', () => {
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let { type, data } = decode(npub)
@@ -31,7 +32,7 @@ test('encode and decode npub', () => {
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nprofileEncode({ pubkey: pk, relays })
expect(nprofile).toMatch(/nprofile1\w+/)
@@ -55,7 +56,7 @@ test('decode nprofile without relays', () => {
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
pubkey: pk,
@@ -75,15 +76,15 @@ test('encode and decode naddr', () => {
})
test('encode and decode nevent', () => {
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
let nevent = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
@@ -92,15 +93,15 @@ test('encode and decode nevent', () => {
})
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
let nevent = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
@@ -108,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
expect(pointer.kind).toEqual(0)
})
test('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
test('decode naddr from habla.news', () => {
let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',

View File

@@ -48,7 +48,7 @@ type Prefixes = {
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: string
nsec: Uint8Array
npub: string
note: string
}
@@ -130,6 +130,8 @@ export function decode(nip19: string): DecodeResult {
}
case 'nsec':
return { type: prefix, data }
case 'npub':
case 'note':
return { type: prefix, data: bytesToHex(data) }
@@ -147,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
@@ -157,16 +158,16 @@ function parseTLV(data: Uint8Array): TLV {
return result
}
export function nsecEncode(hex: string): `nsec1${string}` {
return encodeBytes('nsec', hex)
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
return encodeBytes('nsec', key)
}
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hex)
return encodeBytes('npub', hexToBytes(hex))
}
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hex)
return encodeBytes('note', hexToBytes(hex))
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
@@ -174,9 +175,8 @@ function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array):
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
let data = hexToBytes(hex)
return encodeBech32(prefix, data)
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
}
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
@@ -189,7 +189,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
export function neventEncode(event: EventPointer): `nevent1${string}` {
let kindArray
if (event.kind != undefined) {
if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind)
}
@@ -226,15 +226,17 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
Object.entries(tlv)
.reverse()
.forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
})
return concatBytes(...entries)
}

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { test as testRegex, parse } from './nip21.ts'
test('test()', () => {

View File

@@ -1,15 +1,16 @@
import { finishEvent, Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Reaction, ShortTextNote } from './kinds.ts'
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const reactedEvent = finishEvent(
const reactedEvent = finalizeEvent(
{
kind: Kind.Text,
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -20,14 +21,14 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
privateKey,
)
it('should create a signed event from a minimal template', () => {
test('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.kind).toEqual(Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -46,7 +47,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
it('should create a signed event from a filled template', () => {
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
@@ -55,7 +56,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.kind).toEqual(Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],

View File

@@ -1,4 +1,5 @@
import { Event, finishEvent, Kind } from './event.ts'
import { Event, finalizeEvent } from './pure.ts'
import { Reaction } from './kinds.ts'
import type { EventPointer } from './nip19.ts'
@@ -16,17 +17,13 @@ export type ReactionEventTemplate = {
created_at: number
}
export function finishReactionEvent(
t: ReactionEventTemplate,
reacted: Event<number>,
privateKey: string,
): Event<Kind.Reaction> {
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, privateKey: Uint8Array): Event {
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
return finishEvent(
return finalizeEvent(
{
...t,
kind: Kind.Reaction,
kind: Reaction,
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
content: t.content ?? '+',
},
@@ -34,8 +31,8 @@ export function finishReactionEvent(
)
}
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Reaction) {
export function getReactedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Reaction) {
return undefined
}

View File

@@ -1,101 +0,0 @@
import { getPublicKey, generatePrivateKey } from './keys.ts'
import { getDelegator, createDelegation } from './nip26.ts'
import { buildEvent } from './test-helpers.ts'
test('parse good delegation from NIP', async () => {
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
],
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}),
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
})
test('parse bad delegations', async () => {
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
],
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}),
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1740995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
],
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}),
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
],
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}),
).toEqual(null)
})
test('create and verify delegation', async () => {
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
let delegation = createDelegation(sk1, { pubkey: pk2, kind: 1 })
expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1')
let event = buildEvent({
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2,
})
expect(getDelegator(event)).toEqual(pk1)
})

View File

@@ -1,71 +0,0 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { getPublicKey } from './keys.ts'
import type { Event } from './event.ts'
export type Parameters = {
pubkey: string // the key to whom the delegation will be given
kind?: number
until?: number // delegation will only be valid until this date
since?: number // delegation will be valid from this date on
}
export type Delegation = {
from: string // the pubkey who signed the delegation
to: string // the pubkey that is allowed to use the delegation
cond: string // the string of conditions as they should be included in the event tag
sig: string
}
export function createDelegation(privateKey: string, parameters: Parameters): Delegation {
let conditions = []
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
let cond = conditions.join('&')
if (cond === '') throw new Error('refusing to create a delegation without any conditions')
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`))
let sig = bytesToHex(schnorr.sign(sighash, privateKey))
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig,
}
}
export function getDelegator(event: Event<number>): string | null {
// find delegation tag
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
if (!tag) return null
let pubkey = tag[1]
let cond = tag[2]
let sig = tag[3]
// check conditions
let conditions = cond.split('&')
for (let i = 0; i < conditions.length; i++) {
let [key, operator, value] = conditions[i].split(/\b/)
// the supported conditions are just 'kind' and 'created_at' for now
if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) continue
else if (key === 'created_at' && operator === '<' && event.created_at < parseInt(value)) continue
else if (key === 'created_at' && operator === '>' && event.created_at > parseInt(value)) continue
else return null // invalid condition
}
// check signature
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`))
if (!schnorr.verify(sig, sighash, pubkey)) return null
return pubkey
}

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts'
test('matchAll', () => {

View File

@@ -1,5 +1,7 @@
import { Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { getPublicKey } from './pure.ts'
import * as Kind from './kinds.ts'
import {
channelCreateEvent,
channelMetadataEvent,
@@ -10,7 +12,7 @@ import {
ChannelMessageEventTemplate,
} from './nip28.ts'
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
@@ -20,7 +22,7 @@ describe('NIP-28 Functions', () => {
picture: 'https://example.com/picture.jpg',
}
it('channelCreateEvent should create an event with given template', () => {
test('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115,
@@ -32,7 +34,7 @@ describe('NIP-28 Functions', () => {
expect(event!.pubkey).toEqual(publicKey)
})
it('channelMetadataEvent should create a signed event with given template', () => {
test('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
@@ -48,8 +50,8 @@ describe('NIP-28 Functions', () => {
expect(typeof event!.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message event with given template', () => {
const template = {
test('channelMessageEvent should create a signed message event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
@@ -65,7 +67,7 @@ describe('NIP-28 Functions', () => {
expect(typeof event.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message reply event with given template', () => {
test('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
@@ -76,15 +78,25 @@ describe('NIP-28 Functions', () => {
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags).toContainEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.tags).toContainEqual(['e', template.reply_to_channel_message_event_id, template.relay_url, 'reply'])
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root',
])
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
'e',
template.reply_to_channel_message_event_id as string,
template.relay_url,
'reply',
])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
it('channelHideMessageEvent should create a signed event with given template', () => {
test('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: { reason: 'Inappropriate content' },
@@ -100,7 +112,7 @@ describe('NIP-28 Functions', () => {
expect(typeof event!.sig).toEqual('string')
})
it('channelMuteUserEvent should create a signed event with given template', () => {
test('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: { reason: 'Spamming' },
created_at: 1617932115,

View File

@@ -1,4 +1,5 @@
import { Event, finishEvent, Kind } from './event.ts'
import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
export interface ChannelMetadata {
name: string
@@ -44,10 +45,7 @@ export interface ChannelMuteUserEventTemplate {
tags?: string[][]
}
export const channelCreateEvent = (
t: ChannelCreateEventTemplate,
privateKey: string,
): Event<Kind.ChannelCreation> | undefined => {
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -57,9 +55,9 @@ export const channelCreateEvent = (
return undefined
}
return finishEvent(
return finalizeEvent(
{
kind: Kind.ChannelCreation,
kind: ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -68,10 +66,7 @@ export const channelCreateEvent = (
)
}
export const channelMetadataEvent = (
t: ChannelMetadataEventTemplate,
privateKey: string,
): Event<Kind.ChannelMetadata> | undefined => {
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -81,9 +76,9 @@ export const channelMetadataEvent = (
return undefined
}
return finishEvent(
return finalizeEvent(
{
kind: Kind.ChannelMetadata,
kind: ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -92,16 +87,16 @@ export const channelMetadataEvent = (
)
}
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: string): Event<Kind.ChannelMessage> => {
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finishEvent(
return finalizeEvent(
{
kind: Kind.ChannelMessage,
kind: ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at,
@@ -113,8 +108,8 @@ export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey:
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: string,
): Event<Kind.ChannelHideMessage> | undefined => {
privateKey: Uint8Array,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -124,9 +119,9 @@ export const channelHideMessageEvent = (
return undefined
}
return finishEvent(
return finalizeEvent(
{
kind: Kind.ChannelHideMessage,
kind: ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -135,10 +130,7 @@ export const channelHideMessageEvent = (
)
}
export const channelMuteUserEvent = (
t: ChannelMuteUserEventTemplate,
privateKey: string,
): Event<Kind.ChannelMuteUser> | undefined => {
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -148,9 +140,9 @@ export const channelMuteUserEvent = (
return undefined
}
return finishEvent(
return finalizeEvent(
{
kind: Kind.ChannelMuteUser,
kind: ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,

33
nip30.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip30.ts'
test('matchAll', () => {
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
expect([...result]).toEqual([
{
name: 'blobcat',
shortcode: ':blobcat:',
start: 6,
end: 15,
},
{
name: 'disputed',
shortcode: ':disputed:',
start: 16,
end: 26,
},
])
})
test('replaceAll', () => {
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
const result = replaceAll(content, ({ name }) => {
return `<img src="https://ditto.pub/emoji/${name}.png" />`
})
expect(result).toEqual(
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
)
})

51
nip30.ts Normal file
View File

@@ -0,0 +1,51 @@
/** Regex for a single emoji shortcode. */
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
/** Regex to find emoji shortcodes in content. */
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
/** Represents a Nostr custom emoji. */
export interface CustomEmoji {
/** The matched emoji name with colons. */
shortcode: `:${string}:`
/** The matched emoji name without colons. */
name: string
}
/** Match result for a custom emoji in text content. */
export interface CustomEmojiMatch extends CustomEmoji {
/** Index where the emoji begins in the text content. */
start: number
/** Index where the emoji ends in the text content. */
end: number
}
/** Find all custom emoji shortcodes. */
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [shortcode, name] = match
yield {
shortcode: shortcode as `:${string}:`,
name,
start: match.index!,
end: match.index! + shortcode.length,
}
} catch (_e) {
// do nothing
}
}
}
/** Replace all emoji shortcodes in the content. */
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
return content.replaceAll(regex(), (shortcode, name) => {
return replacer({
shortcode: shortcode as `:${string}:`,
name,
})
})
}

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, validateGithub } from './nip39.ts'

View File

@@ -1,26 +1,14 @@
import 'websocket-polyfill'
import { test, expect } from 'bun:test'
import { finishEvent } from './event.ts'
import { generatePrivateKey } from './keys.ts'
import { authenticate } from './nip42.ts'
import { relayInit } from './relay.ts'
import { makeAuthEvent } from './nip42.ts'
import { Relay } from './relay.ts'
test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
test('auth flow', async () => {
const relay = await Relay.connect('wss://nostr.wine')
return new Promise<void>(resolve => {
relay.on('auth', async challenge => {
await expect(
authenticate({
challenge,
relay,
sign: e => finishEvent(e, sk),
}),
).rejects.toBeTruthy()
relay.close()
resolve()
})
})
const auth = makeAuthEvent(relay.url, 'chachacha')
expect(auth.tags).toHaveLength(2)
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
expect(auth.kind).toEqual(22242)
})

View File

@@ -1,32 +1,17 @@
import { Kind, type EventTemplate, type Event } from './event.ts'
import { Relay } from './relay.ts'
import { EventTemplate } from './core.ts'
import { ClientAuth } from './kinds.ts'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
* creates an EventTemplate for an AUTH event to be signed.
*/
export const authenticate = async ({
challenge,
relay,
sign,
}: {
challenge: string
relay: Relay
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
return {
kind: ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['relay', relayURL],
['challenge', challenge],
],
content: '',
}
return relay.auth(await sign(e))
}

View File

@@ -1,21 +1,44 @@
import crypto from 'node:crypto'
import { hexToBytes } from '@noble/hashes/utils'
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
import { encrypt, decrypt, getSharedSecret } from './nip44.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let sharedKey1 = getSharedSecret(sk1, pk2)
let sharedKey2 = getSharedSecret(sk2, pk1)
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
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)/)
}
})

149
nip44.ts
View File

@@ -1,40 +1,131 @@
import { base64 } from '@scure/base'
import { randomBytes } from '@noble/hashes/utils'
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 { xchacha20 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
const decoder = new TextDecoder()
const u = {
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
utf8Encode: utf8ToBytes,
utf8Decode(bytes: Uint8Array) {
return decoder.decode(bytes)
},
export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error('NIP44: unknown encryption version')
}
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
},
const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
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),
}
},
const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
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)
},
return base64.encode(payload)
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),
}
},
}
export function decrypt(key: Uint8Array, payload: string) {
let data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
return utf8Decoder.decode(plaintext)
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
View 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鷗Œé逍Üߪąñ丂㐀𠀀",
"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"
}
]
}
}
}

View File

@@ -1,7 +1,9 @@
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { Kind } from './event'
import { decrypt } from './nip04.ts'
import crypto from 'node:crypto'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
@@ -43,19 +45,11 @@ describe('parseConnectionString', () => {
describe('makeNwcRequestEvent', () => {
test('returns a valid NWC request event', async () => {
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
const secret = '71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
const invoice =
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
const timeBefore = Date.now() / 1000
const result = await makeNwcRequestEvent({
pubkey,
secret,
invoice,
})
const timeAfter = Date.now() / 1000
expect(result.kind).toBe(Kind.NwcRequest)
expect(result.created_at).toBeGreaterThan(timeBefore)
expect(result.created_at).toBeLessThan(timeAfter)
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
expect(result.kind).toBe(NWCWalletRequest)
expect(await decrypt(secret, pubkey, result.content)).toEqual(
JSON.stringify({
method: 'pay_invoice',

View File

@@ -1,6 +1,6 @@
import { finishEvent } from './event.ts'
import { finalizeEvent } from './pure.ts'
import { NWCWalletRequest } from './kinds.ts'
import { encrypt } from './nip04.ts'
import { Kind } from './event'
export function parseConnectionString(connectionString: string) {
const { pathname, searchParams } = new URL(connectionString)
@@ -15,28 +15,20 @@ export function parseConnectionString(connectionString: string) {
return { pubkey, relay, secret }
}
export async function makeNwcRequestEvent({
pubkey,
secret,
invoice,
}: {
pubkey: string
secret: string
invoice: string
}) {
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
const content = {
method: 'pay_invoice',
params: {
invoice,
},
}
const encryptedContent = await encrypt(secret, pubkey, JSON.stringify(content))
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
const eventTemplate = {
kind: Kind.NwcRequest,
kind: NWCWalletRequest,
created_at: Math.round(Date.now() / 1000),
content: encryptedContent,
tags: [['p', pubkey]],
}
return finishEvent(eventTemplate, secret)
return finalizeEvent(eventTemplate, secretKey)
}

View File

@@ -1,5 +1,6 @@
import { finishEvent } from './event.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
import { describe, test, expect, mock } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
@@ -12,7 +13,7 @@ describe('getZapEndpoint', () => {
})
test('returns null if fetch fails', async () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
@@ -23,7 +24,7 @@ describe('getZapEndpoint', () => {
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = jest.fn(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
@@ -34,7 +35,7 @@ describe('getZapEndpoint', () => {
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
const fetchImplementation = mock(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
@@ -94,9 +95,9 @@ describe('makeZapRequest', () => {
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
@@ -121,7 +122,7 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
@@ -140,9 +141,8 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -159,9 +159,8 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -179,10 +178,10 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -201,10 +200,10 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -221,10 +220,10 @@ describe('validateZapRequest', () => {
})
test('returns null for a valid Zap request', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -244,11 +243,11 @@ describe('validateZapRequest', () => {
describe('makeZapReceipt', () => {
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -271,18 +270,22 @@ describe('makeZapReceipt', () => {
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
['preimage', preimage],
]),
)
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -304,9 +307,13 @@ describe('makeZapReceipt', () => {
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')
})
})

View File

@@ -1,6 +1,6 @@
import { bech32 } from '@scure/base'
import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts'
var _fetch: any
@@ -13,7 +13,7 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(metadata: Event<Kind.Metadata>): Promise<null | string> {
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
try {
let lnurl: string = ''
let { lud06, lud16 } = JSON.parse(metadata.content)
@@ -53,11 +53,11 @@ export function makeZapRequest({
amount: number
comment: string
relays: string[]
}): EventTemplate<Kind.ZapRequest> {
}): EventTemplate {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate<Kind.ZapRequest> = {
let zr: EventTemplate = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
@@ -86,7 +86,7 @@ export function validateZapRequest(zapRequestString: string): string | null {
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
@@ -111,11 +111,11 @@ export function makeZapReceipt({
preimage?: string
bolt11: string
paidAt: Date
}): EventTemplate<Kind.Zap> {
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
}): EventTemplate {
let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
let zap: EventTemplate<Kind.Zap> = {
let zap: EventTemplate = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',

View File

@@ -1,18 +1,23 @@
import { describe, test, expect } from 'bun:test'
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import { Event, Kind, finishEvent } from './event.ts'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { Event, finalizeEvent } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { HTTPAuth } from './kinds.ts'
const sk = generatePrivateKey()
const sk = generateSecretKey()
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
@@ -21,13 +26,13 @@ describe('getToken', () => {
})
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
@@ -38,7 +43,7 @@ describe('getToken', () => {
test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr '
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
expect(result.startsWith(authorizationScheme)).toBe(true)
@@ -46,7 +51,7 @@ describe('getToken', () => {
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
@@ -54,27 +59,35 @@ describe('getToken', () => {
])
})
test('getToken missing loginUrl throws an error', async () => {
const result = getToken('', 'get', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken returns token with a valid payload tag when payload is present', async () => {
const payload = { test: 'payload' }
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
test('getToken missing httpMethod throws an error', async () => {
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
])
})
})
describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
@@ -82,30 +95,30 @@ describe('validateToken', () => {
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
@@ -113,18 +126,34 @@ describe('validateToken', () => {
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
expect(result).toBe(true)
})
test('validateEvent returns false for invalid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(result).rejects.toThrow(Error)
})
})

View File

@@ -1,9 +1,17 @@
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { base64 } from '@scure/base'
import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
import { utf8Decoder, utf8Encoder } from './utils'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import { HTTPAuth } from './kinds.ts'
const _authorizationScheme = 'Nostr '
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Generate token for NIP-98 flow.
*
@@ -14,22 +22,27 @@ const _authorizationScheme = 'Nostr '
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
sign: (e: EventTemplate) => Promise<Event> | Event,
includeAuthorizationScheme: boolean = false,
payload?: Record<string, any>,
): Promise<string> {
if (!loginUrl || !httpMethod) throw new Error('Missing loginUrl or httpMethod')
const event: EventTemplate = {
kind: HTTPAuth,
tags: [
['u', loginUrl],
['method', httpMethod],
],
created_at: Math.round(new Date().getTime() / 1000),
content: '',
}
const event = getBlankEvent(Kind.HttpAuth)
event.tags = [
['u', loginUrl],
['method', httpMethod],
]
event.created_at = Math.round(new Date().getTime() / 1000)
if (payload) {
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
}
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
}
@@ -66,14 +79,14 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
return event
}
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}
if (!verifySignature(event)) {
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.kind !== Kind.HttpAuth) {
if (event.kind !== HTTPAuth) {
throw new Error('Invalid nostr event, kind invalid')
}
@@ -96,5 +109,13 @@ export async function validateEvent(event: Event, url: string, method: string):
throw new Error('Invalid nostr event, method tag invalid')
}
if (Boolean(body) && Object.keys(body).length > 0) {
const payloadTag = event.tags.find(t => t[0] === 'payload')
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
if (payloadTag?.[1] !== payloadHash) {
throw new Error('Invalid payload tag hash, does not match request body hash')
}
}
return true
}

View File

@@ -1,30 +1,181 @@
{
"type": "module",
"name": "nostr-tools",
"version": "1.15.0",
"version": "2.1.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git"
},
"files": [
"./lib/**/*"
"lib"
],
"types": "./lib/index.d.ts",
"main": "lib/nostr.cjs.js",
"module": "lib/esm/nostr.mjs",
"sideEffects": false,
"module": "./lib/esm/index.js",
"main": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts",
"exports": {
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js",
"types": "./lib/index.d.ts"
".": {
"import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./pure": {
"import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts"
},
"./wasm": {
"import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts"
},
"./kinds": {
"import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./abstract-relay": {
"import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts"
},
"./relay": {
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./abstract-pool": {
"import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.d.ts"
},
"./pool": {
"import": "./lib/esm/pool.js",
"require": "./lib/cjs/pool.js",
"types": "./lib/types/pool.d.ts"
},
"./references": {
"import": "./lib/esm/references.js",
"require": "./lib/cjs/references.js",
"types": "./lib/types/references.d.ts"
},
"./nip04": {
"import": "./lib/esm/nip04.js",
"require": "./lib/cjs/nip04.js",
"types": "./lib/types/nip04.d.ts"
},
"./nip44": {
"import": "./lib/esm/nip44.js",
"require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts"
},
"./nip05": {
"import": "./lib/esm/nip05.js",
"require": "./lib/cjs/nip05.js",
"types": "./lib/types/nip05.d.ts"
},
"./nip06": {
"import": "./lib/esm/nip06.js",
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
},
"./nip10": {
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts"
},
"./nip11": {
"import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts"
},
"./nip13": {
"import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip18": {
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",
"types": "./lib/types/nip18.d.ts"
},
"./nip19": {
"import": "./lib/esm/nip19.js",
"require": "./lib/cjs/nip19.js",
"types": "./lib/types/nip19.d.ts"
},
"./nip21": {
"import": "./lib/esm/nip21.js",
"require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts"
},
"./nip25": {
"import": "./lib/esm/nip25.js",
"require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts"
},
"./nip27": {
"import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js",
"types": "./lib/types/nip27.d.ts"
},
"./nip28": {
"import": "./lib/esm/nip28.js",
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip30": {
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts"
},
"./nip39": {
"import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js",
"types": "./lib/types/nip39.d.ts"
},
"./nip42": {
"import": "./lib/esm/nip42.js",
"require": "./lib/cjs/nip42.js",
"types": "./lib/types/nip42.d.ts"
},
"./nip57": {
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip98": {
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./fakejson": {
"import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts"
},
"./utils": {
"import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js",
"types": "./lib/types/utils.d.ts"
}
},
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.2.0",
"@noble/curves": "1.1.0",
"@noble/ciphers": "0.2.0",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
"@scure/bip39": "1.2.1",
"mitata": "^0.1.6",
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@@ -41,31 +192,25 @@
"client",
"nostr"
],
"scripts": {
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.18",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.48.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.3",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"jest": "^29.5.0",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"ts-jest": "^29.1.0",
"tsd": "^0.22.0",
"typescript": "^5.0.4",
"websocket-polyfill": "^0.0.3"
"typescript": "^5.0.4"
},
"scripts": {
"prepublish": "just build && just emit-types"
}
}

View File

@@ -1,38 +1,32 @@
import 'websocket-polyfill'
import { test, expect, afterAll } from 'bun:test'
import { finishEvent, type Event } from './event.ts'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { finalizeEvent, type Event } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
let pool = new SimplePool()
let relays = [
'wss://relay.damus.io/',
'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/',
'wss://nos.lol/',
]
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
afterAll(() => {
pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
})
test('removing duplicates when querying', async () => {
let priv = generatePrivateKey()
test('removing duplicates when subscribing', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{ authors: [pub] }])
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be catched and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
})
let received: Event[] = []
sub.on('event', event => {
// this should be called only once even though we're listening
// to multiple relays because the events will be catched and
// deduplicated efficiently (without even being parsed)
received.push(event)
})
let event = finishEvent(
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
@@ -42,31 +36,31 @@ test('removing duplicates when querying', async () => {
priv,
)
pool.publish(relays, event)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double querying', async () => {
let priv = generatePrivateKey()
test('same with double subs', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
let sub1 = pool.sub(relays, [{ authors: [pub] }])
let sub2 = pool.sub(relays, [{ authors: [pub] }])
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
let received: Event[] = []
sub1.on('event', event => {
received.push(event)
})
sub2.on('event', event => {
received.push(event)
})
let event = finishEvent(
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
@@ -76,55 +70,47 @@ test('same with double querying', async () => {
priv,
)
pool.publish(relays, event)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
[{ kinds: [0, 1], limit: 40 }],
{
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
},
)
})
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
expect(events.size).toBeGreaterThan(50)
})
test('list()', async () => {
let events = await pool.list(
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
[
{
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
kinds: [1],
limit: 2,
},
],
)
test('querySync()', async () => {
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
kinds: [1],
limit: 2,
})
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toEqual(
events
.map(evt => evt.id)
// @ts-ignore ???
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []).length,
)
let relaysForAllEvents = events.map(event => pool.seenOn(event.id)).reduce((acc, n) => acc.concat(n), [])
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
expect(events.length).toBeGreaterThan(2)
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
expect(events).toHaveLength(uniqueEventCount)
})
test('seenOnEnabled: false', async () => {
const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
const event = await poolWithoutSeenOn.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
})
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
expect(relaysForEvent).toHaveLength(0)
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
})

253
pool.ts
View File

@@ -1,249 +1,10 @@
import { eventsGenerator, relayInit, type Relay, type Sub, type SubscriptionOptions } from './relay.ts'
import { normalizeURL } from './utils.ts'
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
import type { Event } from './event.ts'
import { matchFilters, mergeFilters, type Filter } from './filter.ts'
type BatchedRequest = {
filters: Filter<any>[]
relays: string[]
resolve: (events: Event<any>[]) => void
events: Event<any>[]
}
export class SimplePool {
private _conn: { [url: string]: Relay }
private _seenOn: { [id: string]: Set<string> } = {} // a map of all events we've seen in each relay
private batchedByKey: { [batchKey: string]: BatchedRequest[] } = {}
private eoseSubTimeout: number
private getTimeout: number
private seenOnEnabled: boolean = true
private batchInterval: number = 100
constructor(
options: {
eoseSubTimeout?: number
getTimeout?: number
seenOnEnabled?: boolean
batchInterval?: number
} = {},
) {
this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false
this.batchInterval = options.batchInterval || 100
}
close(relays: string[]): void {
relays.forEach(url => {
let relay = this._conn[normalizeURL(url)]
if (relay) relay.close()
})
}
async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url)
if (!this._conn[nm]) {
this._conn[nm] = relayInit(nm, {
getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9,
})
}
const relay = this._conn[nm]
await relay.connect()
return relay
}
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
let _knownIds: Set<string> = new Set()
let modifiedOpts = { ...(opts || {}) }
modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
if (this.seenOnEnabled) {
let set = this._seenOn[id] || new Set()
set.add(url)
this._seenOn[id] = set
}
return _knownIds.has(id)
}
let subs: Sub[] = []
let eventListeners: Set<any> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length
let eoseSent = false
let eoseTimeout = setTimeout(
() => {
eoseSent = true
for (let cb of eoseListeners.values()) cb()
},
opts?.eoseSubTimeout || this.eoseSubTimeout,
)
relays
.filter((r, i, a) => a.indexOf(r) === i)
.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', event => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
}
}
})
let greaterSub: Sub<K> = {
sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts))
return greaterSub as any
},
unsub() {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
},
get events() {
return eventsGenerator(greaterSub)
},
}
return greaterSub
}
get<K extends number = number>(
relays: string[],
filter: Filter<K>,
opts?: SubscriptionOptions,
): Promise<Event<K> | null> {
return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts)
let timeout = setTimeout(() => {
sub.unsub()
resolve(null)
}, this.getTimeout)
sub.on('event', event => {
resolve(event)
clearTimeout(timeout)
sub.unsub()
})
})
}
list<K extends number = number>(
relays: string[],
filters: Filter<K>[],
opts?: SubscriptionOptions,
): Promise<Event<K>[]> {
return new Promise(resolve => {
let events: Event<K>[] = []
let sub = this.sub(relays, filters, opts)
sub.on('event', event => {
events.push(event)
})
// we can rely on an eose being emitted here because pool.sub() will fake one
sub.on('eose', () => {
sub.unsub()
resolve(events)
})
})
}
batchedList<K extends number = number>(
batchKey: string,
relays: string[],
filters: Filter<K>[],
): Promise<Event<K>[]> {
return new Promise(resolve => {
if (!this.batchedByKey[batchKey]) {
this.batchedByKey[batchKey] = [
{
filters,
relays,
resolve,
events: [],
},
]
setTimeout(() => {
Object.keys(this.batchedByKey).forEach(async batchKey => {
const batchedRequests = this.batchedByKey[batchKey]
const filters = [] as Filter[]
const relays = [] as string[]
batchedRequests.forEach(br => {
filters.push(...br.filters)
relays.push(...br.relays)
})
const sub = this.sub(relays, [mergeFilters(...filters)])
sub.on('event', event => {
batchedRequests.forEach(br => matchFilters(br.filters, event) && br.events.push(event))
})
sub.on('eose', () => {
sub.unsub()
batchedRequests.forEach(br => br.resolve(br.events))
})
delete this.batchedByKey[batchKey]
})
}, this.batchInterval)
} else {
this.batchedByKey[batchKey].push({
filters,
relays,
resolve,
events: [],
})
}
})
}
publish(relays: string[], event: Event<number>): Promise<void>[] {
return relays.map(async relay => {
let r = await this.ensureRelay(relay)
return r.publish(event)
})
}
seenOn(id: string): string[] {
return Array.from(this._seenOn[id]?.values?.() || [])
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent })
}
}
export * from './abstract-pool.ts'

59
pure.ts Normal file
View File

@@ -0,0 +1,59 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
class JS implements Nostr {
generateSecretKey(): Uint8Array {
return schnorr.utils.randomPrivateKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(schnorr.getPublicKey(secretKey))
}
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
const event = t as VerifiedEvent
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
event.id = getEventHash(event)
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
event[verifiedSymbol] = true
return event
}
verifyEvent(event: Event): event is VerifiedEvent {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
event[verifiedSymbol] = false
return false
}
try {
const valid = schnorr.verify(event.sig, hash, event.pubkey)
event[verifiedSymbol] = valid
return valid
} catch (err) {
event[verifiedSymbol] = false
return false
}
}
}
export function serializeEvent(evt: UnsignedEvent): string {
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
}
export function getEventHash(event: UnsignedEvent): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const i = new JS()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent
export const verifyEvent = i.verifyEvent
export * from './core.ts'

View File

@@ -1,3 +1,4 @@
import { test, expect } from 'bun:test'
import { parseReferences } from './references.ts'
import { buildEvent } from './test-helpers.ts'

View File

@@ -1,6 +1,6 @@
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
import type { Event } from './event.ts'
import type { Event } from './core.ts'
type Reference = {
text: string

View File

@@ -1,124 +1,107 @@
import 'websocket-polyfill'
import { afterEach, expect, test } from 'bun:test'
import { finishEvent } from './event.ts'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { relayInit } from './relay.ts'
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay } from './relay.ts'
let relay = relayInit('wss://relay.damus.io/')
let relay = new Relay('wss://relay.nostr.bg')
beforeAll(() => {
relay.connect()
})
afterAll(() => {
afterEach(() => {
relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
}),
).resolves.toBe(true)
test('connectivity', async () => {
await relay.connect()
expect(relay.connected).toBeTrue()
})
test('connectivity, with Relay.connect()', async () => {
const relay = await Relay.connect('wss://public.relaying.io')
expect(relay.connected).toBeTrue()
relay.close()
})
test('querying', async () => {
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
await relay.connect()
let sub = relay.sub([
let resolveEvent: () => void
let resolveEose: () => void
const evented = new Promise<void>(resolve => {
resolveEvent = resolve
})
const eosed = new Promise<void>(resolve => {
resolveEose = resolve
})
relay.subscribe(
[
{
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
kinds: [0],
},
],
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
onevent(event) {
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
expect(event).toHaveProperty('kind', 0)
resolveEvent()
},
oneose() {
resolveEose()
},
},
])
sub.on('event', event => {
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
)
let [t1, t2] = await Promise.all([
new Promise<boolean>(resolve => {
resolve1 = resolve
}),
new Promise<boolean>(resolve => {
resolve2 = resolve
}),
])
expect(t1).toEqual(true)
expect(t2).toEqual(true)
await eosed
await evented
}, 10000)
test('async iterator', async () => {
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
])
test('listening and publishing and closing', async () => {
await relay.connect()
for await (const event of sub.events) {
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
break
}
})
test('get()', async () => {
let event = await relay.get({
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
})
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
})
test('list()', async () => {
let events = await relay.list([
{
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
kinds: [1],
limit: 2,
},
])
expect(events.length).toEqual(2)
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let sk = generateSecretKey()
let pk = getPublicKey(sk)
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
let resolveEose: (_: void) => void
let resolveEvent: (_: void) => void
let resolveClose: (_: void) => void
let eventReceived: NostrEvent | undefined
let sub = relay.sub([
const eosed = new Promise(resolve => {
resolveEose = resolve
})
const evented = new Promise(resolve => {
resolveEvent = resolve
})
const closed = new Promise(resolve => {
resolveClose = resolve
})
let sub = relay.subscribe(
[
{
kinds: [23571],
authors: [pk],
},
],
{
kinds: [27572],
authors: [pk],
onevent(event) {
eventReceived = event
resolveEvent()
},
oneose() {
resolveEose()
},
onclose() {
resolveClose()
},
},
])
)
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
await eosed
let event = finishEvent(
let event = finalizeEvent(
{
kind: 27572,
kind: 23571,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite',
@@ -126,15 +109,13 @@ test('listening (twice) and publishing', async () => {
sk,
)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
}),
]),
).resolves.toEqual([true, true])
await relay.publish(event)
await evented
sub.close()
await closed
expect(eventReceived).toBeDefined()
expect(eventReceived).toHaveProperty('pubkey', pk)
expect(eventReceived).toHaveProperty('kind', 23571)
expect(eventReceived).toHaveProperty('content', 'nostr-tools test suite')
})

405
relay.ts
View File

@@ -1,398 +1,23 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
import { verifySignature, validateEvent, type Event } from './event.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { MessageQueue } from './utils.ts'
type RelayEvent = {
connect: () => void | Promise<void>
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
auth: (challenge: string) => void | Promise<void>
}
export type CountPayload = {
count: number
}
export type SubEvent<K extends number> = {
event: (event: Event<K>) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
url: string
status: number
connect: () => Promise<void>
close: () => void
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
count: (filters: Filter[], opts?: SubscriptionOptions) => Promise<CountPayload | null>
publish: (event: Event<number>) => Promise<void>
auth: (event: Event<number>) => Promise<void>
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
}
export type Sub<K extends number = number> = {
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
unsub: () => void
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
events: AsyncGenerator<Event<K>, void, unknown>
/**
* @deprecated use Relay.connect() instead.
*/
export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
}
export type SubscriptionOptions = {
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
eoseSubTimeout?: number
}
const newListeners = (): { [TK in keyof RelayEvent]: RelayEvent[TK][] } => ({
connect: [],
disconnect: [],
error: [],
notice: [],
auth: [],
})
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
countTimeout?: number
} = {},
): Relay {
let { listTimeout = 3000, getTimeout = 3000, countTimeout = 3000 } = options
var ws: WebSocket
var openSubs: { [id: string]: { filters: Filter[] } & SubscriptionOptions } = {}
var listeners = newListeners()
var subListeners: {
[subid: string]: { [TK in keyof SubEvent<any>]: SubEvent<any>[TK][] }
} = {}
var pubListeners: {
[eventid: string]: {
resolve: (_: unknown) => void
reject: (err: Error) => void
}
} = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> {
if (connectionPromise) return connectionPromise
connectionPromise = new Promise((resolve, reject) => {
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolve()
}
ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb())
}
let incomingMessageQueue: MessageQueue = new MessageQueue()
let handleNextInterval: any
ws.onmessage = e => {
incomingMessageQueue.enqueue(e.data)
if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0)
}
}
function handleNext() {
if (incomingMessageQueue.size === 0) {
clearInterval(handleNextInterval)
handleNextInterval = null
return
}
var json = incomingMessageQueue.dequeue()
if (!json) return
let subid = getSubscriptionId(json)
if (subid) {
let so = openSubs[subid]
if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, 'id'), url)) {
return
}
}
try {
let data = JSON.parse(json)
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
let id = data[1]
let event = data[2]
if (
validateEvent(event) &&
openSubs[id] &&
(openSubs[id].skipVerification || verifySignature(event)) &&
matchFilters(openSubs[id].filters, event)
) {
openSubs[id]
;(subListeners[id]?.event || []).forEach(cb => cb(event))
}
return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': {
let id = data[1]
if (id in subListeners) {
subListeners[id].eose.forEach(cb => cb())
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
}
return
}
case 'OK': {
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (id in pubListeners) {
let { resolve, reject } = pubListeners[id]
if (ok) resolve(null)
else reject(new Error(reason))
}
return
}
case 'NOTICE':
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
}
} catch (err) {
return
}
}
})
return connectionPromise
export class Relay extends AbstractRelay {
constructor(url: string) {
super(url, { verifyEvent })
}
function connected() {
return ws?.readyState === 1
}
async function connect(): Promise<void> {
if (connected()) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000))
if (!connected()) {
return
}
}
try {
ws.send(msg)
} catch (err) {
console.log(err)
}
}
const sub = <K extends number = number>(
filters: Filter<K>[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2),
}: SubscriptionOptions = {},
): Sub<K> => {
let subid = id
openSubs[subid] = {
id: subid,
filters,
skipVerification,
alreadyHaveEvent,
}
trySend([verb, subid, ...filters])
let subscription: Sub<K> = {
sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification,
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
id: subid,
}),
unsub: () => {
delete openSubs[subid]
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: (type, cb) => {
subListeners[subid] = subListeners[subid] || {
event: [],
count: [],
eose: [],
}
subListeners[subid][type].push(cb)
},
off: (type, cb): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
},
get events() {
return eventsGenerator(subscription)
},
}
return subscription
}
function _publishEvent(event: Event<number>, type: string) {
return new Promise((resolve, reject) => {
if (!event.id) {
reject(new Error(`event ${event} has no id`))
return
}
let id = event.id
trySend([type, event])
pubListeners[id] = { resolve, reject }
})
}
return {
url,
sub,
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) {
// i would love to know why we need this
;(cb as () => void)()
}
},
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
list: (filters, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub(filters, opts)
let events: Event<any>[] = []
let timeout = setTimeout(() => {
s.unsub()
resolve(events)
}, listTimeout)
s.on('eose', () => {
s.unsub()
clearTimeout(timeout)
resolve(events)
})
s.on('event', event => {
events.push(event)
})
}),
get: (filter, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub([filter], opts)
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, getTimeout)
s.on('event', event => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
count: (filters: Filter[]): Promise<CountPayload | null> =>
new Promise(resolve => {
let s = sub(filters, { ...sub, verb: 'COUNT' })
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, countTimeout)
s.on('count', (event: CountPayload) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
async publish(event): Promise<void> {
await _publishEvent(event, 'EVENT')
},
async auth(event): Promise<void> {
await _publishEvent(event, 'AUTH')
},
connect,
close(): void {
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws?.readyState === WebSocket.OPEN) {
ws.close()
}
},
get status() {
return ws?.readyState ?? 3
},
static async connect(url: string) {
const relay = new Relay(url)
await relay.connect()
return relay
}
}
export async function* eventsGenerator<K extends number>(sub: Sub<K>): AsyncGenerator<Event<K>, void, unknown> {
let nextResolve: ((event: Event<K>) => void) | undefined
const eventQueue: Event<K>[] = []
const pushToQueue = (event: Event<K>) => {
if (nextResolve) {
nextResolve(event)
nextResolve = undefined
} else {
eventQueue.push(event)
}
}
sub.on('event', pushToQueue)
try {
while (true) {
if (eventQueue.length > 0) {
yield eventQueue.shift()!
} else {
const event = await new Promise<Event<K>>(resolve => {
nextResolve = resolve
})
yield event
}
}
} finally {
sub.off('event', pushToQueue)
}
}
export * from './abstract-relay.ts'

View File

@@ -1,12 +1,10 @@
import type { Event } from './event.ts'
type EventParams<K extends number> = Partial<Event<K>>
import type { Event } from './pure.ts'
/** Build an event for testing purposes. */
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
export function buildEvent(params: Partial<Event>): Event {
return {
id: '',
kind: 1 as K,
kind: 1,
pubkey: '',
created_at: 0,
content: '',

View File

@@ -9,8 +9,10 @@
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"outDir": "lib",
"allowImportingTsExtensions": true,
"outDir": "lib/types",
"resolveJsonModule": true,
"rootDir": ".",
"allowImportingTsExtensions": true
"types": ["bun-types"]
}
}

View File

@@ -1,7 +1,8 @@
import { describe, test, expect } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { MessageQueue, insertEventIntoAscendingList, insertEventIntoDescendingList } from './utils.ts'
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
import type { Event } from './event.ts'
import type { Event } from './core.ts'
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
@@ -213,29 +214,27 @@ describe('inserting into a asc sorted list of events', () => {
})
})
describe('enque a message into MessageQueue', () => {
test('enque into an empty queue', () => {
const queue = new MessageQueue()
describe('enqueue a message into MessageQueue', () => {
test('enqueue into an empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enque into a non-empty queue', () => {
const queue = new MessageQueue()
test('enqueue into a non-empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
expect(queue.size).toBe(3)
})
test('dequeue from an empty queue', () => {
const queue = new MessageQueue()
const queue = new Queue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
expect(queue.size).toBe(0)
})
test('dequeue from a non-empty queue', () => {
const queue = new MessageQueue()
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
@@ -245,15 +244,22 @@ describe('enque a message into MessageQueue', () => {
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
const queue = new MessageQueue()
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
expect(queue.size).toBe(0)
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})
test('binary search', () => {
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
})

207
utils.ts
View File

@@ -1,4 +1,4 @@
import type { Event } from './event.ts'
import type { Event } from './core.ts'
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
@@ -13,157 +13,104 @@ export function normalizeURL(url: string): string {
return p.toString()
}
//
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
//
export function insertEventIntoDescendingList(sortedArray: Event<number>[], event: Event<number>) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at < sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at >= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at > event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at < event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
return b.created_at - event.created_at
})
if (!found) {
sortedArray.splice(idx, 0, event)
}
return sortedArray
}
export function insertEventIntoAscendingList(sortedArray: Event<number>[], event: Event<number>) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at > sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at <= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at < event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at > event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
return event.created_at - b.created_at
})
if (!found) {
sortedArray.splice(idx, 0, event)
}
return sortedArray
}
export class MessageNode {
private _value: string
private _next: MessageNode | null
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
let start = 0
let end = arr.length - 1
public get value(): string {
return this._value
}
public set value(message: string) {
this._value = message
}
public get next(): MessageNode | null {
return this._next
}
public set next(node: MessageNode | null) {
this._next = node
while (start <= end) {
const mid = Math.floor((start + end) / 2)
const cmp = compare(arr[mid])
if (cmp === 0) {
return [mid, true]
}
if (cmp < 0) {
end = mid - 1
} else {
start = mid + 1
}
}
constructor(message: string) {
this._value = message
this._next = null
return [start, false]
}
export class QueueNode<V> {
public value: V
public next: QueueNode<V> | null = null
public prev: QueueNode<V> | null = null
constructor(message: V) {
this.value = message
}
}
export class MessageQueue {
private _first: MessageNode | null
private _last: MessageNode | null
public get first(): MessageNode | null {
return this._first
}
public set first(messageNode: MessageNode | null) {
this._first = messageNode
}
public get last(): MessageNode | null {
return this._last
}
public set last(messageNode: MessageNode | null) {
this._last = messageNode
}
private _size: number
public get size(): number {
return this._size
}
public set size(v: number) {
this._size = v
}
export class Queue<V> {
public first: QueueNode<V> | null
public last: QueueNode<V> | null
constructor() {
this._first = null
this._last = null
this._size = 0
this.first = null
this.last = null
}
enqueue(message: string): boolean {
const newNode = new MessageNode(message)
if (this._size === 0 || !this._last) {
this._first = newNode
this._last = newNode
enqueue(value: V): boolean {
const newNode = new QueueNode(value)
if (!this.last) {
// list is empty
this.first = newNode
this.last = newNode
} else if (this.last === this.first) {
// list has a single element
this.last = newNode
this.last.prev = this.first
this.first.next = newNode
} else {
this._last.next = newNode
this._last = newNode
// list has elements, add as last
newNode.prev = this.last
this.last.next = newNode
this.last = newNode
}
this._size++
return true
}
dequeue(): string | null {
if (this._size === 0 || !this._first) return null
let prev = this._first
this._first = prev.next
prev.next = null
dequeue(): V | null {
if (!this.first) return null
this._size--
return prev.value
if (this.first === this.last) {
const target = this.first
this.first = null
this.last = null
return target.value
}
const target = this.first
this.first = target.next
return target.value
}
}

38
wasm.ts Normal file
View File

@@ -0,0 +1,38 @@
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr as NostrWasm } from 'nostr-wasm'
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
let nw: NostrWasm
export function setNostrWasm(x: NostrWasm) {
nw = x
}
class Wasm implements Nostr {
generateSecretKey(): Uint8Array {
return nw.generateSecretKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(nw.getPublicKey(secretKey))
}
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
nw.finalizeEvent(t as any, secretKey)
return t as VerifiedEvent
}
verifyEvent(event: Event): event is VerifiedEvent {
try {
nw.verifyEvent(event)
event[verifiedSymbol] = true
return true
} catch (err) {
return false
}
}
}
const i = new Wasm()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent
export const verifyEvent = i.verifyEvent
export * from './core.ts'

4020
yarn.lock

File diff suppressed because it is too large Load Diff