Compare commits

..

2 Commits

Author SHA1 Message Date
fiatjaf
52cd6490fe max keys 256. 2023-04-04 12:33:23 -03:00
fiatjaf
3248b8b166 add nip41: stateless revocations. 2023-04-04 12:33:20 -03:00
95 changed files with 3543 additions and 6086 deletions

View File

@@ -1,9 +1,8 @@
{
"root": true,
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"],
"plugins": ["@typescript-eslint"],
"parserOptions": {
"ecmaVersion": 9,
@@ -19,8 +18,11 @@
"node": true
},
"plugins": ["babel"],
"globals": {
"document": false,
"BigInt": false,
"navigator": false,
"window": false,
"crypto": false,
@@ -35,22 +37,23 @@
"rules": {
"accessor-pairs": 2,
"arrow-spacing": [2, { "before": true, "after": true }],
"arrow-spacing": [2, {"before": true, "after": true}],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
"comma-dangle": 0,
"comma-spacing": [2, { "before": false, "after": true }],
"comma-spacing": [2, {"before": false, "after": true}],
"comma-style": [2, "last"],
"constructor-super": 2,
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, {"before": true, "after": true}],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, { "before": true, "after": true }],
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
"keyword-spacing": [2, {"before": true, "after": true}],
"new-cap": 0,
"new-parens": 0,
"no-array-constructor": 2,
@@ -82,12 +85,12 @@
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
"no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 2 }],
"no-multiple-empty-lines": [2, {"max": 2}],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 0,
@@ -101,6 +104,7 @@
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
@@ -115,23 +119,34 @@
"no-undef": 2,
"no-undef-init": 2,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
"no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-unused-vars": [
2,
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
"one-var": [0, { "initialized": "never" }],
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"one-var": [0, {"initialized": "never"}],
"operator-linebreak": [
2,
"after",
{"overrides": {"?": "before", ":": "before"}}
],
"padded-blocks": [2, "never"],
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
"quotes": [
2,
"single",
{"avoidEscape": true, "allowTemplateLiterals": true}
],
"semi": [2, "never"],
"semi-spacing": [2, { "before": false, "after": true }],
"semi-spacing": [2, {"before": false, "after": true}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"space-unary-ops": [2, {"words": true, "nonwords": false}],
"spaced-comment": 0,
"template-curly-spacing": [2, "never"],
"use-isnan": 2,

23
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
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,15 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: bun i
- run: just install-dependencies
- run: just build
- run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: bun i
- run: just lint

1
.gitignore vendored
View File

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

View File

@@ -1,9 +1,10 @@
arrowParens: avoid
bracketSpacing: true
insertPragma: false
printWidth: 120
proseWrap: preserve
semi: false
arrowParens: avoid
insertPragma: false
printWidth: 80
proseWrap: preserve
singleQuote: true
trailingComma: all
trailingComma: none
useTabs: false
jsxBracketSameLine: false
bracketSpacing: false

24
LICENSE
View File

@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

292
README.md
View File

@@ -1,92 +1,123 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools
# nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
## Installation
```bash
npm install nostr-tools # or yarn add nostr-tools
```
If using TypeScript, this package requires TypeScript >= 5.0.
## Usage
### Generating a private key and a public key
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generateSecretKey() // `sk` is a Uint8Array
let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import { finalizeEvent, verifyEvent } from 'nostr-tools'
import {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = finalizeEvent({
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
}, sk)
pubkey: getPublicKey(privateKey)
}
let isGood = verifyEvent(event)
event.id = getEventHash(event)
event.sig = signEvent(event, privateKey)
let ok = validateEvent(event)
let veryOk = verifySignature(event)
```
### Interacting with a relay
```js
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
import {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} from 'nostr-tools'
const relay = await Relay.connect('wss://relay.example.com')
console.log(`connected to ${relay.url}`)
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()
// let's query for an event that exists
const sub = relay.subscribe([
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
], {
onevent(event) {
console.log('we got the event we wanted:', event)
},
oneose() {
sub.close()
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey()
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
relay.sub([
let sub = relay.sub([
{
kinds: [1],
authors: [pk],
},
], {
onevent(event) {
console.log('got event:', event)
authors: [pk]
}
])
sub.on('event', event => {
console.log('got event:', event)
})
let eventTemplate = {
let event = {
kind: 1,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world',
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = signEvent(event, sk)
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent)
let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`${relay.url} has accepted our event`)
})
pub.on('failed', reason => {
console.log(`failed to publish to ${relay.url}: ${reason}`)
})
let events = await relay.list([{kinds: [0, 1]}])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
relay.close()
```
@@ -100,48 +131,54 @@ import 'websocket-polyfill'
### Interacting with multiple relays
```js
import { SimplePool } from 'nostr-tools'
import {SimplePool} from 'nostr-tools'
const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let h = pool.subscribeMany(
let sub = pool.sub(
[...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()
authors: [
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
}
]
)
await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!')
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
let pubs = pool.publish(relays, newEvent)
pubs.on('ok', () => {
// this may be called multiple times, once for every relay that accepts the event
// ...
})
let events = await pool.list(relays, [{kinds: [0, 1]}])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
let relaysForEvent = pool.seenOn(
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
// relaysForEvent will be an array of URLs from relays a given event was seen on
```
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
import { parseReferences } from 'nostr-tools'
import {parseReferences} from 'nostr-tools'
let references = parseReferences(event)
let simpleAugmentedContent = event.content
for (let i = 0; i < references.length; i++) {
let { text, profile, event, address } = references[i]
let {text, profile, event, address} = references[i]
let augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event
@@ -156,7 +193,7 @@ for (let i = 0; i < references.length; i++) {
### Querying profile data from a NIP-05 address
```js
import { nip05 } from 'nostr-tools'
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
@@ -165,7 +202,7 @@ console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
To use this on Node.js you first must install `node-fetch@2` and call something like this:
```js
nip05.useFetchImplementation(require('node-fetch'))
@@ -174,103 +211,118 @@ nip05.useFetchImplementation(require('node-fetch'))
### Encoding and decoding NIP-19 codes
```js
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generateSecretKey()
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let { type, data } = nip19.decode(nsec)
let {type, data} = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let { type, data } = nip19.decode(npub)
let {type, data} = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(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)
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
## Import modes
### Using just the packages you want
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
### Encrypting and decrypting direct messages
```js
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { Relay, Subscription } from 'nostr-tools/relay'
import { matchFilter } from 'nostr-tools/filter'
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
// and so on and so forth
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = await nip04.encrypt(sk1, pk2, message)
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties
}
sendEvent(event)
// on the receiver side
sub.on('event', event => {
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
```
### 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.
### Performing and checking for delegation
```js
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
initNostrWasm().then(setNostrWasm)
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
// see https://www.npmjs.com/package/nostr-wasm for options
// 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
```
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
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### 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.generateSecretKey('...') // and so on
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
1. Install [`just`](https://just.systems/)
2. `just -l`
## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
Public domain.

View File

@@ -1,190 +0,0 @@
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)
})
}
}

View File

@@ -1,359 +0,0 @@
/* 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
}

View File

@@ -1,61 +0,0 @@
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,60 +1,47 @@
const fs = require('node:fs')
const esbuild = require('esbuild')
const { join } = require('path')
#!/usr/bin/env node
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(),
)
const fs = require('fs')
const esbuild = require('esbuild')
let common = {
entryPoints,
entryPoints: ['index.ts'],
bundle: true,
sourcemap: 'external',
sourcemap: 'external'
}
esbuild
.build({
...common,
outdir: 'lib/esm',
outfile: 'lib/esm/nostr.mjs',
format: 'esm',
packages: 'external',
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outdir: 'lib/cjs',
format: 'cjs',
packages: 'external',
packages: 'external'
})
.then(() => {
const packageJson = JSON.stringify({ type: 'commonjs' })
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
const packageJson = JSON.stringify({type: 'module'})
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('cjs build success.')
console.log('esm build success.')
})
esbuild
.build({
...common,
entryPoints: ['index.ts'],
outfile: 'lib/nostr.cjs.js',
format: 'cjs',
packages: 'external'
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}',
},
process: '{"env": {}}'
}
})
.then(() => console.log('standalone build success.'))

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,293 +0,0 @@
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
View File

@@ -1,51 +0,0 @@
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
}

340
event.test.js Normal file
View File

@@ -0,0 +1,340 @@
const {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
signEvent,
getPublicKey,
Kind
} = require('./lib/nostr.cjs')
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
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(() => {
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115 // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey'
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
// tamper with the signature
event.sig = event.sig.replace(/0/g, '1')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 =
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey1
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2
})
expect(isValid).toEqual(false)
})
})
describe('signEvent', () => {
it('should sign 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 = signEvent(unsignedEvent, privateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey =
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = signEvent(unsignedEvent, wrongPrivateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

114
event.ts Normal file
View File

@@ -0,0 +1,114 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Reaction = 7,
StatelessRevocation = 13,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
Article = 30023
}
export type EventTemplate = {
kind: Kind
tags: string[][]
content: string
created_at: number
}
export type UnsignedEvent = EventTemplate & {
pubkey: string
}
export type Event = UnsignedEvent & {
id: string
sig: string
}
export function getBlankEvent(): EventTemplate {
return {
kind: 255,
content: '',
tags: [],
created_at: 0
}
}
export function finishEvent(t: EventTemplate, privateKey: string): Event {
let event = t as Event
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = signEvent(event, privateKey)
return event
}
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 secp256k1.utils.bytesToHex(eventHash)
}
export function validateEvent(event: UnsignedEvent): boolean {
if (typeof event !== 'object') return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event: Event & {sig: string}): boolean {
return secp256k1.schnorr.verifySync(
event.sig,
getEventHash(event),
event.pubkey
)
}
export function signEvent(event: UnsignedEvent, key: string): string {
return secp256k1.utils.bytesToHex(
secp256k1.schnorr.signSync(getEventHash(event), key)
)
}

View File

@@ -1,44 +1,49 @@
import { test, expect } from 'bun:test'
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
/* eslint-env jest */
const {fj} = require('./lib/nostr.cjs')
test('match id', () => {
expect(
matchEventId(
fj.matchEventId(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
),
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
)
).toBeTruthy()
expect(
matchEventId(
fj.matchEventId(
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
),
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
)
).toBeFalsy()
})
test('match kind', () => {
expect(
matchEventKind(
fj.matchEventKind(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
1,
),
1
)
).toBeTruthy()
expect(
matchEventKind(
fj.matchEventKind(
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
12720,
),
12720
)
).toBeTruthy()
})
test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
'kasjbdjkav'
)
expect(
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
fj.getSubscriptionId(
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
)
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
})

176
filter.test.js Normal file
View File

@@ -0,0 +1,176 @@
/* eslint-env jest */
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
describe('Filter', () => {
describe('matchFilter', () => {
it('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
authors: ['abc'],
since: 100,
until: 200,
'#tag': ['value']
}
const event = {
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [['tag', 'value']]
}
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']}
const event = {id: '789'}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event kind is not in the filter', () => {
const filter = {kinds: [1, 2, 3]}
const event = {kind: 4}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event author is not in the filter', () => {
const filter = {authors: ['abc', 'def']}
const event = {pubkey: 'ghi'}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = {tags: [['not_tag', 'value1']]}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag value is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = {tags: [['tag', 'value3']]}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when filter has tags that is present in the event', () => {
const filter = {'#tag1': ['foo']}
const event = {
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [
['tag1', 'foo'],
['tag2', 'bar']
]
}
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event is before the filter since value', () => {
const filter = {since: 100}
const event = {created_at: 50}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event is after the filter until value', () => {
const filter = {until: 100}
const event = {created_at: 150}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
})
describe('matchFilters', () => {
it('should return true when at least one filter matches the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = {id: '789', kind: 3, pubkey: 'ghi'}
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return false when no filters match the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = {id: '100', kind: 4, pubkey: 'jkl'}
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
it('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
})

View File

@@ -1,267 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { buildEvent } from './test-helpers.ts'
describe('Filter', () => {
describe('matchFilter', () => {
test('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
authors: ['abc'],
since: 100,
until: 200,
'#tag': ['value'],
}
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [['tag', 'value']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event id is not in the filter', () => {
const filter = { ids: ['123', '456'] }
const event = buildEvent({ id: '789' })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return true when the event id starts with a prefix', () => {
const filter = { ids: ['22', '00'] }
const event = buildEvent({ id: '001' })
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event kind is not in the filter', () => {
const filter = { kinds: [1, 2, 3] }
const event = buildEvent({ kind: 4 })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return false when the event author is not in the filter', () => {
const filter = { authors: ['abc', 'def'] }
const event = buildEvent({ pubkey: 'ghi' })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return false when a tag is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['not_tag', 'value1']] })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return false when a tag value is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['tag', 'value3']] })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return true when filter has tags that is present in the event', () => {
const filter = { '#tag1': ['foo'] }
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [
['tag1', 'foo'],
['tag2', 'bar'],
],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event is before the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 50 })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 100 })
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event is after the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 150 })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 100 })
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
})
describe('matchFilters', () => {
test('should return true when at least one filter matches the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
]
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
test('should return true when at least one prefix matches the event', () => {
const filters = [
{ ids: ['1'], kinds: [1], authors: ['a'] },
{ ids: ['4'], kinds: [2], authors: ['d'] },
{ ids: ['9'], kinds: [3], authors: ['g'] },
]
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
test('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
{ authors: ['abc'], limit: 3 },
]
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
})
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
test('should return false when no filters match the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
]
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
test('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
{ authors: ['abc'], limit: 3 },
]
const event = buildEvent({
id: '456',
kind: 2,
pubkey: 'def',
created_at: 200,
})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
describe('mergeFilters', () => {
test('should merge filters', () => {
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
ids: ['a', 'b', 'c'],
limit: 3,
authors: ['x'],
})
expect(
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
})
})
describe('getFilterLimit', () => {
test('should handle ids', () => {
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
})
test('should count the authors times replaceable kinds', () => {
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
})
test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
})
test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity)
})
})
})

View File

@@ -1,5 +1,4 @@
import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts'
import {Event} from './event'
export type Filter = {
ids?: string[]
@@ -9,81 +8,44 @@ export type Filter = {
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[] | undefined
[key: `#${string}`]: string[]
}
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
}
}
export function matchFilter(
filter: Filter,
event: Event & {id: string}
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
if (
values &&
!event.tags.find(
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at > filter.until) return false
if (filter.until && event.created_at >= filter.until) return false
return true
}
export function matchFilters(filters: Filter[], event: Event): boolean {
export function matchFilters(
filters: Filter[],
event: Event & {id: string}
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}
export function mergeFilters(...filters: Filter[]): Filter {
let result: Filter = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
// @ts-ignore
result[property] = result[property] || []
// @ts-ignore
for (let v = 0; v < values.length; v++) {
// @ts-ignore
let value = values[v]
// @ts-ignore
if (!result[property].includes(value)) result[property].push(value)
}
}
})
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
}
return result
}
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0
if (filter.authors && !filter.authors.length) return 0
return Math.min(
Math.max(0, filter.limit ?? Infinity),
filter.ids?.length ?? Infinity,
filter.authors?.length &&
filter.kinds?.every((kind) => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
)
}

View File

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

View File

@@ -1,28 +1,28 @@
export * from './pure.ts'
export * from './relay.ts'
export * from './filter.ts'
export * from './pool.ts'
export * from './references.ts'
export * from './keys'
export * from './relay'
export * from './event'
export * from './filter'
export * from './pool'
export * from './references'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip30 from './nip30.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip10 from './nip10'
export * as nip19 from './nip19'
export * as nip26 from './nip26'
export * as nip39 from './nip39'
export * as nip41 from './nip41'
export * as nip57 from './nip57'
export * as kinds from './kinds.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'
export * as fj from './fakejson'
export * as utils from './utils'
// monkey patch secp256k1
import * as secp256k1 from '@noble/secp256k1'
import {hmac} from '@noble/hashes/hmac'
import {sha256} from '@noble/hashes/sha256'
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
secp256k1.utils.sha256Sync = (...msgs) =>
sha256(secp256k1.utils.concatBytes(...msgs))

View File

@@ -1,32 +1,23 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
bun run build.js
rm -rf lib
node build.js
test:
bun test --timeout 20000
test: build
jest
test-only file:
bun test {{file}}
test-only file: build
jest {{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
lint:
eslint --ext .ts *.ts
prettier --check *.ts
benchmark:
bun build --target=node --outfile=bench.js benchmarks.ts
timeout 60s deno run --allow-read bench.js || true
timeout 60s node bench.js || true
timeout 60s bun run benchmarks.ts || true
rm bench.js
prettier --plugin-search-dir . --write .

20
keys.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('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)
}
})

9
keys.ts Normal file
View File

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

View File

@@ -1,21 +0,0 @@
import { test, expect } from 'bun:test'
import { classifyKind } from './kinds.ts'
test('kind classification', () => {
expect(classifyKind(1)).toBe('regular')
expect(classifyKind(5)).toBe('regular')
expect(classifyKind(6)).toBe('regular')
expect(classifyKind(7)).toBe('regular')
expect(classifyKind(1000)).toBe('regular')
expect(classifyKind(9999)).toBe('regular')
expect(classifyKind(0)).toBe('replaceable')
expect(classifyKind(3)).toBe('replaceable')
expect(classifyKind(10000)).toBe('replaceable')
expect(classifyKind(19999)).toBe('replaceable')
expect(classifyKind(20000)).toBe('ephemeral')
expect(classifyKind(29999)).toBe('ephemeral')
expect(classifyKind(30000)).toBe('parameterized')
expect(classifyKind(39999)).toBe('parameterized')
expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown')
})

105
kinds.ts
View File

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

15
nip04.test.js Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@@ -1,50 +0,0 @@
import { test, expect } from 'bun:test'
import crypto from 'node:crypto'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
try {
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
} catch (err) {
/***/
}
test('encrypt and decrypt message', async () => {
let sk1 = generateSecretKey()
let sk2 = generateSecretKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
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,41 +1,61 @@
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { base64 } from '@scure/base'
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
import {base64} from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import {utf8Decoder, utf8Encoder} from './utils'
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
)
let ciphertext = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
export async function decrypt(
privkey: string,
pubkey: string,
data: string
): Promise<string> {
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text

20
nip05.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip05} = require('./lib/nostr.cjs')
test('fetch nip05 profiles', async () => {
nip05.useFetchImplementation(fetch)
let p1 = await nip05.queryProfile('jb55.com')
expect(p1.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1.relays).toEqual(['wss://relay.damus.io'])
let p2 = await nip05.queryProfile('jb55@jb55.com')
expect(p2.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
})

View File

@@ -1,20 +0,0 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile } from './nip05.ts'
test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch)
let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
})

View File

@@ -1,13 +1,4 @@
import { ProfilePointer } from './nip19.ts'
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
* - 0: full match
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
import {ProfilePointer} from './nip19'
var _fetch: any
@@ -19,9 +10,14 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
export async function searchDomain(
domain: string,
query = ''
): Promise<{[name: string]: string}> {
try {
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
).json()
return res.names
} catch (_) {
@@ -29,53 +25,36 @@ export async function searchDomain(domain: string, query = ''): Promise<{ [name:
}
}
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
export async function queryProfile(
fullname: string
): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@')
const [_, name = '_', domain] = match
if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
domain = name
name = '_'
}
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
if (!domain.includes('.')) return null
let res
try {
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { names, relays } = parseNIP05Result(await res.json())
const pubkey = names[name]
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
} catch (err) {
return null
}
}
/** nostr.json result. */
export interface NIP05Result {
names: {
[name: string]: string
}
relays?: {
[pubkey: string]: string[]
if (!res?.names?.[name]) return null
let pubkey = res.names[name] as string
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
return {
pubkey,
relays
}
}
/** Parse the nostr.json and throw if it's not valid. */
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) {
result.relays = {}
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result
}

19
nip06.test.js Normal file
View File

@@ -0,0 +1,19 @@
/* eslint-env jest */
const {nip06} = require('./lib/nostr.cjs')
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual(
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
)
})
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 = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual(
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
)
})

View File

@@ -1,28 +0,0 @@
import { test, expect } from 'bun:test'
import { privateKeyFromSeedWords } from './nip06.ts'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
})
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

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

353
nip10.test.js Normal file
View File

@@ -0,0 +1,353 @@
/* eslint-env jest */
const {nip10} = require('./lib/nostr.cjs')
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'e',
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
],
[
'e',
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
],
[
'e',
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
],
[
'e',
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
{
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
{
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: []
},
{
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: []
},
{
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 3 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 2 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
}
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: []
}
})
})
test.todo('recommended + a lot of events')
test.todo('recommended + 3 events')
test.todo('recommended + 2 events')
test('recommended + 1 event', () => {
let event = {
tags: [
[
'p',
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
'wss://relay.mostr.pub'
],
[
'p',
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
'wss://relay.mostr.pub'
],
[
'p',
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
'wss://relay.mostr.pub'
],
[
'p',
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
'wss://relay.mostr.pub'
],
[
'p',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
'wss://relay.mostr.pub'
],
[
'p',
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
'wss://relay.mostr.pub'
],
[
'p',
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
'wss://relay.mostr.pub'
],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply'
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
relays: ['wss://relay.mostr.pub']
}
],
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub']
},
root: undefined
})
})
})

View File

@@ -1,229 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { parse } from './nip10.ts'
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
{
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
{
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: [],
},
{
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: [],
},
{
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: [],
},
],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 3 events', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 2 events', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [],
},
})
})
test('recommended + 1 event', () => {
let event = {
tags: [
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
relays: ['wss://relay.mostr.pub'],
},
],
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
},
root: undefined,
})
})
})

View File

@@ -1,5 +1,5 @@
import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts'
import type {Event} from './event'
import type {EventPointer, ProfilePointer} from './nip19'
export type NIP10Result = {
/**
@@ -28,7 +28,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
reply: undefined,
root: undefined,
mentions: [],
profiles: [],
profiles: []
}
const eTags: string[][] = []
@@ -41,7 +41,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
if (tag[0] === 'p' && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [],
relays: tag[2] ? [tag[2]] : []
})
}
}
@@ -49,11 +49,16 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [
string,
string,
undefined | string,
undefined | string
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
relays: eTagRelayUrl ? [eTagRelayUrl] : []
}
const isFirstETag = eTagIndex === 0

View File

@@ -1,16 +0,0 @@
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
View File

@@ -1,286 +0,0 @@
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,25 +0,0 @@
import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})
test('mines POW for an event', async () => {
const difficulty = 10
const event = minePow(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 0,
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
difficulty,
)
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
})

View File

@@ -1,52 +0,0 @@
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number {
let count = 0
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 4
} else {
count += Math.clz32(nibble) - 28
break
}
}
return count
}
/**
* Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0
const event = unsigned as Omit<Event, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)
while (true) {
const now = Math.floor(new Date().getTime() / 1000)
if (now !== event.created_at) {
count = 0
event.created_at = now
}
tag[1] = (++count).toString()
event.id = getEventHash(event)
if (getPow(event.id) >= difficulty) {
break
}
}
return event
}

View File

@@ -1,102 +0,0 @@
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 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
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(Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
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')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
])
expect(event.content).toEqual('')
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')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toBeUndefined()
})
})
describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toBeUndefined()
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

View File

@@ -1,97 +0,0 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts'
export type RepostEventTemplate = {
/**
* Pass only non-nip18 tags if you have to.
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
*/
tags?: string[][]
/**
* Pass an empty string to NOT include the stringified JSON of the reposted event.
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
* @default Stringified JSON of the reposted event
*/
content?: ''
created_at: number
}
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event,
relayUrl: string,
privateKey: Uint8Array,
): Event {
return finalizeEvent(
{
kind: Repost,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
},
privateKey,
)
}
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
author: lastPTag?.[1],
}
}
export type GetRepostedEventOptions = {
skipVerification?: boolean
}
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event
try {
repostedEvent = JSON.parse(event.content) as Event
} catch (error) {
return undefined
}
if (repostedEvent.id !== pointer.id) {
return undefined
}
if (!skipVerification && !verifyEvent(repostedEvent)) {
return undefined
}
return repostedEvent
}

102
nip19.test.js Normal file
View File

@@ -0,0 +1,102 @@
/* eslint-env jest */
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = nip19.decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = nip19.decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = nip19.decode(nprofile)
expect(type).toEqual('nprofile')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
nip19.decode(
nip19.nprofileEncode({
pubkey:
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: []
})
).data
).toHaveProperty(
'pubkey',
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
)
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let naddr = nip19.naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana'
})
expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = nip19.decode(naddr)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})
test('decode naddr from habla.news', () => {
let {type, data} = nip19.decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
)
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = nip19.decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(data.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com'
)
expect(data.relays).toContain('wss://nostr.banana.com')
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})

View File

@@ -1,163 +0,0 @@
import { test, expect } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts'
import {
decode,
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
neventEncode,
type AddressPointer,
type ProfilePointer,
EventPointer,
} from './nip19.ts'
test('encode and decode nsec', () => {
let sk = generateSecretKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let { type, data } = decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generateSecretKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let { type, data } = decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nprofileEncode({ pubkey: pk, relays })
expect(nprofile).toMatch(/nprofile1\w+/)
let { type, data } = decode(nprofile)
expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
decode(
nprofileEncode({
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: [],
}),
).data,
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana',
})
expect(naddr).toMatch(/naddr1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('encode and decode nevent', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(30023)
})
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(0)
})
test('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
test('decode naddr from habla.news', () => {
let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let { type, data } = decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
expect(pointer.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let { type, data } = decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

181
nip19.ts
View File

@@ -1,29 +1,10 @@
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import { bech32 } from '@scure/base'
import * as secp256k1 from '@noble/secp256k1'
import {bech32} from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import {utf8Decoder, utf8Encoder} from './utils'
const Bech32MaxSize = 5000
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
function integerToUint8Array(number: number) {
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
const uint8Array = new Uint8Array(4)
// Use bitwise operations to extract the bytes.
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
uint8Array[1] = (number >> 16) & 0xff
uint8Array[2] = (number >> 8) & 0xff
uint8Array[3] = number & 0xff // Least significant byte (LSB)
return uint8Array
}
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
@@ -33,7 +14,6 @@ export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
kind?: number
}
export type AddressPointer = {
@@ -43,29 +23,11 @@ export type AddressPointer = {
relays?: string[]
}
type Prefixes = {
nprofile: ProfilePointer
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: Uint8Array
npub: string
note: string
}
type DecodeValue<Prefix extends keyof Prefixes> = {
type: Prefix
data: Prefixes[Prefix]
}
export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes]
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult {
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | AddressPointer | string
} {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) {
@@ -77,26 +39,27 @@ export function decode(nip19: string): DecodeResult {
return {
type: 'nprofile',
data: {
pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
}
}
}
case 'nevent': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
if (tlv[2] && tlv[2][0].length !== 32)
throw new Error('TLV 2 should be 32 bytes')
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
},
author: tlv[2]?.[0]
? secp256k1.utils.bytesToHex(tlv[2][0])
: undefined
}
}
}
@@ -112,36 +75,24 @@ export function decode(nip19: string): DecodeResult {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]),
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
}
}
}
case 'nsec':
return { type: prefix, data }
case 'npub':
case 'note':
return { type: prefix, data: bytesToHex(data) }
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = { [t: number]: Uint8Array[] }
type TLV = {[t: number]: Uint8Array[]}
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
@@ -151,92 +102,76 @@ function parseTLV(data: Uint8Array): TLV {
let l = rest[1]
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}`)
if (v.length < l) continue
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
return encodeBytes('nsec', key)
export function nsecEncode(hex: string): string {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hexToBytes(hex))
export function npubEncode(hex: string): string {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hexToBytes(hex))
export function noteEncode(hex: string): string {
return encodeBytes('note', hex)
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
function encodeBytes(prefix: string, hex: string): string {
let data = secp256k1.utils.hexToBytes(hex)
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
return bech32.encode(prefix, words, Bech32MaxSize)
}
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
}
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
export function nprofileEncode(profile: ProfilePointer): string {
let data = encodeTLV({
0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
})
return encodeBech32('nprofile', data)
let words = bech32.toWords(data)
return bech32.encode('nprofile', words, Bech32MaxSize)
}
export function neventEncode(event: EventPointer): `nevent1${string}` {
let kindArray
if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind)
}
export function neventEncode(event: EventPointer): string {
let data = encodeTLV({
0: [hexToBytes(event.id)],
0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [],
3: kindArray ? [new Uint8Array(kindArray)] : [],
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
})
return encodeBech32('nevent', data)
let words = bech32.toWords(data)
return bech32.encode('nevent', words, Bech32MaxSize)
}
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
export function naddrEncode(addr: AddressPointer): string {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)],
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)]
})
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)],
})
return encodeBech32('nrelay', data)
let words = bech32.toWords(data)
return bech32.encode('naddr', words, Bech32MaxSize)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
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)
})
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)
})
})
return concatBytes(...entries)
return secp256k1.utils.concatBytes(...entries)
}

View File

@@ -1,24 +0,0 @@
import { test, expect } from 'bun:test'
import { test as testRegex, parse } from './nip21.ts'
test('test()', () => {
expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
expect(testRegex('nostr:')).toBe(false)
expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
expect(testRegex('gggggg')).toBe(false)
})
test('parse', () => {
const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
})
})

View File

@@ -1,30 +0,0 @@
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: decode(match[1]),
}
}

View File

@@ -1,78 +0,0 @@
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 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const reactedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
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(Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
])
expect(event.content).toEqual('+')
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')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
])
expect(event.content).toEqual('👍')
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')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
})

View File

@@ -1,62 +0,0 @@
import { Event, finalizeEvent } from './pure.ts'
import { Reaction } from './kinds.ts'
import type { EventPointer } from './nip19.ts'
export type ReactionEventTemplate = {
/**
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
*/
tags?: string[][]
/**
* @default '+'
*/
content?: string
created_at: number
}
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 finalizeEvent(
{
...t,
kind: Reaction,
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
content: t.content ?? '+',
},
privateKey,
)
}
export function getReactedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Reaction) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined || lastPTag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
author: lastPTag[1],
}
}

105
nip26.test.js Normal file
View File

@@ -0,0 +1,105 @@
/* eslint-env jest */
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('parse good delegation from NIP', async () => {
expect(
nip26.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(
nip26.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(
nip26.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(
nip26.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 = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1')
let event = {
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2
}
expect(nip26.getDelegator(event)).toEqual(pk1)
})

90
nip26.ts Normal file
View File

@@ -0,0 +1,90 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {Event} from './event'
import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
export type Parameters = {
pubkey: string // the key to whom the delegation will be given
kind: number | undefined
until: number | undefined // delegation will only be valid until this date
since: number | undefined // 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 = secp256k1.utils.bytesToHex(
secp256k1.schnorr.signSync(sighash, privateKey)
)
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig
}
}
export function getDelegator(event: Event): 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 (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
return pubkey
}

View File

@@ -1,68 +0,0 @@
import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts'
test('matchAll', () => {
const result = matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
])
})
test('matchAll with an invalid nip19', () => {
const result = matchAll(
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
)
expect([...result]).toEqual([
{
decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
},
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = replaceAll(content, ({ decoded, value }) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

View File

@@ -1,63 +0,0 @@
import { decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends NostrURI {
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */
export function* matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length,
}
} catch (_e) {
// do nothing
}
}
}
/**
* Replace all occurrences of Nostr URIs in the text.
*
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
})
})
}

View File

@@ -1,130 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { getPublicKey } from './pure.ts'
import * as Kind from './kinds.ts'
import {
channelCreateEvent,
channelMetadataEvent,
channelMessageEvent,
channelHideMessageEvent,
channelMuteUserEvent,
ChannelMetadata,
ChannelMessageEventTemplate,
} from './nip28.ts'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
const channelMetadata: ChannelMetadata = {
name: 'Test Channel',
about: 'This is a test channel',
picture: 'https://example.com/picture.jpg',
}
test('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115,
}
const event = channelCreateEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelCreation)
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
})
test('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
created_at: 1617932115,
}
const event = channelMetadataEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMetadata)
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root',
])
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
'e',
template.reply_to_channel_message_event_id as string,
template.relay_url,
'reply',
])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
test('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: { reason: 'Inappropriate content' },
created_at: 1617932115,
}
const event = channelHideMessageEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
test('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: { reason: 'Spamming' },
created_at: 1617932115,
pubkey_to_mute: 'pubkey to mute',
}
const event = channelMuteUserEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
})

152
nip28.ts
View File

@@ -1,152 +0,0 @@
import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
export interface ChannelMetadata {
name: string
about: string
picture: string
}
export interface ChannelCreateEventTemplate {
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMetadataEventTemplate {
channel_create_event_id: string
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMessageEventTemplate {
channel_create_event_id: string
reply_to_channel_message_event_id?: string
relay_url: string
content: string
created_at: number
tags?: string[][]
}
export interface ChannelHideMessageEventTemplate {
channel_message_event_id: string
content: string | { reason: string }
created_at: number
tags?: string[][]
}
export interface ChannelMuteUserEventTemplate {
content: string | { reason: string }
created_at: number
pubkey_to_mute: string
tags?: string[][]
}
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finalizeEvent(
{
kind: ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at,
},
privateKey,
)
}
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: Uint8Array,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}

View File

@@ -1,33 +0,0 @@
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:',
)
})

View File

@@ -1,51 +0,0 @@
/** 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,
})
})
}

15
nip39.test.js Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip39} = require('./lib/nostr.cjs.js')
test('validate github claim', async () => {
nip39.useFetchImplementation(fetch)
let result = await nip39.validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421'
)
expect(result).toBe(true)
})

View File

@@ -1,15 +0,0 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, validateGithub } from './nip39.ts'
test('validate github claim', async () => {
useFetchImplementation(fetch)
let result = await validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421',
)
expect(result).toBe(true)
})

View File

@@ -8,10 +8,19 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
export async function validateGithub(
pubkey: string,
username: string,
proof: string
): Promise<boolean> {
try {
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
let res = await (
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
).text()
return (
res ===
`Verifying that I control the following Nostr public key: ${pubkey}`
)
} catch (_) {
return false
}

View File

@@ -1,44 +0,0 @@
import { describe, test, expect, jest } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
describe('getExpiration', () => {
test('returns the expiration as a Date object', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = getExpiration(event)
expect(result).toEqual(new Date(123000))
})
})
describe('isEventExpired', () => {
test('returns true when the event has expired', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = isEventExpired(event)
expect(result).toEqual(true)
})
test('returns false when the event has not expired', () => {
const future = Math.floor(Date.now() / 1000) + 10
const event = buildEvent({ tags: [['expiration', future.toString()]] })
const result = isEventExpired(event)
expect(result).toEqual(false)
})
})
describe('waitForExpire', () => {
test('returns a promise that resolves when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = await waitForExpire(event)
expect(result).toEqual(event)
})
})
describe('onExpire', () => {
test('calls the callback when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const callback = jest.fn()
onExpire(event, callback)
await new Promise((resolve) => setTimeout(resolve, 200))
expect(callback).toHaveBeenCalled()
})
})

View File

@@ -1,49 +0,0 @@
import { Event } from './core.ts'
/** Get the expiration of the event as a `Date` object, if any. */
function getExpiration(event: Event): Date | undefined {
const tag = event.tags.find(([name]) => name === 'expiration')
if (tag) {
return new Date(parseInt(tag[1]) * 1000)
}
}
/** Check if the event has expired. */
function isEventExpired(event: Event): boolean {
const expiration = getExpiration(event)
if (expiration) {
return Date.now() > expiration.getTime()
} else {
return false
}
}
/** Returns a promise that resolves when the event expires. */
async function waitForExpire(event: Event): Promise<Event> {
const expiration = getExpiration(event)
if (expiration) {
const diff = expiration.getTime() - Date.now()
if (diff > 0) {
await sleep(diff)
return event
} else {
return event
}
} else {
throw new Error('Event has no expiration')
}
}
/** Calls the callback when the event expires. */
function onExpire(event: Event, callback: (event: Event) => void): void {
waitForExpire(event)
.then(callback)
.catch(() => {})
}
/** Resolves when the given number of milliseconds have elapsed. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export { getExpiration, isEventExpired, waitForExpire, onExpire }

154
nip41.test.js Normal file
View File

@@ -0,0 +1,154 @@
/* eslint-env jest */
const secp256k1 = require('@noble/secp256k1')
const {
getPublicKey,
validateEvent,
verifySignature,
generatePrivateKey,
nip41
} = require('./lib/nostr.cjs')
test('sanity', () => {
let sk = generatePrivateKey()
expect(getPublicKey(sk)).toEqual(secp256k1.Point.fromPrivateKey(sk).toHexX())
})
test('key arithmetics', () => {
expect(
secp256k1.utils.mod(secp256k1.CURVE.n + 1n, secp256k1.CURVE.n)
).toEqual(1n)
let veryHighPoint = secp256k1.Point.fromPrivateKey(
(secp256k1.CURVE.n - 1n).toString(16).padStart(64, '0')
)
let pointAt2 = secp256k1.Point.fromPrivateKey(
2n.toString(16).padStart(64, '0')
)
let pointAt1 = secp256k1.Point.fromPrivateKey(
1n.toString(16).padStart(64, '0')
)
expect(veryHighPoint.add(pointAt2)).toEqual(pointAt1)
expect(
secp256k1.getPublicKey(1n.toString(16).padStart(64, '0'), true)
).toEqual(pointAt1.toRawBytes(true))
})
test('testing getting child keys compatibility', () => {
let sk = '2222222222222222222222222222222222222222222222222222222222222222'
let pk = secp256k1.getPublicKey(sk, true)
let hsk = '3333333333333333333333333333333333333333333333333333333333333333'
let hpk = secp256k1.getPublicKey(hsk, true)
expect(secp256k1.utils.bytesToHex(nip41.getChildPublicKey(pk, hpk))).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
test('more testing child key derivation', () => {
;[
{
sk: '448aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '00ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '02e3990b0eb40452a8ffbd9fe99037deb7beeb6ab26020e8c0e8284f3009a56d0c',
hpk: '029e9cb07f3a3b8abcad629920d4a5460aefb6b7c08704b7f1ced8648b007ef65f'
},
{
sk: '778aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '99ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '020d09894e321f53a7ac8bc003cb1563a4857d57ea69c39ab7189e2cccedc17d1b',
hpk: '0358fe19e14c78c4a8c0037a2b9d3e3a714717f2a2d8dd54a5e88d283440dcb28a'
},
{
sk: '2eb5edc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '65d515a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '03dd651a07dc6c9a54b596f6492c9623a595cb48e31af04f8c322d4ce81accb2b0',
hpk: '03b8c98d920141a1e168d21e9315cf933a601872ebf57751b30797fb98526c2f4f'
}
].forEach(({pk, hpk, sk, hsk}) => {
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(sk, true))
).toEqual(pk)
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(hsk, true))
).toEqual(hpk)
expect(
secp256k1.utils.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes(pk),
secp256k1.utils.hexToBytes(hpk)
)
)
).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
})
test('generating a revocation event and validating it', () => {
const mnemonic =
'air property excess weird rare rival fade intact brave office mirror wait'
const firstKey = nip41.getPrivateKeyAtIndex(mnemonic, 9)
// expect(firstKey).toEqual(
// '8495ba55f56485d378aa275604a45e76abbcae177e374fa06af5770c3b8e24af'
// )
const firstPubkey = getPublicKey(firstKey)
// expect(firstPubkey).toEqual(
// '35246813a0dd45e74ce22ecdf052cca8ed47759c8f8d412c281dc2755110956f'
// )
// first key is compromised, revoke it
let {parentPrivateKey, event} = nip41.buildRevocationEvent(
mnemonic,
firstPubkey
)
const secondKey = nip41.getPrivateKeyAtIndex(mnemonic, 8)
expect(parentPrivateKey).toEqual(secondKey)
expect(secondKey).toEqual(
'1b311655ef73bed3bbebc83d0cb3eef42c6aff45f944e3a0c263eb6fdf98c617'
)
expect(event).toHaveProperty('kind', 13)
expect(event.tags).toHaveLength(2)
expect(event.tags[0]).toHaveLength(2)
expect(event.tags[1]).toHaveLength(2)
expect(event.tags[0][0]).toEqual('p')
expect(event.tags[1][0]).toEqual('hidden-key')
let hiddenKey = secp256k1.utils.hexToBytes(event.tags[1][1])
let pubkeyAlt1 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('02' + event.pubkey),
hiddenKey
)
)
.slice(2)
let pubkeyAlt2 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('03' + event.pubkey),
hiddenKey
)
)
.slice(2)
expect([pubkeyAlt1, pubkeyAlt2]).toContain(event.tags[0][1])
// receiver of revocation event can validate it
let secondPubkey = getPublicKey(secondKey)
expect(event.pubkey).toEqual(secondPubkey)
expect(validateEvent(event)).toBeTruthy()
expect(verifySignature(event)).toBeTruthy()
expect(nip41.validateRevocation(event)).toBeTruthy()
})

160
nip41.ts Normal file
View File

@@ -0,0 +1,160 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {mnemonicToSeedSync} from '@scure/bip39'
import {HARDENED_OFFSET, HDKey} from '@scure/bip32'
import {getPublicKey} from './keys'
import {Event, getEventHash, Kind, signEvent, verifySignature} from './event'
const MaxKeys = 256
function getRootFromMnemonic(mnemonic: string): HDKey {
return HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive(
`m/44'/1237'/41'`
)
}
export function getPrivateKeyAtIndex(
mnemonic: string,
targetIdx: number
): string {
let root = getRootFromMnemonic(mnemonic)
let rootPrivateKey = secp256k1.utils.bytesToHex(root.privateKey as Uint8Array)
let currentPrivateKey = rootPrivateKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPrivateKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).privateKey as Uint8Array
)
currentPrivateKey = getChildPrivateKey(currentPrivateKey, hiddenPrivateKey)
}
return currentPrivateKey
}
export function getPublicKeyAtIndex(
root: HDKey,
targetIdx: number
): Uint8Array {
let rootPublicKey = root.publicKey as Uint8Array
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
currentPublicKey = getChildPublicKey(currentPublicKey, hiddenPublicKey)
}
return currentPublicKey
}
function getIndexOfPublicKey(root: HDKey, publicKey: string): number {
let rootPublicKey = root.publicKey as Uint8Array
if (secp256k1.utils.bytesToHex(rootPublicKey).slice(2) === publicKey) return 0
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= MaxKeys; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
let pubkeyAtIndex = getChildPublicKey(currentPublicKey, hiddenPublicKey)
if (secp256k1.utils.bytesToHex(pubkeyAtIndex).slice(2) === publicKey)
return idx
currentPublicKey = pubkeyAtIndex
}
throw new Error(
`public key ${publicKey} not in the set of the first ${MaxKeys} public keys`
)
}
export function getChildPublicKey(
parentPublicKey: Uint8Array,
hiddenPublicKey: Uint8Array
): Uint8Array {
if (parentPublicKey.length !== 33 || hiddenPublicKey.length !== 33)
throw new Error(
'getChildPublicKey() requires public keys with the leading differentiator byte.'
)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashPoint = secp256k1.Point.fromPrivateKey(hash)
let point = secp256k1.Point.fromHex(hiddenPublicKey).add(hashPoint)
return point.toRawBytes(true)
}
export function getChildPrivateKey(
parentPrivateKey: string,
hiddenPrivateKey: string
): string {
let parentPublicKey = secp256k1.getPublicKey(parentPrivateKey, true)
let hiddenPublicKey = secp256k1.getPublicKey(hiddenPrivateKey, true)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashScalar = BigInt(`0x${secp256k1.utils.bytesToHex(hash)}`)
let hiddenPrivateKeyScalar = BigInt(`0x${hiddenPrivateKey}`)
let sumScalar = hiddenPrivateKeyScalar + hashScalar
let modulo = secp256k1.utils.mod(sumScalar, secp256k1.CURVE.n)
return modulo.toString(16).padStart(64, '0')
}
export function buildRevocationEvent(
mnemonic: string,
compromisedKey: string,
content = ''
): {
parentPrivateKey: string
event: Event
} {
let root = getRootFromMnemonic(mnemonic)
let idx = getIndexOfPublicKey(root, compromisedKey)
let hiddenKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).publicKey as Uint8Array
)
let parentPrivateKey = getPrivateKeyAtIndex(mnemonic, idx - 1)
let parentPublicKey = getPublicKey(parentPrivateKey)
let event: Event = {
kind: 13,
tags: [
['p', compromisedKey],
['hidden-key', hiddenKey]
],
created_at: Math.round(Date.now() / 1000),
content,
pubkey: parentPublicKey
}
event.sig = signEvent(event, parentPrivateKey)
event.id = getEventHash(event)
return {parentPrivateKey, event}
}
export function validateRevocation(event: Event): boolean {
if (event.kind !== Kind.StatelessRevocation) return false
if (!verifySignature(event)) return false
let invalidKeyTag = event.tags.find(([t, v]) => t === 'p' && v)
if (!invalidKeyTag) return false
let invalidKey = invalidKeyTag[1]
let hiddenKeyTag = event.tags.find(([t, v]) => t === 'hidden-key' && v)
if (!hiddenKeyTag) return false
let hiddenKey = secp256k1.utils.hexToBytes(hiddenKeyTag[1])
if (hiddenKey.length !== 33) return false
let currentKeyAlt1 = secp256k1.utils.hexToBytes('02' + event.pubkey)
let currentKeyAlt2 = secp256k1.utils.hexToBytes('03' + event.pubkey)
let childKeyAlt1 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt1, hiddenKey))
.slice(2)
let childKeyAlt2 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt2, hiddenKey))
.slice(2)
return childKeyAlt1 === invalidKey || childKeyAlt2 === invalidKey
}

View File

@@ -1,14 +0,0 @@
import { test, expect } from 'bun:test'
import { makeAuthEvent } from './nip42.ts'
import { Relay } from './relay.ts'
test('auth flow', async () => {
const relay = await Relay.connect('wss://nostr.wine')
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,17 +0,0 @@
import { EventTemplate } from './core.ts'
import { ClientAuth } from './kinds.ts'
/**
* creates an EventTemplate for an AUTH event to be signed.
*/
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
return {
kind: ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL],
['challenge', challenge],
],
content: '',
}
}

View File

@@ -1,44 +0,0 @@
import { test, expect } from 'bun:test'
import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2
test('get_conversation_key', () => {
for (const v of v2vec.valid.get_conversation_key) {
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
}
})
test('encrypt_decrypt', () => {
for (const v of v2vec.valid.encrypt_decrypt) {
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
const key = v2.utils.getConversationKey(v.sec1, pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
expect(ciphertext).toEqual(v.payload)
const decrypted = v2.decrypt(ciphertext, key)
expect(decrypted).toEqual(v.plaintext)
}
})
test('calc_padded_len', () => {
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
const actual = v2.utils.calcPaddedLen(len)
expect(actual).toEqual(shouldBePaddedTo)
}
})
test('decrypt', async () => {
for (const v of v2vec.invalid.decrypt) {
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
}
})
test('get_conversation_key', async () => {
for (const v of v2vec.invalid.get_conversation_key) {
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
}
})

131
nip44.ts
View File

@@ -1,131 +0,0 @@
import { chacha20 } from '@noble/ciphers/chacha'
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
const decoder = new TextDecoder()
const u = {
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
utf8Encode: utf8ToBytes,
utf8Decode(bytes: Uint8Array) {
return decoder.decode(bytes)
},
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
},
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
ensureBytes(conversationKey, 32)
ensureBytes(nonce, 32)
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
return {
chacha_key: keys.subarray(0, 32),
chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
}
},
calcPaddedLen(len: number): number {
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
},
writeU16BE(num: number) {
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
const arr = new Uint8Array(2)
new DataView(arr.buffer).setUint16(0, num, false)
return arr
},
pad(plaintext: string): Uint8Array {
const unpadded = u.utf8Encode(plaintext)
const unpaddedLen = unpadded.length
const prefix = u.writeU16BE(unpaddedLen)
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
return concatBytes(prefix, unpadded, suffix)
},
unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen < u.minPlaintextSize ||
unpaddedLen > u.maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
)
throw new Error('invalid padding')
return u.utf8Decode(unpadded)
},
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
const combined = concatBytes(aad, message)
return hmac(sha256, key, combined)
},
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
decodePayload(payload: string) {
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
const plen = payload.length
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
if (payload[0] === '#') throw new Error('unknown encryption version')
let data: Uint8Array
try {
data = base64.decode(payload)
} catch (error) {
throw new Error('invalid base64: ' + (error as any).message)
}
const dlen = data.length
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const vers = data[0]
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
}
},
}
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
const padded = u.pad(plaintext)
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
}
function decrypt(payload: string, conversationKey: Uint8Array): string {
const { nonce, ciphertext, mac } = u.decodePayload(payload)
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
return u.unpad(padded)
}
export const v2 = {
utils: u,
encrypt,
decrypt,
}
export default { v2 }

View File

@@ -1,605 +0,0 @@
{
"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,65 +0,0 @@
import crypto from 'node:crypto'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
describe('parseConnectionString', () => {
test('returns pubkey, relay, and secret if connection string is valid', () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
const { pubkey, relay, secret } = parseConnectionString(connectionString)
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
expect(relay).toBe('wss://relay.damus.io')
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
})
test('throws an error if no pubkey in connection string', async () => {
const connectionString =
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
test('throws an error if no relay in connection string', async () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
test('throws an error if no secret in connection string', async () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
})
describe('makeNwcRequestEvent', () => {
test('returns a valid NWC request event', async () => {
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
const invoice =
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
expect(result.kind).toBe(NWCWalletRequest)
expect(await decrypt(secret, pubkey, result.content)).toEqual(
JSON.stringify({
method: 'pay_invoice',
params: {
invoice,
},
}),
)
expect(result.tags).toEqual([['p', pubkey]])
expect(result.id).toEqual(expect.any(String))
expect(result.sig).toEqual(expect.any(String))
})
})

View File

@@ -1,34 +0,0 @@
import { finalizeEvent } from './pure.ts'
import { NWCWalletRequest } from './kinds.ts'
import { encrypt } from './nip04.ts'
export function parseConnectionString(connectionString: string) {
const { pathname, searchParams } = new URL(connectionString)
const pubkey = pathname
const relay = searchParams.get('relay')
const secret = searchParams.get('secret')
if (!pubkey || !relay || !secret) {
throw new Error('invalid connection string')
}
return { pubkey, relay, secret }
}
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
const content = {
method: 'pay_invoice',
params: {
invoice,
},
}
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
const eventTemplate = {
kind: NWCWalletRequest,
created_at: Math.round(Date.now() / 1000),
content: encryptedContent,
tags: [['p', pubkey]],
}
return finalizeEvent(eventTemplate, secretKey)
}

View File

@@ -1,91 +1,99 @@
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'
const {bech32} = require('@scure/base')
const {
nip57,
generatePrivateKey,
getPublicKey,
finishEvent
} = require('./lib/nostr.cjs')
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata)
const metadata = {content: '{}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const fetchImplementation = jest.fn(() =>
Promise.resolve({json: () => ({allowsNostr: false})})
)
nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
const fetchImplementation = jest.fn(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback',
}),
}),
callback: 'callback'
})
})
)
useFetchImplementation(fetchImplementation)
nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
nip57.makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: '',
}),
comment: ''
})
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
nip57.makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: '',
}),
comment: ''
})
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
const result = nip57.makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment',
comment: 'comment'
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
@@ -94,16 +102,18 @@ describe('makeZapRequest', () => {
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
['relays', 'relay1', 'relay2']
])
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => {
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
expect(nip57.validateZapRequest('invalid JSON')).toBe(
'Invalid zap request JSON.'
)
})
test('returns an error message if the Zap request is not a valid Nostr event', () => {
@@ -114,15 +124,17 @@ describe('validateZapRequest', () => {
tags: [
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Zap request is not a valid Nostr event.'
)
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
@@ -133,34 +145,40 @@ describe('validateZapRequest', () => {
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Invalid signature on zap request.'
)
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'p' tag."
)
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -168,20 +186,22 @@ describe('validateZapRequest', () => {
tags: [
['p', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'p' tag is not valid hex."
)
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -190,40 +210,44 @@ describe('validateZapRequest', () => {
['p', publicKey],
['e', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'e' tag is not valid hex."
)
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
],
['amount', '100']
]
},
privateKey,
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'relays' tag."
)
})
test('returns null for a valid Zap request', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -231,89 +255,81 @@ describe('validateZapRequest', () => {
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
})
})
describe('makeZapReceipt', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
),
privateKey
)
)
const preimage = 'preimage'
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
['preimage', preimage],
]),
)
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
['relays', 'relay1', 'relay2']
]
},
privateKey,
),
privateKey
)
)
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
})
})

View File

@@ -1,7 +1,7 @@
import { bech32 } from '@scure/base'
import {bech32} from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts'
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
import {utf8Decoder} from './utils'
var _fetch: any
@@ -16,9 +16,9 @@ export function useFetchImplementation(fetchImplementation: any) {
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
try {
let lnurl: string = ''
let { lud06, lud16 } = JSON.parse(metadata.content)
let {lud06, lud16} = JSON.parse(metadata.content)
if (lud06) {
let { words } = bech32.decode(lud06, 1000)
let {words} = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
@@ -46,7 +46,7 @@ export function makeZapRequest({
event,
amount,
relays,
comment = '',
comment = ''
}: {
profile: string
event: string | null
@@ -57,15 +57,15 @@ export function makeZapRequest({
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate = {
let zr = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays],
],
['relays', ...relays]
]
}
if (event) {
@@ -84,16 +84,18 @@ export function validateZapRequest(zapRequestString: string): string | null {
return 'Invalid zap request JSON.'
}
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
if (!validateEvent(zapRequest))
return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
if (!p[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'p' tag is not valid hex."
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
if (e && !e[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'e' tag is not valid hex."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag."
@@ -105,21 +107,27 @@ export function makeZapReceipt({
zapRequest,
preimage,
bolt11,
paidAt,
paidAt
}: {
zapRequest: string
preimage?: string
preimage: string | null
bolt11: string
paidAt: Date
}): EventTemplate {
let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
let tagsFromZapRequest = zr.tags.filter(
([t]) => t === 'e' || t === 'p' || t === 'a'
)
let zap: EventTemplate = {
let zap = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
tags: [
...tagsFromZapRequest,
['bolt11', bolt11],
['description', zapRequest]
]
}
if (preimage) {

View File

@@ -1,159 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import { Event, finalizeEvent } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { HTTPAuth } from './kinds.ts'
const sk = generateSecretKey()
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
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(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get'],
])
})
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
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'],
])
})
test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr '
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
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)
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 => 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 => finalizeEvent(e, sk), true)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
expect(result).toBe(true)
})
test('validateEvent returns false for invalid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(result).rejects.toThrow(Error)
})
})

121
nip98.ts
View File

@@ -1,121 +0,0 @@
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { base64 } from '@scure/base'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import { HTTPAuth } from './kinds.ts'
const _authorizationScheme = 'Nostr '
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: (e: EventTemplate) => Promise<Event> | Event,
includeAuthorizationScheme: boolean = false,
payload?: Record<string, any>,
): Promise<string> {
const event: EventTemplate = {
kind: HTTPAuth,
tags: [
['u', loginUrl],
['method', httpMethod],
],
created_at: Math.round(new Date().getTime() / 1000),
content: '',
}
if (payload) {
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
}
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
}
/**
* Validate token for NIP-98 flow.
*
* @example
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
const event = await unpackEventFromToken(token).catch(error => {
throw error
})
const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
return valid
}
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
throw new Error('Invalid token')
}
const event = JSON.parse(eventB64) as Event
return event
}
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.kind !== HTTPAuth) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!event.created_at) {
throw new Error('Invalid nostr event, created_at invalid')
}
// Event must be less than 60 seconds old
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
throw new Error('Invalid nostr event, expired')
}
const urlTag = event.tags.find(t => t[0] === 'u')
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
throw new Error('Invalid nostr event, url tag invalid')
}
const methodTag = event.tags.find(t => t[0] === 'method')
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
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,190 +1,29 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.1.3",
"version": "1.8.2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git"
},
"files": [
"lib"
"./lib/**/*"
],
"sideEffects": false,
"module": "./lib/esm/index.js",
"main": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts",
"types": "./lib/index.d.ts",
"main": "lib/nostr.cjs.js",
"module": "lib/esm/nostr.mjs",
"exports": {
".": {
"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"
}
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js"
},
"license": "Unlicense",
"license": "Public domain",
"dependencies": {
"@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"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
"@noble/hashes": "1.0.0",
"@noble/secp256k1": "^1.7.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.1.5",
"@scure/bip39": "^1.1.1",
"prettier": "^2.8.4"
},
"keywords": [
"decentralization",
@@ -195,24 +34,19 @@
],
"devDependencies": {
"@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",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0",
"eslint": "^8.33.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"mitata": "^0.1.6",
"jest": "^29.4.2",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"ts-jest": "^29.0.5",
"tsd": "^0.22.0",
"typescript": "^5.0.4"
},
"scripts": {
"prepublish": "just build && just emit-types"
"typescript": "^4.9.5",
"websocket-polyfill": "^0.0.3"
}
}

133
pool.test.js Normal file
View File

@@ -0,0 +1,133 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
SimplePool,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
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/'
]
afterAll(() => {
pool.close([
...relays,
'wss://nostr.wine',
'wss://offchain.pub',
'wss://eden.nostr.land'
])
})
test('removing duplicates when querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{authors: [pub]}])
let received = []
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 = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: []
}
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event)
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
})
test('same with double querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
let sub1 = pool.sub(relays, [{authors: [pub]}])
let sub2 = pool.sub(relays, [{authors: [pub]}])
let received = []
sub1.on('event', event => {
received.push(event)
})
sub2.on('event', event => {
received.push(event)
})
let event = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 22346,
tags: []
}
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event)
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
})
test('list()', async () => {
let events = await pool.list(
[...relays, '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)
.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)
})

View File

@@ -1,116 +0,0 @@
import { test, expect, afterAll } from 'bun:test'
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://nos.lol', 'wss://public.relaying.io']
afterAll(() => {
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
})
test('removing duplicates when subscribing', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
})
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: [],
},
priv,
)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double subs', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 22346,
tags: [],
},
priv,
)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
[{ kinds: [0, 1], limit: 40 }],
{
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
},
)
})
expect(events.size).toBeGreaterThan(50)
})
test('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).toBeGreaterThan(2)
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
expect(events).toHaveLength(uniqueEventCount)
})
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
})

203
pool.ts
View File

@@ -1,10 +1,199 @@
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
import {Relay, relayInit} from './relay'
import {normalizeURL} from './utils'
import {Filter} from './filter'
import {Event} from './event'
import {SubscriptionOptions, Sub, Pub} from './relay'
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent })
export class SimplePool {
private _conn: {[url: string]: Relay}
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
private eoseSubTimeout: number
private getTimeout: number
constructor(options: {eoseSubTimeout?: number; getTimeout?: number} = {}) {
this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
}
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(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
let _knownIds: Set<string> = new Set()
let modifiedOpts = {...(opts || {})}
modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
let set = this._seenOn[id] || new Set()
set.add(url)
this._seenOn[id] = set
return _knownIds.has(id)
}
let subs: Sub[] = []
let eventListeners: Set<(event: Event) => void> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length
let eoseSent = false
let eoseTimeout = setTimeout(() => {
eoseSent = true
for (let cb of eoseListeners.values()) cb()
}, this.eoseSubTimeout)
relays.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', (event: Event) => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
}
}
})
let greaterSub: Sub = {
sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts))
return greaterSub
},
unsub() {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose')
eoseListeners.delete(cb as () => void | Promise<void>)
}
}
return greaterSub
}
get(
relays: string[],
filter: Filter,
opts?: SubscriptionOptions
): Promise<Event | 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: Event) => {
resolve(event)
clearTimeout(timeout)
sub.unsub()
})
})
}
list(
relays: string[],
filters: Filter[],
opts?: SubscriptionOptions
): Promise<Event[]> {
return new Promise(resolve => {
let events: Event[] = []
let sub = this.sub(relays, filters, opts)
sub.on('event', (event: Event) => {
events.push(event)
})
// we can rely on an eose being emitted here because pool.sub() will fake one
sub.on('eose', () => {
sub.unsub()
resolve(events)
})
})
}
publish(relays: string[], event: Event): Pub {
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
let r
try {
r = await this.ensureRelay(relay)
return r.publish(event)
} catch (_) {
return {on() {}, off() {}}
}
})
const callbackMap = new Map()
return {
on(type, cb) {
relays.forEach(async (relay, i) => {
let pub = await pubPromises[i]
let callback = () => cb(relay)
callbackMap.set(cb, callback)
pub.on(type, callback)
})
},
off(type, cb) {
relays.forEach(async (_, i) => {
let callback = callbackMap.get(cb)
if (callback) {
let pub = await pubPromises[i]
pub.off(type, callback)
}
})
}
}
}
seenOn(id: string): string[] {
return Array.from(this._seenOn[id]?.values?.() || [])
}
}
export * from './abstract-pool.ts'

59
pure.ts
View File

@@ -1,59 +0,0 @@
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'

62
references.test.js Normal file
View File

@@ -0,0 +1,62 @@
/* eslint-env jest */
const {parseReferences} = require('./lib/nostr.cjs')
test('parse mentions', () => {
let evt = {
tags: [
[
'p',
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
'wss://nostr.com'
],
[
'e',
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
'wss://other.com',
'reply'
],
[
'e',
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
''
]
],
content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]'
}
expect(parseReferences(evt)).toEqual([
{
text: '#[0]',
profile: {
pubkey:
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
relays: ['wss://nostr.com']
}
},
{
text: '#[2]',
event: {
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
relays: []
}
},
{
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
profile: {
pubkey:
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: []
}
},
{
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
event: {
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [],
author: undefined
}
}
])
})

View File

@@ -1,47 +0,0 @@
import { test, expect } from 'bun:test'
import { parseReferences } from './references.ts'
import { buildEvent } from './test-helpers.ts'
test('parse mentions', () => {
let evt = buildEvent({
tags: [
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
],
content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
})
expect(parseReferences(evt)).toEqual([
{
text: '#[0]',
profile: {
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
relays: ['wss://nostr.com'],
},
},
{
text: '#[2]',
event: {
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
relays: [],
},
},
{
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
profile: {
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [],
},
},
{
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
event: {
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [],
author: undefined,
},
},
])
})

View File

@@ -1,6 +1,5 @@
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
import type { Event } from './core.ts'
import {Event} from './event'
import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
type Reference = {
text: string
@@ -9,7 +8,8 @@ type Reference = {
address?: AddressPointer
}
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
const mentionRegex =
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
export function parseReferences(evt: Event): Reference[] {
let references: Reference[] = []
@@ -17,40 +17,40 @@ export function parseReferences(evt: Event): Reference[] {
if (ref[2]) {
// it's a NIP-27 mention
try {
let { type, data } = decode(ref[1])
let {type, data} = decode(ref[1])
switch (type) {
case 'npub': {
references.push({
text: ref[0],
profile: { pubkey: data as string, relays: [] },
profile: {pubkey: data as string, relays: []}
})
break
}
case 'nprofile': {
references.push({
text: ref[0],
profile: data as ProfilePointer,
profile: data as ProfilePointer
})
break
}
case 'note': {
references.push({
text: ref[0],
event: { id: data as string, relays: [] },
event: {id: data as string, relays: []}
})
break
}
case 'nevent': {
references.push({
text: ref[0],
event: data as EventPointer,
event: data as EventPointer
})
break
}
case 'naddr': {
references.push({
text: ref[0],
address: data as AddressPointer,
address: data as AddressPointer
})
break
}
@@ -68,28 +68,28 @@ export function parseReferences(evt: Event): Reference[] {
case 'p': {
references.push({
text: ref[0],
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'e': {
references.push({
text: ref[0],
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'a': {
try {
let [kind, pubkey, identifier] = tag[1].split(':')
let [kind, pubkey, identifier] = ref[1].split(':')
references.push({
text: ref[0],
address: {
identifier,
pubkey,
kind: parseInt(kind, 10),
relays: tag[2] ? [tag[2]] : [],
},
relays: tag[2] ? [tag[2]] : []
}
})
} catch (err) {
/***/

140
relay.test.js Normal file
View File

@@ -0,0 +1,140 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let relay = relayInit('wss://relay.damus.io/')
beforeAll(() => {
relay.connect()
})
afterAll(() => {
relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', async () => {
var resolve1
var resolve2
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
let [t1, t2] = await Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
expect(t1).toEqual(true)
expect(t2).toEqual(true)
})
test('get()', async () => {
let event = await relay.get({
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
})
test('list()', async () => {
let events = await relay.list([
{
authors: [
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1],
limit: 2
}
])
expect(events.length).toEqual(2)
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1
var resolve2
let sub = relay.sub([
{
kinds: [27572],
authors: [pk]
}
])
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
let event = {
kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}
event.id = getEventHash(event)
event.sig = signEvent(event, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})

View File

@@ -1,121 +0,0 @@
import { afterEach, expect, test } from 'bun:test'
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay } from './relay.ts'
let relay = new Relay('wss://relay.nostr.bg')
afterEach(() => {
relay.close()
})
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 () => {
await relay.connect()
let resolveEvent: () => void
let resolveEose: () => void
const evented = new Promise<void>(resolve => {
resolveEvent = resolve
})
const eosed = new Promise<void>(resolve => {
resolveEose = resolve
})
relay.subscribe(
[
{
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
kinds: [0],
},
],
{
onevent(event) {
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
expect(event).toHaveProperty('kind', 0)
resolveEvent()
},
oneose() {
resolveEose()
},
},
)
await eosed
await evented
}, 10000)
test('listening and publishing and closing', async () => {
await relay.connect()
let sk = generateSecretKey()
let pk = getPublicKey(sk)
let resolveEose: (_: void) => void
let resolveEvent: (_: void) => void
let resolveClose: (_: void) => void
let eventReceived: NostrEvent | undefined
const eosed = new Promise(resolve => {
resolveEose = resolve
})
const evented = new Promise(resolve => {
resolveEvent = resolve
})
const closed = new Promise(resolve => {
resolveClose = resolve
})
let sub = relay.subscribe(
[
{
kinds: [23571],
authors: [pk],
},
],
{
onevent(event) {
eventReceived = event
resolveEvent()
},
oneose() {
resolveEose()
},
onclose() {
resolveClose()
},
},
)
await eosed
let event = finalizeEvent(
{
kind: 23571,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite',
},
sk,
)
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')
})

369
relay.ts
View File

@@ -1,23 +1,356 @@
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
/* global WebSocket */
/**
* @deprecated use Relay.connect() instead.
*/
export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter'
import {getHex64, getSubscriptionId} from './fakejson'
type RelayEvent = {
connect: () => void | Promise<void>
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
}
type SubEvent = {
event: (event: Event) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
url: string
status: number
connect: () => Promise<void>
close: () => void
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
publish: (event: Event) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
}
export type Pub = {
on: (type: 'ok' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void
}
export type Sub = {
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T,
listener: U
) => void
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T,
listener: U
) => void
}
export class Relay extends AbstractRelay {
constructor(url: string) {
super(url, { verifyEvent })
}
static async connect(url: string) {
const relay = new Relay(url)
await relay.connect()
return relay
}
export type SubscriptionOptions = {
id?: string
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
export * from './abstract-relay.ts'
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
} = {}
): Relay {
let {listTimeout = 3000, getTimeout = 3000} = options
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var subListeners: {
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {}
var pubListeners: {
[eventid: string]: {
ok: Array<() => void>
seen: Array<() => void>
failed: Array<(reason: string) => void>
}
} = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> {
if (connectionPromise) return connectionPromise
connectionPromise = new Promise((resolve, reject) => {
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolve()
}
ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb())
}
let incomingMessageQueue: string[] = []
let handleNextInterval: any
ws.onmessage = e => {
incomingMessageQueue.push(e.data)
if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0)
}
}
function handleNext() {
if (incomingMessageQueue.length === 0) {
clearInterval(handleNextInterval)
handleNextInterval = null
return
}
var json = incomingMessageQueue.shift()
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 'EOSE': {
let id = data[1]
if (id in subListeners) {
subListeners[id].eose.forEach(cb => cb())
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
}
return
}
case 'OK': {
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (id in pubListeners) {
if (ok) pubListeners[id].ok.forEach(cb => cb())
else pubListeners[id].failed.forEach(cb => cb(reason))
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
pubListeners[id].failed = []
}
return
}
case 'NOTICE':
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
}
} catch (err) {
return
}
}
})
return connectionPromise
}
function connected() {
return ws?.readyState === 1
}
async function connect(): Promise<void> {
if (connected()) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000))
if (!connected()) {
return
}
}
try {
ws.send(msg)
} catch (err) {
console.log(err)
}
}
const sub = (
filters: Filter[],
{
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
}: SubscriptionOptions = {}
): Sub => {
let subid = id
openSubs[subid] = {
id: subid,
filters,
skipVerification,
alreadyHaveEvent
}
trySend(['REQ', subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification,
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
id: subid
}),
unsub: () => {
delete openSubs[subid]
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
subListeners[subid] = subListeners[subid] || {
event: [],
eose: []
}
subListeners[subid][type].push(cb)
},
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return {
url,
sub,
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
type: T,
cb: U
): void => {
listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) {
// i would love to know why we need this
;(cb as () => void)()
}
},
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
type: T,
cb: U
): void => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
new Promise(resolve => {
let s = sub(filters, opts)
let events: Event[] = []
let timeout = setTimeout(() => {
s.unsub()
resolve(events)
}, listTimeout)
s.on('eose', () => {
s.unsub()
clearTimeout(timeout)
resolve(events)
})
s.on('event', (event: Event) => {
events.push(event)
})
}),
get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
new Promise(resolve => {
let s = sub([filter], opts)
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, getTimeout)
s.on('event', (event: Event) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
publish(event: Event): Pub {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend(['EVENT', event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
},
connect,
close(): void {
listeners = {connect: [], disconnect: [], error: [], notice: []}
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
}
},
get status() {
return ws?.readyState ?? 3
}
}
}

View File

@@ -1,15 +0,0 @@
import type { Event } from './pure.ts'
/** Build an event for testing purposes. */
export function buildEvent(params: Partial<Event>): Event {
return {
id: '',
kind: 1,
pubkey: '',
created_at: 0,
content: '',
tags: [],
sig: '',
...params,
}
}

View File

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

183
utils.test.js Normal file
View File

@@ -0,0 +1,183 @@
/* eslint-env jest */
const {utils} = require('./lib/nostr.cjs')
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 30},
{created_at: 20},
{created_at: 10},
{created_at: 1}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 15
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 5
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 10, id: 'abc'}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 10}, {created_at: 20}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 1
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 10},
{created_at: 20},
{created_at: 30},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 25
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 50
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 30}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 30, id: 'abc'}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
})
})

View File

@@ -1,265 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
import type { Event } from './core.ts'
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({ created_at: 30 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 10 }),
buildEvent({ created_at: 1 }),
]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 15,
}),
)
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 10 }),
]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 5,
}),
)
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0: Event[] = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 10 }),
]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 10,
}),
)
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 10, id: 'abc' }),
]
const list1 = insertEventIntoDescendingList(
list0,
buildEvent({
id: 'abc',
created_at: 10,
}),
)
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 1,
}),
)
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 10,
}),
)
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({ created_at: 10 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 30 }),
buildEvent({ created_at: 40 }),
]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 25,
}),
)
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 40 }),
]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 50,
}),
)
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 30 }),
]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 20 }),
buildEvent({ created_at: 30, id: 'abc' }),
]
const list1 = insertEventIntoAscendingList(
list0,
buildEvent({
id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(3)
})
})
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('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')
})
test('dequeue from an empty queue', () => {
const queue = new Queue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
})
test('dequeue from a non-empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
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')
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])
})

175
utils.ts
View File

@@ -1,4 +1,4 @@
import type { Event } from './core.ts'
import {Event} from './event'
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
@@ -7,110 +7,105 @@ export function normalizeURL(url: string): string {
let p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
if (
(p.port === '80' && p.protocol === 'ws:') ||
(p.port === '443' && p.protocol === 'wss:')
)
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}
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[], 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 function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
//
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
//
export function insertEventIntoDescendingList(
sortedArray: Event[],
event: Event
) {
let start = 0
let end = arr.length - 1
let end = sortedArray.length - 1
let midPoint
let position = start
while (start <= end) {
const mid = Math.floor((start + end) / 2)
const cmp = compare(arr[mid])
if (cmp === 0) {
return [mid, true]
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
}
}
if (cmp < 0) {
end = mid - 1
} else {
start = mid + 1
}
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
}
return [start, false]
return sortedArray
}
export class QueueNode<V> {
public value: V
public next: QueueNode<V> | null = null
public prev: QueueNode<V> | null = null
export function insertEventIntoAscendingList(
sortedArray: Event[],
event: Event
) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
constructor(message: V) {
this.value = message
}
}
export class Queue<V> {
public first: QueueNode<V> | null
public last: QueueNode<V> | null
constructor() {
this.first = null
this.last = null
}
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 {
// list has elements, add as last
newNode.prev = this.last
this.last.next = newNode
this.last = newNode
}
return true
}
dequeue(): V | null {
if (!this.first) return null
if (this.first === this.last) {
const target = this.first
this.first = null
this.last = null
return target.value
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
}
}
const target = this.first
this.first = target.next
return target.value
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
}
return sortedArray
}

38
wasm.ts
View File

@@ -1,38 +0,0 @@
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'