Compare commits

...

67 Commits

Author SHA1 Message Date
fiatjaf
1b77d6e080 mention nostrify.dev on readme. 2024-08-08 12:13:41 -03:00
Sam Samskies
76d3a91600 authorization should not be in the form data 2024-08-08 12:00:58 -03:00
Sepehr Safari
6f334f31a7 add and improve helpers for nip29 2024-08-01 18:18:13 -03:00
Alex Gleason
9c009ac543 getFilterLimit: handle parameterized replaceable events 2024-07-20 22:00:28 -03:00
Shusui MOYATANI
a87099fa5c remove Content-Type header from NIP-96 uploadFile 2024-07-20 09:44:45 -03:00
António Conselheiro
475a22a95f methods for abstract pool (#419)
* include method to list current pool relays connections and to close all connections

* fix prettier
2024-07-18 13:33:29 -03:00
fiatjaf
54e352d8e2 tag v2.7.1 2024-07-09 07:59:04 -03:00
António Conselheiro
235a1c50cb making AbstractSimplesPool more extendable 2024-07-08 23:49:46 -03:00
António Conselheiro
dfc2107569 fix typo and include missing attributes for nip11 and they docs 2024-07-07 21:14:52 -03:00
Shusui MOYATANI
986b9d0cce support fallback tag in NIP-94 2024-07-04 15:07:37 -03:00
fiatjaf
753ff323ea specify websocket error as close reason when no message is available.
fixes https://github.com/nbd-wtf/nostr-tools/issues/411
2024-06-06 15:32:27 -03:00
Alex Gleason
f8c3e20f3d getFilterLimit: empty tags return 0 2024-05-30 16:32:55 -03:00
fiatjaf
87a91c2daf fix useWebSocketImplementation so it works with pool on nodejs esm. 2024-05-29 13:39:00 -03:00
Anderson Juhasc
4f1dc9ef1c fixing formatting with Prettier 2024-05-27 10:44:44 -03:00
Anderson Juhasc
faa1a9d556 adding nip06 examples to the README 2024-05-27 10:44:44 -03:00
Anderson Juhasc
97d838f254 white spaces removed 2024-05-27 10:44:44 -03:00
Don
260400b24d fix typo in nip07.ts 2024-05-27 10:42:35 -03:00
fiatjaf
6e5ab34a54 tag v2.6.0 2024-05-26 12:04:48 -03:00
fiatjaf
9562c408b3 never import anything from index.ts in submodules. 2024-05-26 12:04:48 -03:00
fiatjaf
4f4de458e9 rename Nip07 to WindowNostr. 2024-05-26 12:00:42 -03:00
António Conselheiro
88454de628 including interface for nip07 (#403)
* including interface for nip07

* fix types for NIP-07

* including NIP-07 export to jsr

* fix readme about nip07

* including in nip7 interface an output signature compatible with the event returned by the signer
2024-05-26 11:58:12 -03:00
Anderson Juhasc
9f5984d78d added functions accountFromSeedWords, extendedKeysFromSeedWords and accountFromExtendedKey to nip06 2024-05-26 08:21:07 -03:00
António Conselheiro
80df21d47f reviewing just installation in devcontainer 2024-05-25 07:28:49 -03:00
António Conselheiro
296e99d2a4 config devcontainer 2024-05-25 07:28:49 -03:00
fiatjaf
1cd9847ad5 filter: fix tests (remove prefix tests). 2024-05-19 14:58:38 -03:00
fiatjaf
fa31fdca78 nip46: try to decrypt with nip44 if nip04 fails. 2024-05-19 14:51:39 -03:00
fiatjaf
5876acd67a nip44: make the api less classy. 2024-05-19 14:40:23 -03:00
fiatjaf
44efd49bc0 filter: stop matching against id and pubkey prefixes. 2024-05-19 14:26:42 -03:00
fiatjaf
f4f9bece6e tag v2.5.2 2024-05-02 11:38:20 -03:00
hzrd149
e217f751da fix count request in anstract relay 2024-05-02 11:37:50 -03:00
fiatjaf
d0ae8b36a2 tag v2.5.1 2024-04-24 17:47:45 -03:00
fiatjaf
fd945757be accept localSecretKey as a parameter on nip46.createAccount()
fixes https://github.com/nbd-wtf/nostr-tools/issues/401
2024-04-24 10:12:33 -03:00
Daniele Tonon
c12ddd3c53 Add hyphen to BUNKER_REGEX 2024-04-20 16:16:13 -03:00
fiatjaf
1e9f828e3e tag 2.5.0 2024-04-12 21:51:09 -03:00
fiatjaf
0a5eaac088 pool.subscribeManyMap() 2024-04-12 21:50:26 -03:00
Alex Gleason
e858698cb9 Add stable sortEvents function 2024-04-12 21:20:06 -03:00
Alex Gleason
b349ee577d Merge pull request #392 from verbiricha/patch-1
fix typos in README
2024-04-12 17:57:49 -05:00
Alex Gleason
849a2ac3f3 Merge pull request #397 from alexgleason/pure-test
core.test.ts -> pure.test.ts
2024-04-12 17:48:09 -05:00
Alex Gleason
c18b94677c core.test.ts -> pure.test.ts 2024-04-12 17:44:36 -05:00
franzap
f306cec716 querySync does not take an array 2024-04-08 08:38:06 -03:00
fiatjaf
5c7e9c8f36 tag 2.4.0 2024-04-05 07:22:56 -03:00
fiatjaf
1d7620a057 temporary _onauth handler until we figure stuff out. 2024-04-05 07:22:17 -03:00
Alejandro
88247e56c1 fix typos in README 2024-03-27 18:58:23 +01:00
Nostr.Band
e5cda3509c Fix pubkey param to nip46 connect
NIP46 requires remote_user_pubkey as first param to connect.
2024-03-27 08:09:13 -03:00
abhay-raizada
02da1dc036 Readme: Instructions to convert sk to hex 2024-03-23 10:39:25 -03:00
Alex Gleason
7aed747bb2 Remove GitHub actions flow for publishing to JSR because it's not working 2024-03-18 13:23:29 -05:00
Alex Gleason
747a7944d7 Wasm: add explicit type to i 2024-03-18 13:04:21 -05:00
Alex Gleason
9f8b7274b3 Revert "tsconfig: for sanity, go back to moduleResolution bundler and see if that fixes it"
This reverts commit ee565db7f5.
2024-03-18 13:02:35 -05:00
Alex Gleason
ee565db7f5 tsconfig: for sanity, go back to moduleResolution bundler and see if that fixes it 2024-03-18 13:00:56 -05:00
Alex Gleason
e9ee8258e7 tsconfig: module NodeNext 2024-03-18 11:54:38 -05:00
Alex Gleason
ad07d260ab Add missing file extensions to imports 2024-03-18 11:51:00 -05:00
Alex Gleason
632184afb8 publish: npm install -g jsr 2024-03-18 11:45:11 -05:00
Alex Gleason
d7d5d30f41 publish: try bunx instead of npx 2024-03-18 11:40:47 -05:00
Alex Gleason
387ce2c335 publish: --allow-dirty ¯\_(ツ)_/¯ 2024-03-18 11:35:27 -05:00
Alex Gleason
b62b8f88af jsr: bump version to v2.3.2 2024-03-18 11:32:33 -05:00
Alex Gleason
6b43533f2e tsconfig: moduleResolution NodeNext 2024-03-18 11:32:04 -05:00
fiatjaf
e30e08d8e2 update relay on nip11 test. 2024-03-16 13:45:57 -03:00
Sepehr Safari
59426d9f35 Nip58 Implementation (#386)
* implement nip58

* add tests for nip58

* export nip58

* bump version
2024-03-16 13:44:56 -03:00
fiatjaf
5429142858 v2.3.2 2024-03-16 13:41:10 -03:00
fiatjaf
564c9bca17 don't try to send a ["CLOSE"] after the websocket is closed.
addresses https://github.com/nbd-wtf/nostr-tools/pull/387
2024-03-16 13:40:02 -03:00
fiatjaf
0190ae94a7 Revert "fix: error thrown on ws close"
This reverts commit e1bde08ff3.
2024-03-16 13:32:33 -03:00
Jeffrey Ko
e1bde08ff3 fix: error thrown on ws close 2024-03-16 13:29:24 -03:00
Alex Gleason
71456feb20 jsr: explicit exports 2024-03-13 00:17:07 -03:00
Alex Gleason
ca928c697b publish: --allow-slow-types for now 2024-03-11 15:15:41 -05:00
Alex Gleason
7b98cae7fa Merge pull request #382 from alexgleason/bundle-resolution
tsconfig: moduleResolution Bundler
2024-03-11 14:47:27 -05:00
Alex Gleason
db53f37161 tsconfig: moduleResolution Bundler 2024-03-11 14:22:11 -05:00
Alex Gleason
1691f0b51d Merge pull request #381 from nbd-wtf/alexgleason-patch-1
publish: bun i
2024-03-11 13:00:14 -05:00
42 changed files with 2180 additions and 683 deletions

19
.devcontainer/Dockerfile Executable file
View File

@@ -0,0 +1,19 @@
FROM node:20
RUN npm install typescript -g
# Install bun
RUN curl -fsSL https://bun.sh/install | bash
# Install just
WORKDIR /usr/bin
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN chmod +x ./just
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
WORKDIR /nostr-tools
ENV LANG C.UTF-8
# The run the start script
CMD [ "/bin/bash" ]

19
.devcontainer/devcontainer.json Executable file
View File

@@ -0,0 +1,19 @@
{
"name": "Nostr Tools",
"dockerComposeFile": [
"docker-compose.yml"
],
"service": "nostr-tools-dev",
"workspaceFolder": "/nostr-tools",
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"eamodio.gitlens",
"dbaeumer.vscode-eslint",
"manishsencha.readme-preview",
"wix.vscode-import-cost"
]
}
}
}

View File

@@ -0,0 +1,13 @@
version: '3.9'
services:
nostr-tools-dev:
image: nostr-tools-dev
container_name: nostr-tools-dev
build:
context: ../.
dockerfile: ./.devcontainer/Dockerfile
working_dir: /nostr-tools
volumes:
- ..:/nostr-tools:cached
tty: true

View File

@@ -1,18 +0,0 @@
name: Publish
on:
push:
branches:
- master
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # The OIDC ID token is used for authentication with JSR.
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun i
- run: npx jsr publish

View File

@@ -4,7 +4,7 @@ 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).
This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or 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
@@ -25,6 +25,15 @@ let sk = generateSecretKey() // `sk` is a Uint8Array
let pk = getPublicKey(sk) // `pk` is a hex string
```
To get the secret key in hex format, use
```js
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
let skHex = bytesToHex(sk)
let backToBytes = hexToBytes(skHex)
```
### Creating, signing and verifying events
```js
@@ -95,8 +104,11 @@ relay.close()
To use this on Node.js you first must install `ws` and call something like this:
```js
import { useWebSocketImplementation } from 'nostr-tools/relay'
useWebSocketImplementation(require('ws'))
import { useWebSocketImplementation } from 'nostr-tools/pool'
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
import WebSocket from 'ws'
useWebSocketImplementation(WebSocket)
```
### Interacting with multiple relays
@@ -129,7 +141,7 @@ let h = pool.subscribeMany(
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 events = await pool.querySync(relays, { kinds: [0, 1] })
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
@@ -174,6 +186,43 @@ import { useFetchImplementation } from 'nostr-tools/nip05'
useFetchImplementation(require('node-fetch'))
```
### Including NIP-07 types
```js
import type { WindowNostr } from 'nostr-tools/nip07'
declare global {
interface Window {
nostr?: WindowNostr;
}
}
```
### Generating NIP-06 keys
```js
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey
} from 'nostr-tools/nip06'
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' // optional
const accountIndex = 0
const sk0 = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex)
const { privateKey: sk1, publicKey: pk1 } = accountFromSeedWords(mnemonic, passphrase, accountIndex)
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(mnemonic, passphrase, extendedAccountIndex)
const { privateKey: sk2, publicKey: pk2 } = accountFromExtendedKey(privateExtendedKey)
const { publicKey: pk3 } = accountFromExtendedKey(publicExtendedKey)
```
### Encoding and decoding NIP-19 codes
```js

View File

@@ -1,4 +1,11 @@
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
/* global WebSocket */
import {
AbstractRelay as AbstractRelay,
SubscriptionParams,
Subscription,
type AbstractRelayConstructorOptions,
} from './abstract-relay.ts'
import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts'
@@ -7,6 +14,8 @@ import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: () => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
maxWait?: number
onclose?: (reasons: string[]) => void
@@ -14,15 +23,18 @@ export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
}
export class AbstractSimplePool {
private relays = new Map<string, AbstractRelay>()
protected relays = new Map<string, AbstractRelay>()
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent']
public trustedRelayURLs: Set<string> = new Set()
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
private _WebSocket?: typeof WebSocket
constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@@ -32,6 +44,7 @@ export class AbstractSimplePool {
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket,
})
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay)
@@ -48,6 +61,10 @@ export class AbstractSimplePool {
}
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params)
}
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
@@ -61,12 +78,13 @@ export class AbstractSimplePool {
const _knownIds = new Set<string>()
const subs: Subscription[] = []
const relaysLength = Object.keys(requests).length
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relays.length) {
if (eosesReceived.filter(a => a).length === relaysLength) {
params.oneose?.()
handleEose = () => {}
}
@@ -76,7 +94,7 @@ export class AbstractSimplePool {
let handleClose = (i: number, reason: string) => {
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relays.length) {
if (closesReceived.filter(a => a).length === relaysLength) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
@@ -93,13 +111,16 @@ export class AbstractSimplePool {
// open a subscription in all given relays
const allOpened = Promise.all(
relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
Object.entries(requests).map(async (req, i, arr) => {
if (arr.indexOf(req) !== i) {
// duplicate
handleClose(i, 'duplicate url')
return
}
let [url, filters] = req
url = normalizeURL(url)
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
@@ -187,4 +208,16 @@ export class AbstractSimplePool {
return r.publish(event)
})
}
listConnectionStatus(): Map<string, boolean> {
const map = new Map<string, boolean>()
this.relays.forEach((relay, url) => map.set(url, relay.connected))
return map
}
destroy(): void {
this.relays.forEach(conn => conn.close())
this.relays = new Map()
}
}

View File

@@ -7,14 +7,9 @@ import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket
}
export class AbstractRelay {
@@ -24,6 +19,9 @@ export class AbstractRelay {
public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
// this is exposed just to help in ndk migration, shouldn't be relied upon
public _onauth: ((challenge: string) => void) | null = null
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map()
@@ -39,12 +37,15 @@ export class AbstractRelay {
private serial: number = 0
private verifyEvent: Nostr['verifyEvent']
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
private _WebSocket: typeof WebSocket
constructor(url: string, opts: AbstractRelayConstructorOptions) {
this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket
}
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }): Promise<AbstractRelay> {
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts)
await relay.connect()
return relay
@@ -84,7 +85,7 @@ export class AbstractRelay {
}, this.connectionTimeout)
try {
this.ws = new _WebSocket(this.url)
this.ws = new this._WebSocket(this.url)
} catch (err) {
reject(err)
return
@@ -97,19 +98,22 @@ export class AbstractRelay {
}
this.ws.onerror = ev => {
reject((ev as any).message)
reject((ev as any).message || 'websocket error')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
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
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
}
}
this.ws.onmessage = this._onmessage.bind(this)
@@ -212,6 +216,7 @@ export class AbstractRelay {
return
case 'AUTH': {
this.challenge = data[1] as string
this._onauth?.(data[1] as string)
return
}
}
@@ -252,7 +257,7 @@ export class AbstractRelay {
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret
}
@@ -338,7 +343,7 @@ export class Subscription {
}
public close(reason: string = 'closed by caller') {
if (!this.closed) {
if (!this.closed && this.relay.connected) {
// 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) + ']')

View File

@@ -1,8 +1,8 @@
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 { NostrEvent } from './core.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
import { setNostrWasm, verifyEvent } from './wasm.ts'
import { AbstractRelay } from './abstract-relay.ts'
import { Relay as PureRelay } from './relay.ts'
import { alwaysTrue } from './helpers.ts'

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,293 +1,19 @@
import { describe, test, expect } from 'bun:test'
import { 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'
import { sortEvents } from './core.ts'
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)
})
test('sortEvents', () => {
const events = [
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
]
const sortedEvents = sortEvents(events)
expect(sortedEvents).toEqual([
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
])
})

14
core.ts
View File

@@ -49,3 +49,17 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
return true
}
/**
* Sort events in reverse-chronological order by the `created_at` timestamp,
* and then by the event `id` (lexicographically) in case of ties.
* This mutates the array.
*/
export function sortEvents(events: Event[]): Event[] {
return events.sort((a: NostrEvent, b: NostrEvent): number => {
if (a.created_at !== b.created_at) {
return b.created_at - a.created_at
}
return a.id.localeCompare(b.id)
})
}

View File

@@ -13,7 +13,6 @@ describe('Filter', () => {
until: 200,
'#tag': ['value'],
}
const event = buildEvent({
id: '123',
kind: 1,
@@ -21,39 +20,21 @@ describe('Filter', () => {
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)
})
@@ -154,25 +135,8 @@ describe('Filter', () => {
{ 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)
})
@@ -201,11 +165,8 @@ describe('Filter', () => {
{ 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)
})
@@ -221,9 +182,7 @@ describe('Filter', () => {
pubkey: 'def',
created_at: 200,
})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
@@ -256,6 +215,16 @@ describe('Filter', () => {
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
})
test('should handle parameterized replaceable events', () => {
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
expect(
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
).toEqual(8)
})
test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
})
@@ -263,5 +232,9 @@ describe('Filter', () => {
test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity)
})
test('empty tags return 0', () => {
expect(getFilterLimit({ '#p': [] })).toEqual(0)
})
})
})

View File

@@ -1,5 +1,5 @@
import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts'
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts'
export type Filter = {
ids?: string[]
@@ -14,15 +14,13 @@ export type Filter = {
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
}
return false
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -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
}
return false
}
for (let f in filter) {
@@ -41,7 +39,9 @@ export function matchFilter(filter: Filter, event: Event): boolean {
export function matchFilters(filters: Filter[], event: Event): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
if (matchFilter(filters[i], event)) {
return true
}
}
return false
}
@@ -72,17 +72,34 @@ export function mergeFilters(...filters: Filter[]): Filter {
return result
}
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
/**
* Calculate the intrinsic limit of a filter.
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
*/
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
for (const [key, value] of Object.entries(filter)) {
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
}
return Math.min(
// The `limit` property creates an artificial limit.
Math.max(0, filter.limit ?? Infinity),
// There can only be one event per `id`.
filter.ids?.length ?? Infinity,
// Replaceable events are limited by the number of authors and kinds.
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
filter.authors?.length && filter.kinds?.every(kind => isParameterizedReplaceableKind(kind)) && filter['#d']?.length
? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity,
)
}

View File

@@ -1,7 +1,7 @@
export * from './pure.ts'
export * from './relay.ts'
export { Relay } from './relay.ts'
export * from './filter.ts'
export * from './pool.ts'
export { SimplePool } from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'

View File

@@ -1,5 +1,45 @@
{
"name": "@nostr/tools",
"version": "2.3.1",
"exports": "./index.ts"
"version": "2.3.2",
"exports": {
".": "./index.ts",
"./core": "./core.ts",
"./pure": "./pure.ts",
"./wasm": "./wasm.ts",
"./kinds": "./kinds.ts",
"./filter": "./filter.ts",
"./abstract-relay": "./abstract-relay.ts",
"./relay": "./relay.ts",
"./abstract-pool": "./abstract-pool.ts",
"./pool": "./pool.ts",
"./references": "./references.ts",
"./nip04": "./nip04.ts",
"./nip05": "./nip05.ts",
"./nip06": "./nip06.ts",
"./nip07": "./nip07.ts",
"./nip10": "./nip10.ts",
"./nip11": "./nip11.ts",
"./nip13": "./nip13.ts",
"./nip18": "./nip18.ts",
"./nip19": "./nip19.ts",
"./nip21": "./nip21.ts",
"./nip25": "./nip25.ts",
"./nip27": "./nip27.ts",
"./nip28": "./nip28.ts",
"./nip29": "./nip29.ts",
"./nip30": "./nip30.ts",
"./nip39": "./nip39.ts",
"./nip42": "./nip42.ts",
"./nip44": "./nip44.ts",
"./nip46": "./nip46.ts",
"./nip49": "./nip49.ts",
"./nip57": "./nip57.ts",
"./nip75": "./nip75.ts",
"./nip94": "./nip94.ts",
"./nip96": "./nip96.ts",
"./nip98": "./nip98.ts",
"./nip99": "./nip99.ts",
"./fakejson": "./fakejson.ts",
"./utils": "./utils.ts"
}
}

View File

@@ -1,5 +1,10 @@
import { test, expect } from 'bun:test'
import { privateKeyFromSeedWords } from './nip06.ts'
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey,
} 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'
@@ -26,3 +31,46 @@ test('generate private key for account 1 from a mnemonic and passphrase', async
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
})
test('generate private and public 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, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
})
test('generate extended keys from mnemonic', () => {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const passphrase = ''
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
mnemonic,
passphrase,
extendedAccountIndex,
)
expect(privateExtendedKey).toBe(
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
)
expect(publicExtendedKey).toBe(
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
)
})
test('generate account from extended private key', () => {
const xprv =
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
expect(privateKey).toBe('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731')
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
})
test('generate account from extended public key', () => {
const xpub =
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
const { publicKey } = accountFromExtendedKey(xpub)
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
})

View File

@@ -3,13 +3,69 @@ import { wordlist } from '@scure/bip39/wordlists/english'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
const DERIVATION_PATH = `m/44'/1237'`
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return bytesToHex(privateKey)
}
export function accountFromSeedWords(
mnemonic: string,
passphrase?: string,
accountIndex = 0,
): {
privateKey: string
publicKey: string
} {
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
const privateKey = bytesToHex(seed.privateKey!)
const publicKey = bytesToHex(seed.publicKey!.slice(1))
if (!privateKey && !publicKey) {
throw new Error('could not derive key pair')
}
return { privateKey, publicKey }
}
export function extendedKeysFromSeedWords(
mnemonic: string,
passphrase?: string,
extendedAccountIndex = 0,
): {
privateExtendedKey: string
publicExtendedKey: string
} {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
let privateExtendedKey = seed.privateExtendedKey
let publicExtendedKey = seed.publicExtendedKey
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
return { privateExtendedKey, publicExtendedKey }
}
export function accountFromExtendedKey(
base58key: string,
accountIndex = 0,
): {
privateKey?: string
publicKey: string
} {
let extendedKey = HDKey.fromExtendedKey(base58key)
let version = base58key.slice(0, 4)
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
let publicKey = bytesToHex(child.publicKey!.slice(1))
if (!publicKey) throw new Error('could not derive public key')
if (version === 'xprv') {
let privateKey = bytesToHex(child.privateKey!)
if (!privateKey) throw new Error('could not derive private key')
return { privateKey, publicKey }
}
return { publicKey }
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}

16
nip07.ts Normal file
View File

@@ -0,0 +1,16 @@
import { EventTemplate, NostrEvent } from './core.ts'
import { RelayRecord } from './relay.ts'
export interface WindowNostr {
getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<NostrEvent>
getRelays(): Promise<RelayRecord>
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
}

View File

@@ -1,17 +1,16 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, fetchRelayInformation } from './nip11'
import { useFetchImplementation, fetchRelayInformation } from './nip11.ts'
// TODO: replace with a mock
describe('requesting relay as for NIP11', () => {
useFetchImplementation(fetch)
test('testing a relay', async () => {
const info = await fetchRelayInformation('wss://atlas.nostr.land')
expect(info.name).toEqual('nostr.land')
expect(info.description).toContain('nostr.land family')
expect(info.fees).toBeTruthy()
const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
expect(info.software).toEqual('custom')
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
})
})

View File

@@ -68,7 +68,7 @@ export interface BasicRelayInformation {
* 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
* @param max_subscriptions 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.
@@ -101,12 +101,17 @@ export interface BasicRelayInformation {
* 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 restricted_writes: this relay requires some kind
* of condition to be fulfilled in order to accept events
* (not necessarily, but including
* @param payment_required this relay requires payment
* before a new connection may perform any action.
* @param created_at_lower_limit: 'created_at' lower limit
* @param created_at_upper_limit: 'created_at' upper limit
*/
export interface Limitations {
max_message_length: number
max_subscription: number
max_subscriptions: number
max_filters: number
max_limit: number
max_subid_length: number
@@ -116,6 +121,9 @@ export interface Limitations {
min_pow_difficulty: number
auth_required: boolean
payment_required: boolean
created_at_lower_limit: number
created_at_upper_limit: number
restricted_writes: boolean
}
interface RetentionDetails {

690
nip29.ts
View File

@@ -1,80 +1,514 @@
import { AbstractSimplePool } from './abstract-pool'
import { Subscription } from './abstract-relay'
import { decode } from './nip19'
import type { Event } from './core'
import { fetchRelayInformation } from './nip11'
import { normalizeURL } from './utils'
import { AddressPointer } from './nip19'
import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts'
import type { Event, EventTemplate } from './core.ts'
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { AddressPointer, decode } from './nip19.ts'
import { normalizeURL } from './utils.ts'
export function subscribeRelayGroups(
pool: AbstractSimplePool,
url: string,
params: {
ongroups: (_: Group[]) => void
onerror: (_: Error) => void
onconnect?: () => void
},
): () => void {
let normalized = normalizeURL(url)
let sub: Subscription
let groups: Group[] = []
fetchRelayInformation(normalized)
.then(async info => {
let rl = await pool.ensureRelay(normalized)
params.onconnect?.()
sub = rl.prepareSubscription(
[
{
kinds: [39000],
limit: 50,
authors: [info.pubkey],
},
],
{
onevent(event: Event) {
groups.push(parseGroup(event, normalized))
},
oneose() {
params.ongroups(groups)
sub.onevent = (event: Event) => {
groups.push(parseGroup(event, normalized))
params.ongroups(groups)
}
},
},
)
sub.fire()
})
.catch(params.onerror)
return () => sub.close()
/**
* Represents a NIP29 group.
*/
export type Group = {
relay: string
metadata: GroupMetadata
admins?: GroupAdmin[]
members?: GroupMember[]
reference: GroupReference
}
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
let normalized = normalizeURL(gr.host)
let info = await fetchRelayInformation(normalized)
let event = await pool.get([normalized], {
kinds: [39000],
authors: [info.pubkey],
'#d': [gr.id],
})
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
return parseGroup(event, normalized)
}
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
let gr = parseGroupCode(code)
if (!gr) throw new Error(`code "${code}" does not identify a group`)
return loadGroup(pool, gr)
/**
* Represents the metadata for a NIP29 group.
*/
export type GroupMetadata = {
id: string
pubkey: string
name?: string
picture?: string
about?: string
isPublic?: boolean
isOpen?: boolean
}
/**
* Represents a NIP29 group reference.
*/
export type GroupReference = {
id: string
host: string
}
/**
* Represents a NIP29 group member.
*/
export type GroupMember = {
pubkey: string
label?: string
}
/**
* Represents a NIP29 group admin.
*/
export type GroupAdmin = {
pubkey: string
label?: string
permissions: GroupAdminPermission[]
}
/**
* Represents the permissions that a NIP29 group admin can have.
*/
export enum GroupAdminPermission {
AddUser = 'add-user',
EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event',
RemoveUser = 'remove-user',
AddPermission = 'add-permission',
RemovePermission = 'remove-permission',
EditGroupStatus = 'edit-group-status',
}
/**
* Generates a group metadata event template.
*
* @param group - The group object.
* @returns An event template with the generated group metadata that can be signed later.
*/
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
group.metadata.name && tags.push(['name', group.metadata.name])
group.metadata.picture && tags.push(['picture', group.metadata.picture])
group.metadata.about && tags.push(['about', group.metadata.about])
group.metadata.isPublic && tags.push(['public'])
group.metadata.isOpen && tags.push(['open'])
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39000,
tags,
}
}
/**
* Validates a group metadata event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is valid.
*/
export function validateGroupMetadataEvent(event: Event): boolean {
if (event.kind !== 39000) return false
if (!event.pubkey) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an event template for group admins.
*
* @param group - The group object.
* @param admins - An array of group admins.
* @returns The generated event template with the group admins that can be signed later.
*/
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const admin of admins) {
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39001,
tags,
}
}
/**
* Validates a group admins event.
*
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateGroupAdminsEvent(event: Event): boolean {
if (event.kind !== 39001) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate permissions
for (const [tag, value, label, ...permissions] of event.tags) {
if (tag !== 'p') continue
for (let i = 0; i < permissions.length; i += 1) {
if (typeof permissions[i] !== 'string') return false
// validate permission name from the GroupAdminPermission enum
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
}
}
return true
}
/**
* Generates an event template for a group with its members.
*
* @param group - The group object.
* @param members - An array of group members.
* @returns The generated event template with the group members that can be signed later.
*/
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const member of members) {
tags.push(['p', member.pubkey, member.label || ''])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39002,
tags,
}
}
/**
* Validates a group members event.
*
* @param event - The event to validate.
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
*/
export function validateGroupMembersEvent(event: Event): boolean {
if (event.kind !== 39002) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Returns the normalized relay URL based on the provided group reference.
*
* @param groupReference - The group reference object containing the host.
* @returns The normalized relay URL.
*/
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
return normalizeURL(groupReference.host)
}
/**
* Fetches relay information by group reference.
*
* @param groupReference The group reference.
* @returns A promise that resolves to the relay information.
*/
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
return fetchRelayInformation(normalizedRelayURL)
}
/**
* Fetches the group metadata event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
* @throws {Error} If the group is not found on the specified relay.
*/
export async function fetchGroupMetadataEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
kinds: [39000],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMetadataEvent
}
/**
* Parses a group metadata event and returns the corresponding GroupMetadata object.
*
* @param event - The event to parse.
* @returns The parsed GroupMetadata object.
* @throws An error if the group metadata event is invalid.
*/
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
const metadata: GroupMetadata = {
id: '',
pubkey: event.pubkey,
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'd':
metadata.id = value
break
case 'name':
metadata.name = value
break
case 'picture':
metadata.picture = value
break
case 'about':
metadata.about = value
break
case 'public':
metadata.isPublic = true
break
case 'open':
metadata.isOpen = true
break
}
}
return metadata
}
/**
* Fetches the group admins event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information.
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
* @throws {Error} If the group admins event is not found on the specified relay.
*/
export async function fetchGroupAdminsEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
kinds: [39001],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupAdminsEvent
}
/**
* Parses a group admins event and returns an array of GroupAdmin objects.
*
* @param event - The event to parse.
* @returns An array of GroupAdmin objects.
* @throws Throws an error if the group admins event is invalid.
*/
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
const admins: GroupAdmin[] = []
for (const [tag, value, label, ...permissions] of event.tags) {
if (tag !== 'p') continue
admins.push({
pubkey: value,
label,
permissions: permissions as GroupAdminPermission[],
})
}
return admins
}
/**
* Fetches the group members event from the specified relay.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool object.
* @param {GroupReference} options.groupReference - The group reference object.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
* @throws {Error} If the group members event is not found.
*/
export async function fetchGroupMembersEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMembersEvent = await pool.get([normalizedRelayURL], {
kinds: [39002],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMembersEvent
}
/**
* Parses a group members event and returns an array of GroupMember objects.
* @param event - The event to parse.
* @returns An array of GroupMember objects.
* @throws Throws an error if the group members event is invalid.
*/
export function parseGroupMembersEvent(event: Event): GroupMember[] {
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
const members: GroupMember[] = []
for (const [tag, value, label] of event.tags) {
if (tag !== 'p') continue
members.push({
pubkey: value,
label,
})
}
return members
}
/**
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
* If the normalized relay URL is not provided, it will be obtained using the group reference.
* If the relay information is not provided, it will be fetched using the normalized relay URL.
*
* @param {Object} options - The options for loading the group.
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
* @param {GroupReference} options.groupReference - The reference of the group to load.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
* @returns {Promise<Group>} A promise that resolves to the loaded group.
*/
export async function loadGroup({
pool,
groupReference,
normalizedRelayURL,
relayInformation,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Group> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const metadata = parseGroupMetadataEvent(metadataEvent)
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const admins = parseGroupAdminsEvent(adminsEvent)
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const members = parseGroupMembersEvent(membersEvent)
const group: Group = {
relay: normalizedRelayURL,
metadata,
admins,
members,
reference: groupReference,
}
return group
}
/**
* Loads a group from the specified pool using the provided group code.
*
* @param {AbstractSimplePool} pool - The pool to load the group from.
* @param {string} code - The code representing the group.
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
* @throws {Error} - If the group code is invalid.
*/
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
const groupReference = parseGroupCode(code)
if (!groupReference) throw new Error('invalid group code')
return loadGroup({ pool, groupReference })
}
/**
* Parses a group code and returns a GroupReference object.
*
* @param code The group code to parse.
* @returns A GroupReference object if the code is valid, otherwise null.
*/
export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) {
try {
@@ -99,68 +533,74 @@ export function parseGroupCode(code: string): null | GroupReference {
return null
}
/**
* Encodes a group reference into a string.
*
* @param gr - The group reference to encode.
* @returns The encoded group reference as a string.
*/
export function encodeGroupReference(gr: GroupReference): string {
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
return `${gr.host}'${gr.id}`
const { host, id } = gr
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
return `${normalizedHost}'${id}`
}
export type Group = {
id: string
relay: string
pubkey: string
name?: string
picture?: string
about?: string
public?: boolean
open?: boolean
}
/**
* Subscribes to relay groups metadata events and calls the provided event handler function
* when an event is received.
*
* @param {Object} options - The options for subscribing to relay groups metadata events.
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
* @param {string} options.relayURL - The URL of the relay.
* @param {Function} options.onError - The error handler function.
* @param {Function} options.onEvent - The event handler function.
* @param {Function} [options.onConnect] - The connect handler function.
* @returns {Function} - A function to close the subscription
*/
export function subscribeRelayGroupsMetadataEvents({
pool,
relayURL,
onError,
onEvent,
onConnect,
}: {
pool: AbstractSimplePool
relayURL: string
onError: (err: Error) => void
onEvent: (event: Event) => void
onConnect?: () => void
}): () => void {
let sub: Subscription
export function parseGroup(event: Event, relay: string): Group {
const group: Partial<Group> = { relay, pubkey: event.pubkey }
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
switch (tag[0]) {
case 'd':
group.id = tag[1] || ''
break
case 'name':
group.name = tag[1] || ''
break
case 'about':
group.about = tag[1] || ''
break
case 'picture':
group.picture = tag[1] || ''
break
case 'open':
group.open = true
break
case 'public':
group.public = true
break
}
}
return group as Group
}
const normalizedRelayURL = normalizeURL(relayURL)
export type Member = {
pubkey: string
label?: string
permissions: string[]
}
fetchRelayInformation(normalizedRelayURL)
.then(async info => {
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
export function parseMembers(event: Event): Member[] {
const members = []
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
if (tag.length < 2) continue
if (tag[0] !== 'p') continue
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
const member: Member = { pubkey: tag[1], permissions: [] }
if (tag.length > 2) member.label = tag[2]
if (tag.length > 3) member.permissions = tag.slice(3)
members.push(member)
}
return members
onConnect?.()
sub = abstractedRelay.prepareSubscription(
[
{
kinds: [39000],
limit: 50,
authors: [info.pubkey],
},
],
{
onevent(event: Event) {
onEvent(event)
},
},
)
})
.catch(err => {
sub.close()
onError(err)
})
return () => sub.close()
}

View File

@@ -7,7 +7,7 @@ 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)
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
}
})
@@ -15,7 +15,7 @@ test('get_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)
const key = v2.utils.getConversationKey(hexToBytes(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)
@@ -39,6 +39,8 @@ test('decrypt', async () => {
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)/)
expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
/(Point is not on curve|Cannot find square root)/,
)
}
})

221
nip44.ts
View File

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

View File

@@ -1,11 +1,13 @@
import { hexToBytes } from '@noble/hashes/utils'
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt, encrypt } from './nip04.ts'
import { getConversationKey, decrypt as nip44decrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
import type { RelayRecord } from './relay.ts'
var _fetch: any
@@ -17,7 +19,7 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = {
@@ -109,13 +111,21 @@ export class BunkerSigner {
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
const skBytes = this.secretKey
this.subCloser = this.pool.subscribeMany(
this.bp.relays,
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }],
{
async onevent(event: NostrEvent) {
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
let o
try {
o = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
} catch (err) {
o = JSON.parse(nip44decrypt(event.content, getConversationKey(skBytes, event.pubkey)))
}
const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id]
@@ -193,7 +203,7 @@ export class BunkerSigner {
* Calls the "connect" method on the bunker.
*/
async connect(): Promise<void> {
await this.sendRequest('connect', [getPublicKey(this.secretKey), this.bp.secret || ''])
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || ''])
}
/**
@@ -207,7 +217,7 @@ export class BunkerSigner {
/**
* Calls the "get_relays" method on the bunker.
*/
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> {
async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', []))
}
@@ -254,6 +264,8 @@ export class BunkerSigner {
* @param username - The username for the account.
* @param domain - The domain for the account.
* @param email - The optional email for the account.
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
this will default to generating a random key.
* @throws Error if the email is present but invalid.
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
*/
@@ -263,11 +275,11 @@ export async function createAccount(
username: string,
domain: string,
email?: string,
localSecretKey: Uint8Array = generateSecretKey(),
): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let sk = generateSecretKey()
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'

View File

@@ -1,5 +1,5 @@
import { test, expect } from 'bun:test'
import { decrypt, encrypt } from './nip49'
import { decrypt, encrypt } from './nip49.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('encrypt and decrypt', () => {

View File

@@ -1,7 +1,7 @@
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, encodeBytes } from './nip19'
import { Bech32MaxSize, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base'
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {

357
nip58.test.ts Normal file
View File

@@ -0,0 +1,357 @@
import { expect, test } from 'bun:test'
import { EventTemplate } from './core.ts'
import {
BadgeAward as BadgeAwardKind,
BadgeDefinition as BadgeDefinitionKind,
ProfileBadges as ProfileBadgesKind,
} from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
import {
BadgeAward,
BadgeDefinition,
ProfileBadges,
generateBadgeAwardEventTemplate,
generateBadgeDefinitionEventTemplate,
generateProfileBadgesEventTemplate,
validateBadgeAwardEvent,
validateBadgeDefinitionEvent,
validateProfileBadgesEvent,
} from './nip58.ts'
test('BadgeDefinition has required property "d"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
expect(badge.d).toEqual('badge-id')
})
test('BadgeDefinition has optional property "name"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
name: 'Badge Name',
}
expect(badge.name).toEqual('Badge Name')
})
test('BadgeDefinition has optional property "description"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
description: 'Badge Description',
}
expect(badge.description).toEqual('Badge Description')
})
test('BadgeDefinition has optional property "image"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
image: ['https://example.com/badge.png', '1024x1024'],
}
expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024'])
})
test('BadgeDefinition has optional property "thumbs"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
thumbs: [
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
],
}
expect(badge.thumbs).toEqual([
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
])
})
test('BadgeAward has required property "a"', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
expect(badgeAward.a).toEqual('badge-definition-address')
})
test('BadgeAward has required property "p"', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
expect(badgeAward.p).toEqual([
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
])
})
test('ProfileBadges has required property "d"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
expect(profileBadges.d).toEqual('profile_badges')
})
test('ProfileBadges has required property "badges"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
expect(profileBadges.badges).toEqual([])
})
test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address',
e: ['badge-award-event-id'],
},
],
}
expect(profileBadges.badges[0].a).toEqual('badge-definition-address')
expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id'])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
name: 'Badge Name',
description: 'Badge Description',
image: ['https://example.com/badge.png', '1024x1024'],
thumbs: [
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
],
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
})
test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeDefinitionEvent(event)
expect(isValid).toBe(true)
})
test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeDefinitionEvent(event)
expect(isValid).toBe(false)
})
test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags: [
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeAwardEvent(event)
expect(isValid).toBe(true)
})
test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeAwardEvent(event)
expect(isValid).toBe(false)
})
test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([['d', 'profile_badges']])
})
test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address',
e: ['badge-award-event-id'],
},
],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([
['d', 'profile_badges'],
['a', 'badge-definition-address'],
['e', 'badge-award-event-id'],
])
})
test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address1',
e: ['badge-award-event-id1', 'badge-award-event-id2'],
},
{
a: 'badge-definition-address2',
e: ['badge-award-event-id3'],
},
],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([
['d', 'profile_badges'],
['a', 'badge-definition-address1'],
['e', 'badge-award-event-id1', 'badge-award-event-id2'],
['a', 'badge-definition-address2'],
['e', 'badge-award-event-id3'],
])
})
test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags: [
['d', 'profile_badges'],
['a', 'badge-definition-address'],
['e', 'badge-award-event-id'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateProfileBadgesEvent(event)
expect(isValid).toBe(true)
})
test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateProfileBadgesEvent(event)
expect(isValid).toBe(false)
})

245
nip58.ts Normal file
View File

@@ -0,0 +1,245 @@
import { Event, EventTemplate } from './core.ts'
import {
BadgeAward as BadgeAwardKind,
BadgeDefinition as BadgeDefinitionKind,
ProfileBadges as ProfileBadgesKind,
} from './kinds.ts'
/**
* Represents the structure for defining a badge within the Nostr network.
* This structure is used to create templates for badge definition events,
* facilitating the recognition and awarding of badges to users for various achievements.
*/
export type BadgeDefinition = {
/**
* A unique identifier for the badge. This is used to distinguish badges
* from one another and should be unique across all badge definitions.
* Typically, this could be a short, descriptive string.
*/
d: string
/**
* An optional short name for the badge. This provides a human-readable
* title for the badge, making it easier to recognize and refer to.
*/
name?: string
/**
* An optional description for the badge. This field can be used to
* provide more detailed information about the badge, such as the criteria
* for its awarding or its significance.
*/
description?: string
/**
* An optional image URL and dimensions for the badge. The first element
* of the tuple is the URL pointing to a high-resolution image representing
* the badge, and the second element specifies the image's dimensions in
* the format "widthxheight". The recommended dimensions are 1024x1024 pixels.
*/
image?: [string, string]
/**
* An optional list of thumbnail images for the badge. Each element in the
* array is a tuple, where the first element is the URL pointing to a thumbnail
* version of the badge image, and the second element specifies the thumbnail's
* dimensions in the format "widthxheight". Multiple thumbnails can be provided
* to support different display sizes.
*/
thumbs?: Array<[string, string]>
}
/**
* Represents the structure for awarding a badge to one or more recipients
* within the Nostr network. This structure is used to create templates for
* badge award events, which are immutable and signify the recognition of
* individuals' achievements or contributions.
*/
export type BadgeAward = {
/**
* A reference to the Badge Definition event. This is typically composed
* of the event ID of the badge definition. It establishes a clear linkage
* between the badge being awarded and its original definition, ensuring
* that recipients are awarded the correct badge.
*/
a: string
/**
* An array of p tags, each containing a pubkey and its associated relays.
*/
p: string[][]
}
/**
* Represents the collection of badges a user chooses to display on their profile.
* This structure is crucial for applications that allow users to showcase achievements
* or recognitions in the form of badges, following the specifications of NIP-58.
*/
export type ProfileBadges = {
/**
* A unique identifier for the profile badges collection. According to NIP-58,
* this should be set to "profile_badges" to differentiate it from other event types.
*/
d: 'profile_badges'
/**
* A list of badges that the user has elected to display on their profile. Each item
* in the array represents a specific badge, including references to both its definition
* and the award event.
*/
badges: Array<{
/**
* The event address of the badge definition. This is a reference to the specific badge
* being displayed, linking back to the badge's original definition event. It allows
* clients to fetch and display the badge's details, such as its name, description,
* and image.
*/
a: string
/**
* The event id of the badge award with corresponding relays. This references the event
* in which the badge was awarded to the user. It is crucial for verifying the
* authenticity of the badge display, ensuring that the user was indeed awarded the
* badge they are choosing to display.
*/
e: string[]
}>
}
/**
* Generates an EventTemplate based on the provided BadgeDefinition.
*
* @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateBadgeDefinitionEventTemplate({
d,
description,
image,
name,
thumbs,
}: BadgeDefinition): EventTemplate {
// Mandatory tags
const tags: string[][] = [['d', d]]
// Append optional tags
name && tags.push(['name', name])
description && tags.push(['description', description])
image && tags.push(['image', ...image])
if (thumbs) {
for (const thumb of thumbs) {
tags.push(['thumb', ...thumb])
}
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags,
}
return eventTemplate
}
/**
* Validates a badge definition event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid badge definition event.
*/
export function validateBadgeDefinitionEvent(event: Event): boolean {
if (event.kind !== BadgeDefinitionKind) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an EventTemplate based on the provided BadgeAward.
*
* @param {BadgeAward} badgeAward - The BadgeAward object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate {
// Mandatory tags
const tags: string[][] = [['a', a]]
for (const _p of p) {
tags.push(['p', ..._p])
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags,
}
return eventTemplate
}
/**
* Validates a badge award event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid badge award event.
*/
export function validateBadgeAwardEvent(event: Event): boolean {
if (event.kind !== BadgeAwardKind) return false
const requiredTags = ['a', 'p'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an EventTemplate based on the provided ProfileBadges.
*
* @param {ProfileBadges} profileBadges - The ProfileBadges object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate {
// Mandatory tags
const tags: string[][] = [['d', 'profile_badges']]
// Append optional tags
for (const badge of badges) {
tags.push(['a', badge.a], ['e', ...badge.e])
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags,
}
return eventTemplate
}
/**
* Validates a profile badges event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid profile badges event.
*/
export function validateProfileBadgesEvent(event: Event): boolean {
if (event.kind !== ProfileBadgesKind) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}

View File

@@ -1,5 +1,5 @@
import { Event, EventTemplate } from './core'
import { ZapGoal } from './kinds'
import { Event, EventTemplate } from './core.ts'
import { ZapGoal } from './kinds.ts'
/**
* Represents a fundraising goal in the Nostr network as defined by NIP-75.

View File

@@ -21,6 +21,7 @@ describe('generateEventTemplate', () => {
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
}
const expectedEventTemplate: EventTemplate = {
@@ -40,6 +41,8 @@ describe('generateEventTemplate', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
}
@@ -71,6 +74,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -100,6 +104,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -129,6 +134,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -158,6 +164,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -181,6 +188,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -204,6 +212,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -227,6 +236,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -259,6 +269,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -288,6 +299,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
@@ -319,6 +331,8 @@ describe('parseEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
},
sk,
@@ -340,6 +354,7 @@ describe('parseEvent', () => {
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
})
})
@@ -364,6 +379,7 @@ describe('parseEvent', () => {
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,

View File

@@ -1,4 +1,4 @@
import { Event, EventTemplate } from './core'
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
/**
@@ -75,6 +75,11 @@ export type FileMetadataObject = {
* Optional: A description for accessibility, providing context or a brief description of the file.
*/
alt?: string
/**
* Optional: fallback URLs in case url fails.
*/
fallback?: string[]
}
/**
@@ -104,6 +109,7 @@ export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTe
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
return eventTemplate
}
@@ -194,6 +200,10 @@ export function parseEvent(event: Event): FileMetadataObject {
case 'alt':
fileMetadata.alt = value
break
case 'fallback':
fileMetadata.fallback ??= []
fileMetadata.fallback.push(value)
break
}
}

View File

@@ -1,6 +1,6 @@
import { sha256 } from '@noble/hashes/sha256'
import { EventTemplate } from './core'
import { FileServerPreference } from './kinds'
import { EventTemplate } from './core.ts'
import { FileServerPreference } from './kinds.ts'
import { bytesToHex } from '@noble/hashes/utils'
/**
@@ -340,9 +340,6 @@ export async function uploadFile(
// Create FormData object
const formData = new FormData()
// Append the authorization header to HTML Form Data
formData.append('Authorization', nip98AuthorizationHeader)
// Append optional fields to FormData
optionalFormDataFields &&
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
@@ -359,7 +356,6 @@ export async function uploadFile(
method: 'POST',
headers: {
Authorization: nip98AuthorizationHeader,
'Content-Type': 'multipart/form-data',
},
body: formData,
})

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from 'bun:test'
import { Event } from './core'
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
import { finalizeEvent, generateSecretKey } from './pure'
import { Event } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('validateEvent', () => {
test('should return true for a valid classified listing event', () => {

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.3.1",
"version": "2.7.2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -85,6 +85,9 @@
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
},
"./nip07": {
"types": "./lib/types/nip07.d.ts"
},
"./nip10": {
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
@@ -170,6 +173,11 @@
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip58": {
"import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js",
"types": "./lib/types/nip58.d.ts"
},
"./nip75": {
"import": "./lib/esm/nip75.js",
"require": "./lib/cjs/nip75.js",

View File

@@ -1,9 +1,9 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { SimplePool } from './pool.ts'
import { SimplePool, useWebSocketImplementation } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
import { hexToBytes } from '@noble/hashes/utils'
useWebSocketImplementation(MockWebSocketClient)
@@ -84,6 +84,86 @@ test('same with double subs', async () => {
expect(received).toHaveLength(2)
})
test('subscribe many map', async () => {
let priv = hexToBytes('8ea002840d413ccdd5be98df5dd89d799eaa566355ede83ca0bbdbb4b145e0d3')
let pub = getPublicKey(priv)
let received: Event[] = []
let event1 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test1',
kind: 20001,
tags: [],
},
priv,
)
let event2 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 20002,
tags: [['t', 'biloba']],
},
priv,
)
let event3 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test3',
kind: 20003,
tags: [['t', 'biloba']],
},
priv,
)
const [relayA, relayB, relayC] = relayURLs
pool.subscribeManyMap(
{
[relayA]: [{ authors: [pub], kinds: [20001] }],
[relayB]: [{ authors: [pub], kinds: [20002] }],
[relayC]: [{ kinds: [20003], '#t': ['biloba'] }],
},
{
onevent(event: Event) {
received.push(event)
},
},
)
// publish the first
await Promise.all(pool.publish([relayA, relayB], event1))
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event1)
// publish the second
await pool.publish([relayB], event2)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
expect(received[1]).toEqual(event2)
// publish a events that shouldn't match our filters
await Promise.all([
...pool.publish([relayA, relayB], event3),
...pool.publish([relayA, relayB, relayC], event1),
pool.publish([relayA, relayB, relayC], event2),
])
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
// publsih the third
await pool.publish([relayC], event3)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(3)
expect(received[2]).toEqual(event3)
})
test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()

14
pool.ts
View File

@@ -1,9 +1,21 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
}
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent })
super({ verifyEvent, websocketImplementation: _WebSocket })
}
}

293
pure.test.ts Normal file
View File

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

View File

@@ -1,3 +1,5 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
@@ -8,9 +10,19 @@ export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
}
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
}
export class Relay extends AbstractRelay {
constructor(url: string) {
super(url, { verifyEvent })
super(url, { verifyEvent, websocketImplementation: _WebSocket })
}
static async connect(url: string): Promise<Relay> {
@@ -20,4 +32,6 @@ export class Relay extends AbstractRelay {
}
}
export type RelayRecord = Record<string, { read: boolean; write: boolean }>
export * from './abstract-relay.ts'

View File

@@ -35,9 +35,9 @@ export class MockRelay {
finalizeEvent(
{
kind: 1,
content: '',
content: 'autogenerated by relay',
created_at: Math.floor(Date.now() / 1000),
tags: [],
tags: [['t', 'auto']],
},
sk,
),
@@ -68,9 +68,9 @@ export class MockRelay {
const event = finalizeEvent(
{
kind,
content: '',
content: 'kind-aware autogenerated by relay',
created_at: Math.floor(Date.now() / 1000),
tags: [],
tags: [['t', 'auto']],
},
sk,
)

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"module": "esnext",
"module": "NodeNext",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"strict": true,
"moduleResolution": "node",
"moduleResolution": "NodeNext",
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,

View File

@@ -1,6 +1,6 @@
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr as NostrWasm } from 'nostr-wasm'
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core.ts'
let nw: NostrWasm
@@ -30,7 +30,7 @@ class Wasm implements Nostr {
}
}
const i = new Wasm()
const i: Wasm = new Wasm()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent