Compare commits

..

1 Commits

Author SHA1 Message Date
fiatjaf
56ac558401 fix buffer import and use rollup for transpiling the package. 2021-12-11 07:53:44 -03:00
114 changed files with 545 additions and 13382 deletions

View File

@@ -1,19 +0,0 @@
FROM node:20
RUN npm install typescript eslint prettier -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" ]

View File

@@ -1,19 +0,0 @@
{
"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

@@ -1,13 +0,0 @@
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,9 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -1,10 +1,4 @@
{
"root": true,
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"parserOptions": {
"ecmaVersion": 9,
"ecmaFeatures": {
@@ -19,11 +13,14 @@
"node": true
},
"plugins": [
"babel"
],
"globals": {
"document": false,
"navigator": false,
"window": false,
"crypto": false,
"location": false,
"URL": false,
"URLSearchParams": false,
@@ -45,7 +42,9 @@
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"handle-callback-err": [2, "^(err|error)$"],
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, { "before": true, "after": true }],
"handle-callback-err": [2, "^(err|error)$" ],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
@@ -100,6 +99,7 @@
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
@@ -116,8 +116,7 @@
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,

View File

@@ -1,24 +0,0 @@
name: test every commit
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: bun i
- run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: bun i
- run: just lint

4
.gitignore vendored
View File

@@ -2,7 +2,3 @@ node_modules
dist
yarn.lock
package-lock.json
.envrc
lib
test.html
bench.js

View File

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

24
LICENSE
View File

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

399
README.md
View File

@@ -1,375 +1,70 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
# nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
## Installation
```bash
# npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
```
If using TypeScript, this package requires TypeScript >= 5.0.
## Documentation
https://jsr.io/@nostr/tools/doc
## Usage
### Generating a private key and a public key
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import {relayPool} from 'nostr-tools'
let sk = generateSecretKey() // `sk` is a Uint8Array
let pk = getPublicKey(sk) // `pk` is a hex string
```
const pool = relayPool()
To get the secret key in hex format, use
pool.setPrivateKey('<hex>') // optional
```js
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
pool.addRelay('ws://some.relay.com', {read: true, write: true})
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
let skHex = bytesToHex(sk)
let backToBytes = hexToBytes(skHex)
```
// example callback function for a subscription
function onEvent(event, relay) => {
console.log(`got an event from ${relay.url} which is already validated.`, event)
}
### Creating, signing and verifying events
// subscribing to a single user
// author is the user's public key
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
```js
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
// or bulk follow
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
let event = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
}, sk)
// reuse a subscription channel
const mySubscription = pool.sub({cb: ..., filter: ....})
mySubscription.sub({filter: ....})
mySubscription.sub({cb: ...})
mySubscription.unsub()
let isGood = verifyEvent(event)
```
### Interacting with one or multiple relays
Doesn't matter what you do, you always should be using a `SimplePool`:
```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool()
const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
// let's query for one event that exists
const event = pool.get(
relays,
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
// get specific event
const specificChannel = pool.sub({
cb: (event, relay) => {
console.log('got specific event from relay', event, relay)
specificChannel.unsub()
},
)
if (event) {
console.log('it exists indeed on this relay:', event)
}
// let's query for more than one event that exists
const events = pool.querySync(
relays,
{
kinds: [1],
limit: 10
},
)
if (events) {
console.log('it exists indeed on this relay:', events)
}
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey()
let pk = getPublicKey(sk)
pool.subscribe(
['wss://a.com', 'wss://b.com', 'wss://c.com'],
{
kinds: [1],
authors: [pk],
},
{
onevent(event) {
console.log('got event:', event)
}
}
)
let eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world',
}
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk)
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
relay.close()
```
To use this on Node.js you first must install `ws` and call something like this:
```js
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)
```
### Parsing references (mentions) from a content based on NIP-27
```js
import * as nip27 from '@nostr/tools/nip27'
for (let block of nip27.parse(evt.content)) {
switch (block.type) {
case 'text':
console.log(block.text)
break
case 'reference': {
if ('id' in block.pointer) {
console.log("it's a nevent1 uri", block.pointer)
} else if ('identifier' in block.pointer) {
console.log("it's a naddr1 uri", block.pointer)
} else {
console.log("it's an npub1 or nprofile1 uri", block.pointer)
}
break
}
case 'url': {
console.log("it's a normal url:", block.url)
break
}
case 'image':
case 'video':
case 'audio':
console.log("it's a media url:", block.url)
case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url)
default:
break
}
}
```
### Connecting to a bunker using NIP-46
```js
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
const localSecretKey = generateSecretKey()
// parse a bunker URI
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
if (!bunkerPointer) {
throw new Error('Invalid bunker input')
}
// create the bunker instance
const pool = new SimplePool()
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
await bunker.connect()
// and use it
const pubkey = await bunker.getPublicKey()
const event = await bunker.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from bunker!'
filter: {id: '<hex>'}
})
// cleanup
await signer.close()
pool.close([])
```
// or get a specific event plus all the events that reference it in the 'e' tag
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
### Parsing thread from any note based on NIP-10
// get all events
pool.sub({cb: (event, relay) => {...}, filter: {}})
```js
import * as nip10 from '@nostr/tools/nip10'
// get recent events
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
// event is a nostr event with tags
const refs = nip10.parse(event)
// get the root event of the thread
if (refs.root) {
console.log('root event:', refs.root.id)
console.log('root event relay hints:', refs.root.relays)
console.log('root event author:', refs.root.author)
}
// get the immediate parent being replied to
if (refs.reply) {
console.log('reply to:', refs.reply.id)
console.log('reply relay hints:', refs.reply.relays)
console.log('reply author:', refs.reply.author)
}
// get any mentioned events
for (let mention of refs.mentions) {
console.log('mentioned event:', mention.id)
console.log('mention relay hints:', mention.relays)
console.log('mention author:', mention.author)
}
// get any quoted events
for (let quote of refs.quotes) {
console.log('quoted event:', quote.id)
console.log('quote relay hints:', quote.relays)
}
// get any referenced profiles
for (let profile of refs.profiles) {
console.log('referenced profile:', profile.pubkey)
console.log('profile relay hints:', profile.relays)
}
```
### Querying profile data from a NIP-05 address
```js
import { queryProfile } from 'nostr-tools/nip05'
let profile = await queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js
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;
// publishing events(inside an async function):
const ev = await pool.publish(eventObject, (status, url) => {
if (status === 0) {
console.log(`publish request sent to ${url}`)
}
}
if (status === 1) {
console.log(`event published by ${url}`, ev)
}
})
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
// subscribing to a new relay
pool.addRelay('<url>')
// will automatically subscribe to the all the events called with .sub above
```
### Encoding and decoding NIP-19 codes
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
let sk = generateSecretKey()
let nsec = nip19.nsecEncode(sk)
let { type, data } = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generateSecretKey())
let npub = nip19.npubEncode(pk)
let { type, data } = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
let { type, data } = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Using it with `nostr-wasm`
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
```js
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
initNostrWasm().then(setNostrWasm)
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
// see https://www.npmjs.com/package/nostr-wasm for options
```
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
```js
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
import { initNostrWasm } from 'nostr-wasm'
initNostrWasm().then(setNostrWasm)
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
const pool = new AbstractSimplePool({ verifyEvent })
```
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
```
benchmark time (avg) (min … max) p75 p99 p995
------------------------------------------------- -----------------------------
• relay read message and verify event (many events)
------------------------------------------------- -----------------------------
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
summary for relay read message and verify event
wasm
86.77x slower than trusted
6.86x faster than pure js
```
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generateSecretKey('...') // and so on
</script>
```
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
## Contributing to this repository
Use NIP-34 to send your patches to:
```
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
```
For other utils please read the source (for now).

View File

@@ -1,303 +0,0 @@
/* global WebSocket */
import {
AbstractRelay as AbstractRelay,
SubscriptionParams,
Subscription,
type AbstractRelayConstructorOptions,
} from './abstract-relay.ts'
import { normalizeURL } from './utils.ts'
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: (reason?: string) => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number
onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
// Deprecated: use onauth instead
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string
label?: string
}
export class AbstractSimplePool {
protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent']
public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket
constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
url = normalizeURL(url)
let relay = this.relays.get(url)
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket,
})
relay.onclose = () => {
this.relays.delete(url)
}
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay)
}
await relay.connect()
return relay
}
close(relays: string[]) {
relays.map(normalizeURL).forEach(url => {
this.relays.get(url)?.close()
this.relays.delete(url)
})
}
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.map(url => ({ url, filter })),
params,
)
}
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params,
)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
if (!set) {
set = new Set()
this.seenOn.set(id, set)
}
set.add(relay)
}
}
const _knownIds = new Set<string>()
const subs: Subscription[] = []
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === requests.length) {
params.oneose?.()
handleEose = () => {}
}
}
// batch all closes into a single
const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === requests.length) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
}
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true
}
const have = _knownIds.has(id)
_knownIds.add(id)
return have
}
// open a subscription in all given relays
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
})
} catch (err) {
handleClose(i, (err as any)?.message || String(err))
return
}
let subscription = relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
if (reason.startsWith('auth-required: ') && params.onauth) {
relay
.auth(params.onauth)
.then(() => {
relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
subs.push(subscription)
}),
)
return {
async close(reason?: string) {
await allOpened
subs.forEach(sub => {
sub.close(reason)
})
},
}
}
subscribeEose(
relays: string[],
filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribe(relays, filter, {
...params,
oneose() {
subcloser.close('closed automatically on eose')
},
})
return subcloser
}
subscribeManyEose(
relays: string[],
filters: Filter[],
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filters, {
...params,
oneose() {
subcloser.close('closed automatically on eose')
},
})
return subcloser
}
async querySync(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> {
return new Promise(async resolve => {
const events: Event[] = []
this.subscribeEose(relays, filter, {
...params,
onevent(event: Event) {
events.push(event)
},
onclose(_: string[]) {
resolve(events)
},
})
})
}
async get(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> {
filter.limit = 1
const events = await this.querySync(relays, filter, params)
events.sort((a, b) => b.created_at - a.created_at)
return events[0] || null
}
publish(
relays: string[],
event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
// duplicate
return Promise.reject('duplicate url')
}
let r = await this.ensureRelay(url)
return r
.publish(event)
.catch(async err => {
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
await r.auth(options.onauth)
return r.publish(event) // retry
}
throw err
})
.then(reason => {
if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
})
}
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

@@ -1,465 +0,0 @@
/* global WebSocket */
import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
type RelayWebSocket = WebSocket & {
ping?(): void
on?(event: 'pong', listener: () => void): any
}
export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket
}
export class SendingOnClosedConnection extends Error {
constructor(message: string, relay: string) {
super(`Tried to send message '${message} on a closed connection to ${relay}.`)
this.name = 'SendingOnClosedConnection'
}
}
export class AbstractRelay {
public readonly url: string
private _connected: boolean = false
public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: RelayWebSocket | undefined
private incomingMessageQueue = new Queue<string>()
private queueRunning = false
private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0
private 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
// this.pingHeartBeat = opts.pingHeartBeat
// this.pingFrequency = opts.pingFrequency
// this.pingTimeout = opts.pingTimeout
}
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts)
await relay.connect()
return relay
}
private closeAllSubscriptions(reason: string) {
for (let [_, sub] of this.openSubs) {
sub.close(reason)
}
this.openSubs.clear()
for (let [_, ep] of this.openEventPublishes) {
ep.reject(new Error(reason))
}
this.openEventPublishes.clear()
for (let [_, cr] of this.openCountRequests) {
cr.reject(new Error(reason))
}
this.openCountRequests.clear()
}
public get connected(): boolean {
return this._connected
}
public async connect(): Promise<void> {
if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined
this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out')
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection timed out')
}, this.connectionTimeout)
try {
this.ws = new this._WebSocket(this.url)
} catch (err) {
clearTimeout(this.connectionTimeoutHandle)
reject(err)
return
}
this.ws.onopen = () => {
clearTimeout(this.connectionTimeoutHandle)
this._connected = true
if (this.ws && this.ws.ping) {
// && this.pingHeartBeat
this.pingpong()
}
resolve()
}
this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
}
}
this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
}
}
this.ws.onmessage = this._onmessage.bind(this)
})
return this.connectionPromise
}
private async receivePong() {
return new Promise((res, err) => {
;(this.ws && this.ws.on && this.ws.on('pong', () => res(true))) || err("ws can't listen for pong")
})
}
// nodejs requires this magic here to ensure connections are closed when internet goes off and stuff
// in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491
private async pingpong() {
// if the websocket is connected
if (this.ws?.readyState == 1) {
// send a ping
this.ws && this.ws.ping && this.ws.ping()
// wait for either a pong or a timeout
const result = await Promise.any([
this.receivePong(),
new Promise(res => setTimeout(() => res(false), 10000)), // TODO: opts.pingTimeout
])
console.error('pingpong result', result)
if (result) {
// schedule another pingpong
setTimeout(() => this.pingpong(), 10000) // TODO: opts.pingFrequency
} else {
// pingpong closing socket
this.ws && this.ws.close()
}
}
}
private async runQueue() {
this.queueRunning = true
while (true) {
if (false === this.handleNext()) {
break
}
await yieldThread()
}
this.queueRunning = false
}
private handleNext(): undefined | false {
const json = this.incomingMessageQueue.dequeue()
if (!json) {
return false
}
const subid = getSubscriptionId(json)
if (subid) {
const so = this.openSubs.get(subid as string)
if (!so) {
// this is an EVENT message, but for a subscription we don't have, so just stop here
return
}
// this will be called only when this message is a EVENT message for a subscription we have
// we do this before parsing the JSON to not have to do that for duplicate events
// since JSON parsing is slow
const id = getHex64(json, 'id')
const alreadyHave = so.alreadyHaveEvent?.(id)
// notify any interested client that the relay has this event
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
so.receivedEvent?.(this, id)
if (alreadyHave) {
// if we had already seen this event we can just stop here
return
}
}
try {
let data = JSON.parse(json)
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
const so = this.openSubs.get(data[1] as string) as Subscription
const event = data[2] as NostrEvent
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
so.onevent(event)
}
return
}
case 'COUNT': {
const id: string = data[1]
const payload = data[2] as { count: number }
const cr = this.openCountRequests.get(id) as CountResolver
if (cr) {
cr.resolve(payload.count)
this.openCountRequests.delete(id)
}
return
}
case 'EOSE': {
const so = this.openSubs.get(data[1] as string)
if (!so) return
so.receivedEose()
return
}
case 'OK': {
const id: string = data[1]
const ok: boolean = data[2]
const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ep) {
clearTimeout(ep.timeout)
if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
}
return
}
case 'CLOSED': {
const id: string = data[1]
const so = this.openSubs.get(id)
if (!so) return
so.closed = true
so.close(data[2] as string)
return
}
case 'NOTICE':
this.onnotice(data[1] as string)
return
case 'AUTH': {
this.challenge = data[1] as string
return
}
}
} catch (err) {
return
}
}
public async send(message: string) {
if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => {
this.ws?.send(message)
})
}
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
const challenge = this.challenge
if (!challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
this.authPromise = new Promise<string>(async (resolve, reject) => {
try {
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
let timeout = setTimeout(() => {
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
})
return this.authPromise
}
public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
})
this.send('["EVENT",' + JSON.stringify(event) + ']')
return ret
}
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
this.serial++
const id = params?.id || 'count:' + this.serial
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret
}
public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const subscription = this.prepareSubscription(filters, params)
subscription.fire()
return subscription
}
public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription)
return subscription
}
public close() {
this.closeAllSubscriptions('relay connection closed by us')
this._connected = false
this.ws?.close()
this.onclose?.()
}
// this is the function assigned to this.ws.onmessage
// it's exposed for testing and debugging purposes
public _onmessage(ev: MessageEvent<any>) {
this.incomingMessageQueue.enqueue(ev.data as string)
if (!this.queueRunning) {
this.runQueue()
}
}
}
export class Subscription {
public readonly relay: AbstractRelay
public readonly id: string
public closed: boolean = false
public eosed: boolean = false
public filters: Filter[]
public alreadyHaveEvent: ((id: string) => boolean) | undefined
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
public onevent: (evt: Event) => void
public oneose: (() => void) | undefined
public onclose: ((reason: string) => void) | undefined
public eoseTimeout: number
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
this.relay = relay
this.filters = filters
this.id = id
this.alreadyHaveEvent = params.alreadyHaveEvent
this.receivedEvent = params.receivedEvent
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
this.oneose = params.oneose
this.onclose = params.onclose
this.onevent =
params.onevent ||
(event => {
console.warn(
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
event,
)
})
}
public fire() {
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
// only now we start counting the eoseTimeout
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
}
public receivedEose() {
if (this.eosed) return
clearTimeout(this.eoseTimeoutHandle)
this.eosed = true
this.oneose?.()
}
public close(reason: string = 'closed by caller') {
if (!this.closed && 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
try {
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
} catch (err) {
if (err instanceof SendingOnClosedConnection) {
/* doesn't matter, it's ok */
} else {
throw err
}
}
this.closed = true
}
this.relay.openSubs.delete(this.id)
this.onclose?.(reason)
}
}
export type SubscriptionParams = {
onevent?: (evt: Event) => void
oneose?: () => void
onclose?: (reason: string) => void
alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: AbstractRelay, id: string) => void
eoseTimeout?: number
}
export type CountResolver = {
resolve: (count: number) => void
reject: (err: Error) => void
}
export type EventPublishResolver = {
resolve: (reason: string) => void
reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
}

View File

@@ -1,61 +0,0 @@
import { run, bench, group, baseline } from 'mitata'
import { initNostrWasm } from 'nostr-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'
// benchmarking relay reads with verifyEvent
const EVENTS = 200
let messages: string[] = []
let baseContent = ''
for (let i = 0; i < EVENTS; i++) {
baseContent += 'a'
}
const secretKey = generateSecretKey()
for (let i = 0; i < EVENTS; i++) {
const tags = []
for (let t = 0; t < i; t++) {
tags.push(['t', 'nada'])
}
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
const signed = finalizeEvent(event, secretKey)
messages.push(JSON.stringify(['EVENT', '_', signed]))
}
setNostrWasm(await initNostrWasm())
const pureRelay = new PureRelay('wss://pure.com/')
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
const runWith = (relay: AbstractRelay) => async () => {
return new Promise<void>(resolve => {
let received = 0
let sub = relay.prepareSubscription([{}], {
id: '_',
onevent(_: NostrEvent) {
received++
if (received === messages.length - 1) {
resolve()
sub.closed = true
sub.close()
}
},
})
for (let e = 0; e < messages.length; e++) {
relay._onmessage({ data: messages[e] } as any)
}
})
}
group(`relay read ${EVENTS} messages and verify its events`, () => {
baseline('wasm', runWith(wasmRelay))
bench('pure js', runWith(pureRelay))
bench('trusted', runWith(trustedRelay))
})
// actually running the thing
await run()

View File

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

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,18 +0,0 @@
import { test, expect } from 'bun:test'
import { sortEvents } from './core.ts'
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' },
])
})

65
core.ts
View File

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

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

View File

@@ -1,41 +0,0 @@
export function getHex64(json: string, field: string): string {
let len = field.length + 3
let idx = json.indexOf(`"${field}":`) + len
let s = json.slice(idx).indexOf(`"`) + idx + 1
return json.slice(s, s + 64)
}
export function getInt(json: string, field: string): number {
let len = field.length
let idx = json.indexOf(`"${field}":`) + len + 3
let sliced = json.slice(idx)
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
return parseInt(sliced.slice(0, end), 10)
}
export function getSubscriptionId(json: string): string | null {
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
if (idx === -1) return null
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
if (pstart === -1) return null
let start = idx + 7 + 1 + pstart
let pend = json.slice(start + 1, 80).indexOf(`"`)
if (pend === -1) return null
let end = start + 1 + pend
return json.slice(start + 1, end)
}
export function matchEventId(json: string, id: string): boolean {
return id === getHex64(json, 'id')
}
export function matchEventPubkey(json: string, pubkey: string): boolean {
return pubkey === getHex64(json, 'pubkey')
}
export function matchEventKind(json: string, kind: number): boolean {
return kind === getInt(json, 'kind')
}

View File

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

105
filter.ts
View File

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

View File

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

View File

@@ -1,31 +0,0 @@
export * from './pure.ts'
export { Relay } from './relay.ts'
export * from './filter.ts'
export { SimplePool } from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip30 from './nip30.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts'
export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

View File

@@ -1,50 +0,0 @@
{
"name": "@nostr/tools",
"version": "2.15.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",
"./nip17": "./nip17.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",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts",
"./nip94": "./nip94.ts",
"./nip98": "./nip98.ts",
"./nip99": "./nip99.ts",
"./nipb7": "./nipb7.ts",
"./fakejson": "./fakejson.ts",
"./utils": "./utils.ts",
"./signer": "./signer.ts"
}
}

View File

@@ -1,36 +0,0 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
build:
rm -rf lib
bun run build.js
tsc
test:
bun test --timeout 20000
test-only file:
bun test {{file}}
publish: build
# publish to jsr first because it is more strict and will catch some errors
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
jsr publish --allow-dirty
git checkout -- package.json
# then to npm
npm publish
format:
eslint --ext .ts --fix *.ts
prettier --write *.ts
lint:
eslint --ext .ts *.ts
prettier --check *.ts
benchmark:
bun build --target=node --outfile=bench.js benchmarks.ts
timeout 60s deno run --allow-read bench.js || true
timeout 60s node bench.js || true
timeout 60s bun run benchmarks.ts || true
rm bench.js

View File

@@ -1,41 +0,0 @@
import { expect, test } from 'bun:test'
import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
test('kind classification', () => {
expect(classifyKind(1)).toBe('regular')
expect(classifyKind(5)).toBe('regular')
expect(classifyKind(6)).toBe('regular')
expect(classifyKind(7)).toBe('regular')
expect(classifyKind(1000)).toBe('regular')
expect(classifyKind(9999)).toBe('regular')
expect(classifyKind(0)).toBe('replaceable')
expect(classifyKind(3)).toBe('replaceable')
expect(classifyKind(10000)).toBe('replaceable')
expect(classifyKind(19999)).toBe('replaceable')
expect(classifyKind(20000)).toBe('ephemeral')
expect(classifyKind(29999)).toBe('ephemeral')
expect(classifyKind(30000)).toBe('parameterized')
expect(classifyKind(39999)).toBe('parameterized')
expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown')
})
test('kind type guard', () => {
const privateKey = generateSecretKey()
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
expect(isKind(repostedEvent, Repost)).toBeFalse()
})

193
kinds.ts
View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { cbc } from '@noble/ciphers/aes'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text)
let ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let iv = base64.decode(ivb64)
let ciphertext = base64.decode(ctb64)
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
return utf8Decoder.decode(plaintext)
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

View File

@@ -1,46 +0,0 @@
import { test, expect } from 'bun:test'
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
test('validate NIP05_REGEX', () => {
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
expect(isNip05('bob@bob.com.br')).toBeTrue()
expect(isNip05('b&b@bob.com.br')).toBeFalse()
})
test('fetch nip05 profiles', async () => {
const fetchStub = async (url: string) => ({
status: 200,
async json() {
return {
'https://compile-error.net/.well-known/nostr.json?name=_': {
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
},
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
relays: {
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
'wss://pyramid.fiatjaf.com',
'wss://nos.lol',
],
},
},
}[url]
},
})
useFetchImplementation(fetchStub)
let p2 = await queryProfile('compile-error.net')
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
})

View File

@@ -1,66 +0,0 @@
import { ProfilePointer } from './nip19.ts'
export type Nip05 = `${string}@${string}`
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
* - 0: full match
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _fetch: any
try {
_fetch = fetch
} catch (_) {
null
}
export function useFetchImplementation(fetchImplementation: unknown) {
_fetch = fetchImplementation
}
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
return json.names
} catch (_) {
return {}
}
}
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
const pubkey = json.names[name]
return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
const res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false
}

View File

@@ -1,77 +0,0 @@
import { test, expect } from 'bun:test'
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey,
} from './nip06.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
})
test('generate private key for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
})
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual(hexToBytes('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(hexToBytes('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).toEqual(hexToBytes('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

@@ -1,75 +0,0 @@
import { bytesToHex } from '@noble/hashes/utils'
import { wordlist } from '@scure/bip39/wordlists/english'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
const DERIVATION_PATH = `m/44'/1237'`
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return privateKey
}
export function accountFromSeedWords(
mnemonic: string,
passphrase?: string,
accountIndex = 0,
): {
privateKey: Uint8Array
publicKey: string
} {
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
const publicKey = bytesToHex(seed.publicKey!.slice(1))
const privateKey = seed.privateKey
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?: Uint8Array
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 = child.privateKey!
if (!privateKey) throw new Error('could not derive private key')
return { privateKey, publicKey }
}
return { publicKey }
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}
export function validateWords(words: string): boolean {
return validateMnemonic(words, wordlist)
}

View File

@@ -1,14 +0,0 @@
import { EventTemplate, VerifiedEvent } from './core.ts'
export interface WindowNostr {
getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<VerifiedEvent>
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,264 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { parse } from './nip10.ts'
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
{
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
{
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: [],
},
{
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: [],
},
{
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: [],
},
],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern, inverted, author hint', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
'wss://banana.com',
'root',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: ['wss://banana.com', 'wss://goiaba.com'],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
})
})
test('1 event, relay hint from author', () => {
let event = {
tags: [
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
'',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: ['wss://banana.com'],
},
],
reply: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
},
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
},
})
})
test('many p 1 reply', () => {
let event = {
tags: [
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
relays: ['wss://relay.mostr.pub'],
},
{
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
relays: ['wss://relay.mostr.pub'],
},
],
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
})
})
})

147
nip10.ts
View File

@@ -1,147 +0,0 @@
import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts'
export function parse(event: Pick<Event, 'tags'>): {
/**
* Pointer to the root of the thread.
*/
root: EventPointer | undefined
/**
* Pointer to a "parent" event that parsed event replies to (responded to).
*/
reply: EventPointer | undefined
/**
* Pointers to events that may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
} {
const result: ReturnType<typeof parse> = {
reply: undefined,
root: undefined,
mentions: [],
profiles: [],
quotes: [],
}
let maybeParent: EventPointer | undefined
let maybeRoot: EventPointer | undefined
for (let i = event.tags.length - 1; i >= 0; i--) {
const tag = event.tags[i]
if (tag[0] === 'e' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
string,
string,
undefined | string,
undefined | string,
undefined | string,
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
author: eTagAuthor,
}
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (!maybeParent) {
maybeParent = eventPointer
} else {
maybeRoot = eventPointer
}
result.mentions.push(eventPointer)
continue
}
if (tag[0] === 'q' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
result.quotes.push({
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
})
}
if (tag[0] === 'p' && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [],
})
continue
}
}
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
}
if (!result.reply) {
result.reply = maybeParent || result.root
}
// remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => {
if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) {
result.mentions.splice(idx, 1)
}
if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
result.mentions.forEach(ref => {
if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
return result
}

View File

@@ -1,18 +0,0 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
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://nos.lol')
expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toContain(1)
expect(info.supported_nips).toContain(11)
expect(info.supported_nips).toContain(70)
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
})
})

294
nip11.ts
View File

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

View File

@@ -1,30 +0,0 @@
import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
;[
['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
})
test('mines POW for an event', async () => {
const difficulty = 10
const event = minePow(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 0,
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
difficulty,
)
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
})

View File

@@ -1,60 +0,0 @@
import { bytesToHex } from '@noble/hashes/utils'
import { type UnsignedEvent, type Event } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number {
let count = 0
for (let i = 0; i < 64; i += 8) {
const nibble = parseInt(hex.substring(i, i + 8), 16)
if (nibble === 0) {
count += 32
} else {
count += Math.clz32(nibble)
break
}
}
return count
}
/**
* Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*/
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0
const event = unsigned as Omit<Event, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)
while (true) {
const now = Math.floor(new Date().getTime() / 1000)
if (now !== event.created_at) {
count = 0
event.created_at = now
}
tag[1] = (++count).toString()
event.id = fastEventHash(event)
if (getPow(event.id) >= difficulty) {
break
}
}
return event
}
export function fastEventHash(evt: UnsignedEvent): string {
return bytesToHex(
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
)
}

View File

@@ -1,97 +0,0 @@
import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
const recipients = [
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
]
const message = 'Hello, this is a direct message!'
const conversationTitle = 'Private Group Conversation' // Optional
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
[Symbol('verified')]: true,
}
expect(wrappedEvent.kind).toEqual(expected.kind)
expect(wrappedEvent.tags).toEqual(expected.tags)
})
test('wrapManyEvents', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729560014,
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 14,
content: 'Hello, this is a direct message!',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
['e', 'previousEventId123', '', 'reply'],
['subject', 'Private Group Conversation'],
],
}
const result = unwrapEvent(wrappedEvent, sk1)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})

View File

@@ -1,77 +0,0 @@
import { PrivateDirectMessage } from './kinds.ts'
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
import * as nip59 from './nip59.ts'
type Recipient = {
publicKey: string
relayUrl?: string
}
type ReplyTo = {
eventId: string
relayUrl?: string
}
function createEvent(
recipients: Recipient | Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): EventTemplate {
const baseEvent: EventTemplate = {
created_at: Math.ceil(Date.now() / 1000),
kind: PrivateDirectMessage,
tags: [],
content: message,
}
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
recipientsArray.forEach(({ publicKey, relayUrl }) => {
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
})
if (replyTo) {
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
}
if (conversationTitle) {
baseEvent.tags.push(['subject', conversationTitle])
}
return baseEvent
}
export function wrapEvent(
senderPrivateKey: Uint8Array,
recipient: Recipient,
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent {
const event = createEvent(recipient, message, conversationTitle, replyTo)
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
}
export function wrapManyEvents(
senderPrivateKey: Uint8Array,
recipients: Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent[] {
if (!recipients || recipients.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
// wrap the event for the sender and then for each recipient
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
)
}
export const unwrapEvent = nip59.unwrapEvent
export const unwrapManyEvents = nip59.unwrapManyEvents

View File

@@ -1,170 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
test('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
])
expect(event.content).toEqual('')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toBeUndefined()
})
})
describe('GenericRepost', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const eventTemplate: EventTemplate = {
content: '',
created_at: 1617932114,
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 repostedEvent = finalizeEvent(eventTemplate, privateKey)
test('should create a generic reposted event', () => {
const template = { created_at: 1617932115 }
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(GenericRepost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
['k', '30009'],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
})
describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toBeUndefined()
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})
describe('finishRepostEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
test('should create an event with empty content if the reposted event is protected', () => {
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [['-']],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.content).toBe('')
})
})

106
nip18.ts
View File

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

View File

@@ -1,283 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
decode,
naddrEncode,
neventEncode,
NostrTypeGuard,
nprofileEncode,
npubEncode,
nsecEncode
} from './nip19.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
test('encode and decode nsec', () => {
let sk = generateSecretKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let { type, data } = decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generateSecretKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let { type, data } = decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nprofileEncode({ pubkey: pk, relays })
expect(nprofile).toMatch(/nprofile1\w+/)
let { type, data } = decode(nprofile)
expect(type).toEqual('nprofile')
const pointer = data
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
decode(
nprofileEncode({
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: [],
}),
).data,
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana',
})
expect(naddr).toMatch(/naddr1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('encode and decode nevent', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(30023)
})
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(0)
})
test('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
test('decode naddr from habla.news', () => {
let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
)
expect(type).toEqual('naddr')
const pointer = data
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let { type, data } = decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
)
expect(type).toEqual('naddr')
const pointer = data
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
expect(pointer.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
describe('NostrTypeGuard', () => {
test('isNProfile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeTrue()
})
test('isNProfile invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
expect(is).toBeFalse()
})
test('isNProfile with invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNEvent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
)
expect(is).toBeTrue()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
)
expect(is).toBeFalse()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNAddr', () => {
const is = NostrTypeGuard.isNAddr(
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
)
expect(is).toBeTrue()
})
test('isNAddr with invalid nadress', () => {
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNSec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeTrue()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
expect(is).toBeFalse()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNPub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeTrue()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNote', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
expect(is).toBeTrue()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNcryptsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeTrue()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeFalse()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
})

270
nip19.ts
View File

@@ -1,270 +0,0 @@
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export type NProfile = `nprofile1${string}`
export type NEvent = `nevent1${string}`
export type NAddr = `naddr1${string}`
export type NSec = `nsec1${string}`
export type NPub = `npub1${string}`
export type Note = `note1${string}`
export type Ncryptsec = `ncryptsec1${string}`
export const NostrTypeGuard = {
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
}
export const Bech32MaxSize = 5000
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
function integerToUint8Array(number: number) {
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
const uint8Array = new Uint8Array(4)
// Use bitwise operations to extract the bytes.
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
uint8Array[1] = (number >> 16) & 0xff
uint8Array[2] = (number >> 8) & 0xff
uint8Array[3] = number & 0xff // Least significant byte (LSB)
return uint8Array
}
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
kind?: number
}
export type AddressPointer = {
identifier: string
pubkey: string
kind: number
relays?: string[]
}
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
try {
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
return decode(nip19code)
} catch (_err) {
return { type: 'invalid', data: null }
}
}
export type DecodedNevent = {
type: 'nevent'
data: EventPointer
}
export type DecodedNprofile = {
type: 'nprofile'
data: ProfilePointer
}
export type DecodedNaddr = {
type: 'naddr'
data: AddressPointer
}
export type DecodedNsec = {
type: 'nsec'
data: Uint8Array
}
export type DecodedNpub = {
type: 'npub'
data: string
}
export type DecodedNote = {
type: 'note'
data: string
}
export type DecodedResult = DecodedNevent | DecodedNprofile | DecodedNaddr | DecodedNpub | DecodedNsec | DecodedNote
export function decode(nip19: NEvent): DecodedNevent
export function decode(nip19: NProfile): DecodedNprofile
export function decode(nip19: NAddr): DecodedNaddr
export function decode(nip19: NSec): DecodedNsec
export function decode(nip19: NPub): DecodedNpub
export function decode(nip19: Note): DecodedNote
export function decode(code: string): DecodedResult
export function decode(code: string): DecodedResult {
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) {
case 'nprofile': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
case 'nevent': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
},
}
}
case 'naddr': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
case 'nsec':
return { type: prefix, data }
case 'npub':
case 'note':
return { type: prefix, data: bytesToHex(data) }
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = { [t: number]: Uint8Array[] }
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(key: Uint8Array): NSec {
return encodeBytes('nsec', key)
}
export function npubEncode(hex: string): NPub {
return encodeBytes('npub', hexToBytes(hex))
}
export function noteEncode(hex: string): Note {
return encodeBytes('note', hexToBytes(hex))
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
}
export function nprofileEncode(profile: ProfilePointer): NProfile {
let data = encodeTLV({
0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
})
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): NEvent {
let kindArray
if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind)
}
let data = encodeTLV({
0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [],
3: kindArray ? [new Uint8Array(kindArray)] : [],
})
return encodeBech32('nevent', data)
}
export function naddrEncode(addr: AddressPointer): NAddr {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)],
})
return encodeBech32('naddr', data)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv)
.reverse()
.forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return concatBytes(...entries)
}

View File

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

View File

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

View File

@@ -1,78 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Reaction, ShortTextNote } from './kinds.ts'
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const reactedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
test('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
])
expect(event.content).toEqual('+')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
test('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
])
expect(event.content).toEqual('👍')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
})

View File

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

View File

@@ -1,77 +0,0 @@
import { test, expect } from 'bun:test'
import { parse } from './nip27.ts'
test('first: parse simple content with 1 url and 1 nostr uri', () => {
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
{ type: 'text', text: ' check out my profile:' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '; and this cool image ' },
{ type: 'image', url: 'https://images.com/image.jpg' },
])
})
test('second: parse content with 3 urls of different types', () => {
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
http://music.com/song.mp3
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/' },
{ type: 'text', text: "; this was a relay and now here's a video -> " },
{ type: 'video', url: 'https://videos.com/video.mp4' },
{ type: 'text', text: '! and some music:\n' },
{ type: 'audio', url: 'http://music.com/song.mp3' },
{ type: 'text', text: '\nand a regular link: ' },
{ type: 'url', url: 'https://regular.com/page?ok=true' },
{
type: 'text',
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
},
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
])
})
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
with a video https://example.com/vid.webm and finally https://example.com/docs`
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: 'Look at these profiles ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: ' ' },
{
type: 'reference',
pointer: {
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
relays: ['wss://qwieu.com'],
},
},
{ type: 'text', text: ' check this event ' },
{
type: 'reference',
pointer: {
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
relays: ['wss://zjbdksa.aswjdkn'],
author: undefined,
kind: undefined,
},
},
{ type: 'text', text: "\n here's an image " },
{ type: 'image', url: 'https://example.com/pic.png' },
{ type: 'text', text: ' and another profile ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '\n with a video ' },
{ type: 'video', url: 'https://example.com/vid.webm' },
{ type: 'text', text: ' and finally ' },
{ type: 'url', url: 'https://example.com/docs' },
])
})

169
nip27.ts
View File

@@ -1,169 +0,0 @@
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
export type Block =
| {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
const noCharacter = /\W/m
const noURLCharacter = /\W |\W$|$|,| /m
export function* parse(content: string): Iterable<Block> {
const max = content.length
let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
if (content.substring(u - 5, u) === 'nostr') {
const m = content.substring(u + 60).match(noCharacter)
const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
switch (type) {
case 'npub':
pointer = { pubkey: data } as ProfilePointer
break
case 'nsec':
case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue
}
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 5 : 4
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 3 : 2
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else {
// ignore this, it is nothing
index = u + 1
continue
}
}
if (prevIndex !== max) {
yield { type: 'text', text: content.substring(prevIndex) }
}
}

View File

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

158
nip28.ts
View File

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

614
nip29.ts
View File

@@ -1,614 +0,0 @@
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 { decode, NostrTypeGuard } from './nip19.ts'
import { normalizeURL } from './utils.ts'
/**
* Represents a NIP29 group.
*/
export type Group = {
relay: string
metadata: GroupMetadata
admins?: GroupAdmin[]
members?: GroupMember[]
reference: GroupReference
}
/**
* 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 {
/** @deprecated use PutUser instead */
AddUser = 'add-user',
EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event',
RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status',
PutUser = 'put-user',
CreateGroup = 'create-group',
DeleteGroup = 'delete-group',
CreateInvite = 'create-invite',
}
/**
* 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 (NostrTypeGuard.isNAddr(code)) {
try {
let { data } = decode(code)
let { relays, identifier } = data
if (!relays || relays.length === 0) return null
let host = relays![0]
if (host.startsWith('wss://')) {
host = host.slice(6)
}
return { host, id: identifier }
} catch (err) {
return null
}
} else if (code.split("'").length === 2) {
let spl = code.split("'")
return { host: spl[0], id: spl[1] }
}
return null
}
/**
* 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 {
const { host, id } = gr
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
return `${normalizedHost}'${id}`
}
/**
* 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
const normalizedRelayURL = normalizeURL(relayURL)
fetchRelayInformation(normalizedRelayURL)
.then(async info => {
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
import { expect, test } from 'bun:test'
import { makeAuthEvent } from './nip42.ts'
import { Relay } from './relay.ts'
import { MockRelay } from './test-helpers.ts'
test('auth flow', async () => {
const mockRelay = new MockRelay()
const relay = await Relay.connect(mockRelay.url)
const auth = makeAuthEvent(relay.url, 'chachacha')
expect(auth.tags).toHaveLength(2)
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
expect(auth.kind).toEqual(22242)
})

View File

@@ -1,17 +0,0 @@
import { EventTemplate } from './core.ts'
import { ClientAuth } from './kinds.ts'
/**
* creates an EventTemplate for an AUTH event to be signed.
*/
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
return {
kind: ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL],
['challenge', challenge],
],
content: '',
}
}

View File

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

127
nip44.ts
View File

@@ -1,127 +0,0 @@
import { chacha20 } from '@noble/ciphers/chacha'
import { equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
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')
}
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),
}
}
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)
}
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)
}
const dlen = data.length
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const vers = data[0]
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
}
}
export function 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,605 +0,0 @@
{
"v2": {
"valid": {
"get_conversation_key": [
{
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
},
{
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
},
{
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
},
{
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
},
{
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
},
{
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
},
{
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
},
{
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
},
{
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
},
{
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
},
{
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
},
{
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
},
{
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
},
{
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
},
{
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
},
{
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
},
{
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
},
{
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
},
{
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
},
{
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
},
{
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
},
{
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
},
{
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
},
{
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
},
{
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
},
{
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
},
{
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
},
{
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
},
{
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
},
{
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
},
{
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
},
{
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
"note": "sec1 = n-2, pub2: random, 0x02"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
"note": "sec1 = 2, pub2: rand"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
"note": "sec1 == pub2"
}
],
"get_message_keys": {
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
"keys": [
{
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
"chacha_nonce": "c4ad129bb01180c0933a160c",
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
},
{
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
"chacha_nonce": "22925e920cee4a50a478be90",
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
},
{
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
"chacha_nonce": "d3594987af769a52904656ac",
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
},
{
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
},
{
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
"chacha_nonce": "400224ba67fc2f1b76736916",
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
},
{
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
},
{
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
"chacha_nonce": "72f69a5a5f795465cee59da8",
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
},
{
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
"chacha_nonce": "6e69be92d61c04a276021565",
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
},
{
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
"chacha_nonce": "3dda53569cfcb7fac1805c35",
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
},
{
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
"chacha_nonce": "65064239186e50304cc0f156",
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
},
{
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
},
{
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
"chacha_nonce": "cbb2530ea653766e5a37a83a",
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
},
{
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
},
{
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
"chacha_nonce": "67803605a7e5010d0f63f8c8",
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
},
{
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
"chacha_nonce": "4e62a0073087ed808be62469",
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
},
{
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
},
{
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
"chacha_nonce": "329bb3024461e84b2e1c489b",
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
},
{
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
"chacha_nonce": "653d759042b85194d4d8c0a7",
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
},
{
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
"chacha_nonce": "b822e2c959df32b3cb772a7c",
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
},
{
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
},
{
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
},
{
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
},
{
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
},
{
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
},
{
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
"chacha_nonce": "263830a065af33d9c6c5aa1f",
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
},
{
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
},
{
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
"chacha_nonce": "ccdaad5b3b7645be430992eb",
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
},
{
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
"chacha_nonce": "ed02dece5fc3a186f123420b",
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
},
{
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
},
{
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
"chacha_nonce": "26b450612ca5e905b937e147",
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
},
{
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
"chacha_nonce": "4a296a1fb0048e5020d3b129",
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
},
{
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
"chacha_nonce": "a8188daff807a1182200b39d",
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
}
]
},
"calc_padded_len": [
[16, 32],
[32, 32],
[33, 64],
[37, 64],
[45, 64],
[49, 64],
[64, 64],
[65, 96],
[100, 128],
[111, 128],
[200, 224],
[250, 256],
[320, 320],
[383, 384],
[384, 384],
[400, 448],
[500, 512],
[512, 512],
[515, 640],
[700, 768],
[800, 896],
[900, 1024],
[1020, 1024],
[65536, 65536]
],
"encrypt_decrypt": [
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
"plaintext": "🍕🫃",
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
},
{
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"plaintext": "表ポあA鷗Œé逍Üߪąñ丂㐀𠀀",
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
},
{
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
"plaintext": "ability🤝的 ȺȾ",
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
},
{
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
"plaintext": "pepper👀їжак",
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
},
{
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"plaintext": "( ͡° ͜ʖ ͡°)",
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
"plaintext": "الكل في المجمو عة (5)",
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"plaintext": "🙈 🙉 🙊 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ 9⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
}
],
"encrypt_decrypt_long_msg": [
{
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
"letter": "ф",
"repeat": 65535,
"payload_checksum_sha256": "",
"note": "фффф... (65535 times)"
}
]
},
"invalid": {
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
"get_conversation_key": [
{
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 higher than curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 is 0"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"note": "pub2 is invalid, no sqrt, all-ff"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 == curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "pub2 is invalid, no sqrt"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
"note": "pub2 is point of order 3 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
"note": "pub2 is point of order 13 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
"note": "pub2 is point of order 3319 on twist"
}
],
"decrypt": [
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o b l e",
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
"note": "unknown encryption version"
},
{
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
"plaintext": "⚠️",
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
"note": "unknown encryption version 0"
},
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o s t r",
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
"note": "invalid base64"
},
{
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
"plaintext": "¯\\_(ツ)_/¯",
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"note": "invalid MAC"
},
{
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
"plaintext": "🥎",
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
"note": "invalid MAC"
},
{
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
"plaintext": "elliptic-curve cryptography",
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
"note": "invalid padding"
},
{
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
"plaintext": "noble",
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
"note": "invalid padding"
},
{
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
"plaintext": "censorship-resistant and global social network",
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
"note": "invalid padding"
}
]
}
}
}

362
nip46.ts
View File

@@ -1,362 +0,0 @@
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts'
import { Signer } from './signer.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = {
relays: string[]
pubkey: string
secret: null | string
}
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
bunkerPointer.relays.forEach(relay => {
bunkerURL.searchParams.append('relay', relay)
})
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
}
return bunkerURL.toString()
}
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
and returns a BunkerPointer -- or null in case of error */
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
let match = input.match(BUNKER_REGEX)
if (match) {
try {
const pubkey = match[1]
const qs = new URLSearchParams(match[2])
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret'),
}
} catch (_err) {
/* just move to the next case */
}
}
return queryBunkerProfile(input)
}
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json()
let pubkey = res.names[name]
let relays = res.nip46[pubkey] || []
return { pubkey, relays, secret: null }
} catch (_err) {
return null
}
}
export type BunkerSignerParams = {
pool?: AbstractSimplePool
onauth?: (url: string) => void
}
export class BunkerSigner implements Signer {
private params: BunkerSignerParams
private pool: AbstractSimplePool
private subCloser: SubCloser | undefined
private isOpen: boolean
private serial: number
private idPrefix: string
private listeners: {
[id: string]: {
resolve: (_: string) => void
reject: (_: string) => void
}
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer
private cachedPubKey: string | undefined
/**
* Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses.
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair.
*/
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
this.params = params
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0
this.listeners = {}
this.waitingForAuth = {}
this.setupSubscription(params)
}
private setupSubscription(params: BunkerSignerParams) {
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey
this.subCloser = this.pool.subscribe(
this.bp.relays,
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
{
onevent: async (event: NostrEvent) => {
const o = JSON.parse(decrypt(event.content, convKey))
const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id]
if (params.onauth) {
params.onauth(error)
} else {
console.warn(
`nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
)
}
return
}
let handler = listeners[id]
if (handler) {
if (error) handler.reject(error)
else if (result) handler.resolve(result)
delete listeners[id]
}
},
onclose: () => {
this.subCloser = undefined
},
},
)
this.isOpen = true
}
// closes the subscription -- this object can't be used anymore after this
async close() {
this.isOpen = false
this.subCloser!.close()
}
async sendRequest(method: string, params: string[]): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
if (!this.subCloser) this.setupSubscription(this.params)
this.serial++
const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
// the request event
const verifiedEvent: VerifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000),
},
this.secretKey,
)
// setup callback listener
this.listeners[id] = { resolve, reject }
this.waitingForAuth[id] = true
// publish the event
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
} catch (err) {
reject(err)
}
})
}
/**
* Calls the "connect" method on the bunker.
* The promise will be rejected if the response is not "pong".
*/
async ping(): Promise<void> {
let resp = await this.sendRequest('ping', [])
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
}
/**
* Calls the "connect" method on the bunker.
*/
async connect(): Promise<void> {
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || ''])
}
/**
* Calls the "get_public_key" method on the bunker.
* (before we would return the public key hardcoded in the bunker parameters, but
* that is not correct as that may be the bunker pubkey and the actual signer
* pubkey may be different.)
*/
async getPublicKey(): Promise<string> {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', [])
}
return this.cachedPubKey
}
/**
* Signs an event using the remote private key.
* @param event - The event to sign.
* @returns A Promise that resolves to the signed event.
*/
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp)
if (verifyEvent(signed)) {
return signed
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
}
}
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
}
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
}
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
}
}
/**
* Creates an account with the specified username, domain, and optional email.
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
* @param username - The username for the account.
* @param domain - The domain for the account.
* @param email - The optional email for the account.
* @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.
*/
export async function createAccount(
bunker: BunkerProfile,
params: BunkerSignerParams,
username: string,
domain: string,
email?: string,
localSecretKey: Uint8Array = generateSecretKey(),
): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
// once we get the newly created pubkey back, we hijack this signer instance
// and turn it into the main instance for this newly created pubkey
rpc.bp.pubkey = pubkey
await rpc.connect()
return rpc
}
/**
* Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects.
*/
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()],
})
events.sort((a, b) => b.created_at - a.created_at)
// validate bunkers by checking their NIP-05 and pubkey
// map to a more useful object
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content)
// skip duplicates
try {
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
} catch (err) {
/***/
}
const bp = await queryBunkerProfile(content.nip05)
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false,
}
}
} catch (err) {
return undefined
}
}),
)
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
}
export type BunkerProfile = {
bunkerPointer: BunkerPointer
domain: string
nip05: string
name: string
picture: string
about: string
website: string
local: boolean
}

View File

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

View File

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

View File

@@ -1,95 +0,0 @@
import { test, expect } from 'bun:test'
import { decrypt, encrypt } from './nip49.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('encrypt and decrypt', () => {
for (let i = 0; i < vectors.length; i++) {
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
let sec = hexToBytes(secret)
let there = encrypt(sec, password, logn, ksb)
let back = decrypt(there, password)
let again = decrypt(ncryptsec, password)
expect(back).toEqual(again)
expect(again).toEqual(sec)
}
})
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
1,
0x00,
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
2,
0x01,
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
3,
0x02,
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
],
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
7,
0x00,
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
8,
0x01,
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x02,
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
4,
0x00,
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
],
[
'',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
5,
0x01,
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
1,
0x00,
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
],
[
'ÅΩẛ̣',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
],
[
'ÅΩṩ',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
],
]

View File

@@ -1,50 +0,0 @@
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base'
export function encrypt(
sec: Uint8Array,
password: string,
logn: number = 16,
ksb: 0x00 | 0x01 | 0x02 = 0x02,
): Ncryptsec {
let salt = randomBytes(16)
let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let nonce = randomBytes(24)
let aad = Uint8Array.from([ksb])
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let ciphertext = xc2p1.encrypt(sec)
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
return encodeBytes('ncryptsec', b)
}
export function decrypt(ncryptsec: string, password: string): Uint8Array {
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
if (prefix !== 'ncryptsec') {
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
}
let b = new Uint8Array(bech32.fromWords(words))
let version = b[0]
if (version !== 0x02) {
throw new Error(`invalid version ${version}, expected 0x02`)
}
let logn = b[1]
let n = 2 ** logn
let salt = b.slice(2, 2 + 16)
let nonce = b.slice(2 + 16, 2 + 16 + 24)
let ksb = b[2 + 16 + 24]
let aad = Uint8Array.from([ksb])
let ciphertext = b.slice(2 + 16 + 24 + 1)
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let sec = xc2p1.decrypt(ciphertext)
return sec
}

View File

@@ -1,42 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { normalizeIdentifier } from './nip54.ts'
describe('normalizeIdentifier', () => {
test('converts to lowercase', () => {
expect(normalizeIdentifier('HELLO')).toBe('hello')
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
})
test('trims whitespace', () => {
expect(normalizeIdentifier(' hello ')).toBe('hello')
expect(normalizeIdentifier('\thello\n')).toBe('hello')
})
test('normalizes Unicode to NFKC form', () => {
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
expect(normalizeIdentifier('café')).toBe('café')
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
})
test('replaces non-alphanumeric characters with hyphens', () => {
expect(normalizeIdentifier('hello world')).toBe('hello-world')
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
})
test('preserves numbers', () => {
expect(normalizeIdentifier('user123')).toBe('user123')
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
})
test('handles multiple consecutive special characters', () => {
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
})
test('handles Unicode letters from different scripts', () => {
expect(normalizeIdentifier('привет')).toBe('привет')
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
})
})

View File

@@ -1,19 +0,0 @@
export function normalizeIdentifier(name: string): string {
// Trim and lowercase
name = name.trim().toLowerCase()
// Normalize Unicode to NFKC form
name = name.normalize('NFKC')
// Convert to array of characters and map each one
return Array.from(name)
.map(char => {
// Check if character is letter or number using Unicode ranges
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
return char
}
return '-'
})
.join('')
}

View File

@@ -1,166 +0,0 @@
import { test, expect } from 'bun:test'
import * as nip55 from './nip55.js'
// Function to parse the NostrSigner URI
function parseNostrSignerUri(uri: string) {
const [base, query] = uri.split('?')
const basePart = base.replace('nostrsigner:', '')
let jsonObject = null
if (basePart) {
try {
jsonObject = JSON.parse(decodeURIComponent(basePart))
} catch (e) {
console.warn('Failed to parse base JSON:', e)
}
}
const urlSearchParams = new URLSearchParams(query)
const queryParams = Object.fromEntries(urlSearchParams.entries())
if (queryParams.permissions) {
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
}
return {
base: jsonObject,
...queryParams,
}
}
// Test cases
test('Get Public Key URI', () => {
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
const callbackUrl = 'https://example.com/?event='
const uri = nip55.getPublicKeyUri({
permissions,
callbackUrl,
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'get_public_key')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
})
test('Sign Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.signEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('base.content', 'test')
expect(jsonObject).toHaveProperty('type', 'sign_event')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})
test('Encrypt NIP-04 URI', () => {
const callbackUrl = 'https://example.com/?event='
const uri = nip55.encryptNip04Uri({
callbackUrl,
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-04 URI', () => {
const uri = nip55.decryptNip04Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Encrypt NIP-44 URI', () => {
const uri = nip55.encryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-44 URI', () => {
const uri = nip55.decryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Decrypt Zap Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.decryptZapEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
returnType: 'event',
compressionType: 'gzip',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
expect(jsonObject).toHaveProperty('returnType', 'event')
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})

123
nip55.ts
View File

@@ -1,123 +0,0 @@
type BaseParams = {
callbackUrl?: string
returnType?: 'signature' | 'event'
compressionType?: 'none' | 'gzip'
}
type PermissionsParams = BaseParams & {
permissions?: { type: string; kind?: number }[]
}
type EventUriParams = BaseParams & {
eventJson: Record<string, unknown>
id?: string
currentUser?: string
}
type EncryptDecryptParams = BaseParams & {
pubKey: string
content: string
id?: string
currentUser?: string
}
type UriParams = BaseParams & {
base: string
type: string
id?: string
currentUser?: string
permissions?: { type: string; kind?: number }[]
pubKey?: string
plainText?: string
encryptedText?: string
appName?: string
}
function encodeParams(params: Record<string, unknown>): string {
return new URLSearchParams(params as Record<string, string>).toString()
}
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
}
function buildUri({
base,
type,
callbackUrl,
returnType = 'signature',
compressionType = 'none',
...params
}: UriParams): string {
const baseParams = {
type,
compressionType,
returnType,
callbackUrl,
id: params.id,
current_user: params.currentUser,
permissions:
params.permissions && params.permissions.length > 0
? encodeURIComponent(JSON.stringify(params.permissions))
: undefined,
pubKey: params.pubKey,
plainText: params.plainText,
encryptedText: params.encryptedText,
appName: params.appName,
}
const filteredParams = filterUndefined(baseParams)
return `${base}?${encodeParams(filteredParams)}`
}
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
return buildUri({
base: 'nostrsigner:',
type,
...params,
})
}
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
return buildDefaultUri('get_public_key', { permissions, ...params })
}
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'sign_event',
...params,
})
}
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, plainText: params.content })
}
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, encryptedText: params.content })
}
export function encryptNip04Uri(params: EncryptDecryptParams): string {
return encryptUri('nip04_encrypt', params)
}
export function decryptNip04Uri(params: EncryptDecryptParams): string {
return decryptUri('nip04_decrypt', params)
}
export function encryptNip44Uri(params: EncryptDecryptParams): string {
return encryptUri('nip44_encrypt', params)
}
export function decryptNip44Uri(params: EncryptDecryptParams): string {
return decryptUri('nip44_decrypt', params)
}
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'decrypt_zap_event',
...params,
})
}

View File

@@ -1,349 +0,0 @@
import { describe, test, expect, mock } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import {
getSatoshisAmountFromBolt11,
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback',
}),
}),
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: '',
}),
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: '',
}),
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment',
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
})
})
describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => {
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
})
test('returns an error message if the Zap request is not a valid Nostr event', () => {
const zapRequest = {
kind: 1234,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
pubkey: publicKey,
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['e', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
],
},
privateKey,
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
})
test('returns null for a valid Zap request', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
})
})
describe('makeZapReceipt', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
test('returns a valid Zap receipt with a preimage', () => {
const zapRequest = JSON.stringify(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
),
)
const preimage = 'preimage'
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
['preimage', preimage],
]),
)
})
test('returns a valid Zap receipt without a preimage', () => {
const zapRequest = JSON.stringify(
finalizeEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
},
privateKey,
),
)
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')
})
})
test('parses the amount from bolt11 invoices', () => {
expect(
getSatoshisAmountFromBolt11(
'lnbc4u1p5zcarnpp5djng98r73nxu66nxp6gndjkw24q7rdzgp7p80lt0gk4z3h3krkssdq9tfpygcqzzsxqzjcsp58hz3v5qefdm70g5fnm2cn6q9thzpu6m4f5wjqurhur5xzmf9vl3s9qxpqysgq9v6qv86xaruzeak9jjyz54fygrkn526z7xhm0llh8wl44gcgh0rznhjqdswd4cjurzdgh0pgzrfj4sd7f3mf89jd6kadse008ex7kxgqqa5xrk',
),
).toEqual(400)
expect(
getSatoshisAmountFromBolt11(
'lnbc8400u1p5zcaz5pp5ltvyhtg4ed7sd8jurj28ugmavezkmqsadpe3t9npufpcrd0uet0scqzyssp5l3hz4ayt5ee0p83ma4a96l2rruhx33eyycewldu2ffa5pk2qx7jq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdq8w3jhxaqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll8qkt3np4rqyqqqqlgqqqqqeqqjqhuhjk5u9r850ncxngne7cfp9s08s2nm6c2rkz7jhl8gjmlx0fga5tlncgeuh4avlsrkq6ljyyhgq8rrxprga03esqhd0gf5455x6tdcqahhw9q',
),
).toEqual(840000)
expect(
getSatoshisAmountFromBolt11(
'lnbc210n1p5zcuaxpp52nn778cfk46md4ld0hdj2juuzvfrsrdaf4ek2k0yeensae07x2cqdq9tfpygcqzzsxqzjcsp5768c4k79jtnq92pgppan8rjnujcpcqhnqwqwk3lm5dfr7e0k2a7s9qxpqysgqt8lnh9l7ple27t73x7gty570ltas2s33uahc7egke5tdmhxr3ezn590wf2utxyt7d3afnk2lxc2u0enc6n53ck4mxwpmzpxa7ws05aqp0c5x3r',
),
).toEqual(21)
expect(
getSatoshisAmountFromBolt11(
'lnbc899640n1p5zcuavpp5w72fqrf09286lq33vw364qryrq5nw60z4dhdx56f8w05xkx4massdq9tfpygcqzzsxqzjcsp5qrqn4kpvem5jwpl63kj5pfdlqxg2plaffz0prz7vaqjy29uc66us9qxpqysgqlhzzqmn2jxd2476404krm8nvrarymwq7nj2zecl92xug54ek0mfntdxvxwslf756m8kq0r7jtpantm52fmewc72r5lfmd85505jnemgqw5j0pc',
),
).toEqual(89964)
})

193
nip57.ts
View File

@@ -1,193 +0,0 @@
import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
try {
let lnurl: string = ''
let { lud06, lud16 } = JSON.parse(metadata.content)
if (lud06) {
let { words } = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} catch (err) {
/*-*/
}
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = '',
}: {
profile: string
event: string | Event | null
amount: number
comment: string
relays: string[]
}): EventTemplate {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays],
],
}
if (event && typeof event === 'string') {
zr.tags.push(['e', event])
}
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
}
return zr
}
export function validateZapRequest(zapRequestString: string): string | null {
let zapRequest: Event
try {
zapRequest = JSON.parse(zapRequestString)
} catch (err) {
return 'Invalid zap request JSON.'
}
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag."
return null
}
export function makeZapReceipt({
zapRequest,
preimage,
bolt11,
paidAt,
}: {
zapRequest: string
preimage?: string
bolt11: string
paidAt: Date
}): EventTemplate {
let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
let zap: EventTemplate = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
}
if (preimage) {
zap.tags.push(['preimage', preimage])
}
return zap
}
export function getSatoshisAmountFromBolt11(bolt11: string): number {
if (bolt11.length < 50) {
return 0
}
bolt11 = bolt11.substring(0, 50)
const idx = bolt11.lastIndexOf('1')
if (idx === -1) {
return 0
}
const hrp = bolt11.substring(0, idx)
if (!hrp.startsWith('lnbc')) {
return 0
}
const amount = hrp.substring(4) // equivalent to strings.CutPrefix
if (amount.length < 1) {
return 0
}
// if last character is a digit, then the amount can just be interpreted as BTC
const char = amount[amount.length - 1]
const digit = char.charCodeAt(0) - '0'.charCodeAt(0)
const isDigit = digit >= 0 && digit <= 9
let cutPoint = amount.length - 1
if (isDigit) {
cutPoint++
}
if (cutPoint < 1) {
return 0
}
const num = parseInt(amount.substring(0, cutPoint))
switch (char) {
case 'm':
return num * 100000
case 'u':
return num * 100
case 'n':
return num / 10
case 'p':
return num / 10000
default:
return num * 100000000
}
}

View File

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

@@ -1,245 +0,0 @@
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,113 +0,0 @@
import { test, expect } from 'bun:test'
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
import { decode } from './nip19.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
const recipientPublicKey = getPublicKey(recipientPrivateKey)
const event = {
kind: 1,
content: 'Are you going to the party tonight?',
}
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
[Symbol('verified')]: true,
}
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
expect(result.kind).toEqual(expected.kind)
expect(result.tags).toEqual(expected.tags)
})
test('wrapManyEvent', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 1,
content: 'Are you going to the party tonight?',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [],
}
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})
test('getWrappedEvents and unwrapManyEvents', async () => {
const expected = [
{
created_at: 1729721879,
content: 'Hello!',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
},
{
created_at: 1729722025,
content: 'How are you?',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
},
]
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
const pool = new SimplePool()
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
unwrappedEvents.forEach((event, index) => {
expect(event).toEqual(expected[index])
})
})

107
nip59.ts
View File

@@ -1,107 +0,0 @@
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
import { Seal, GiftWrap } from './kinds.ts'
type Rumor = UnsignedEvent & { id: string }
const TWO_DAYS = 2 * 24 * 60 * 60
const now = () => Math.round(Date.now() / 1000)
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
const rumor = {
created_at: now(),
content: '',
tags: [],
...event,
pubkey: getPublicKey(privateKey),
} as any
rumor.id = getEventHash(rumor)
return rumor as Rumor
}
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
return finalizeEvent(
{
kind: Seal,
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
created_at: randomNow(),
tags: [],
},
privateKey,
)
}
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
const randomKey = generateSecretKey()
return finalizeEvent(
{
kind: GiftWrap,
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
created_at: randomNow(),
tags: [['p', recipientPublicKey]],
},
randomKey,
) as NostrEvent
}
export function wrapEvent(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientPublicKey: string,
): NostrEvent {
const rumor = createRumor(event, senderPrivateKey)
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
return createWrap(seal, recipientPublicKey)
}
export function wrapManyEvents(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientsPublicKeys: string[],
): NostrEvent[] {
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
recipientsPublicKeys.forEach(recipientPublicKey => {
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
})
return wrappeds
}
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
}
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
let unwrappedEvents: Rumor[] = []
wrappedEvents.forEach(e => {
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
})
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
return unwrappedEvents
}

View File

@@ -1,203 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { ZapGoal } from './kinds.ts'
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('Goal Type', () => {
it('should create a proper Goal object', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
expect(goal.content).toBe('Fundraising for a new project')
expect(goal.amount).toBe('100000000')
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
expect(goal.closedAt).toBe(1671150419)
expect(goal.image).toBe('https://example.com/goal-image.jpg')
expect(goal.summary).toBe('Help us reach our fundraising goal!')
expect(goal.r).toBe('https://example.com/additional-info')
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
expect(goal.zapTags).toEqual([
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
})
describe('generateGoalEventTemplate', () => {
it('should generate an EventTemplate for a fundraising goal', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
])
})
it('should generate an EventTemplate that is valid', () => {
const sk = generateSecretKey()
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
})
describe('validateZapGoalEvent', () => {
it('should validate a proper Goal event', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
it('should not validate an event with an incorrect kind', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: 0, // Incorrect kind
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "amount" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
// Missing "amount" tag
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "relays" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
// Missing "relays" tag
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
})

115
nip75.ts
View File

@@ -1,115 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { ZapGoal } from './kinds.ts'
/**
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
* This type is used to structure the information needed to create a goal event (`kind:9041`).
*/
export type Goal = {
/**
* A human-readable description of the fundraising goal.
* This content should provide clear information about the purpose of the fundraising.
*/
content: string
/**
* The target amount for the fundraising goal in milisats.
* This defines the financial target that the fundraiser aims to reach.
*/
amount: string
/**
* A list of relays where the zaps towards this goal will be sent to and tallied from.
* Each relay is represented by its WebSocket URL.
*/
relays: string[]
/**
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
* Zaps published after this timestamp should not count towards the goal progress.
* If not provided, the goal remains open indefinitely or until manually closed.
*/
closedAt?: number
/**
* An optional URL to an image related to the goal.
* This can be used to visually represent the goal on client interfaces.
*/
image?: string
/**
* An optional brief description or summary of the goal.
* This can provide a quick overview of the goal, separate from the detailed `content`.
*/
summary?: string
/**
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
* This is a single URL, as per NIP-75 specifications for linking additional resources.
*/
r?: string
/**
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
*/
a?: string
/**
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
*/
zapTags?: string[][]
}
/**
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
* This function is tailored to fit the structure of EventTemplate as defined in the library.
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
* @returns An EventTemplate object structured for creating a Nostr event.
*/
export function generateGoalEventTemplate({
amount,
content,
relays,
a,
closedAt,
image,
r,
summary,
zapTags,
}: Goal): EventTemplate {
const tags: string[][] = [
['amount', amount],
['relays', ...relays],
]
// Append optional tags based on the presence of optional properties in zapGoal
closedAt && tags.push(['closed_at', closedAt.toString()])
image && tags.push(['image', image])
summary && tags.push(['summary', summary])
r && tags.push(['r', r])
a && tags.push(['a', a])
zapTags && tags.push(...zapTags)
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content,
tags,
}
return eventTemplate
}
export function validateZapGoalEvent(event: Event): boolean {
if (event.kind !== ZapGoal) return false
const requiredTags = ['amount', 'relays'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}

View File

@@ -1,390 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('generateEventTemplate', () => {
it('should generate the correct event template', () => {
const fileMetadataObject: FileMetadataObject = {
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
}
const expectedEventTemplate: EventTemplate = {
content: 'Lorem ipsum dolor sit amet',
created_at: expect.any(Number),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
}
const eventTemplate = generateEventTemplate(fileMetadataObject)
expect(eventTemplate).toEqual(expectedEventTemplate)
})
})
describe('validateEvent', () => {
it('should return true for a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
it('should return false if kind is not FileMetadataKind', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: 0, // not FileMetadataKind
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if content is empty', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // empty
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if required tags are missing', () => {
const sk = generateSecretKey()
const eventWithoutUrl: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
// missing url
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutM: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
// missing m
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutX: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
// missing x
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutOx: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
// missing ox
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(eventWithoutUrl)).toBe(false)
expect(validateEvent(eventWithoutM)).toBe(false)
expect(validateEvent(eventWithoutX)).toBe(false)
expect(validateEvent(eventWithoutOx)).toBe(false)
})
it('should return false if size is not a number', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', 'abc'], // not a number
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if dim is not a valid dimension string', () => {
const sk = generateSecretKey()
const eventWithInvalidDim: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', 'abc'], // invalid dim
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(eventWithInvalidDim)).toBe(false)
})
})
describe('parseEvent', () => {
it('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
},
sk,
)
const parsedEvent = parseEvent(event)
expect(parsedEvent).toEqual({
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
})
})
it('should throw an error if the event is invalid', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // invalid
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow('Invalid event')
})
})

211
nip94.ts
View File

@@ -1,211 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
/**
* Type definition for File Metadata as specified in NIP-94.
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
*/
export type FileMetadataObject = {
/**
* A description or caption for the file content.
*/
content: string
/**
* The URL to download the file.
*/
url: string
/**
* The MIME type of the file, in lowercase.
*/
m: string
/**
* The SHA-256 hex-encoded string of the file.
*/
x: string
/**
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
*/
ox: string
/**
* Optional: The size of the file in bytes.
*/
size?: string
/**
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
*/
dim?: string
/**
* Optional: The URI to the magnet file.
*/
magnet?: string
/**
* Optional: The torrent infohash.
*/
i?: string
/**
* Optional: The blurhash string to show while the file is being loaded by the client.
*/
blurhash?: string
/**
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
*/
thumb?: string
/**
* Optional: The URL of a preview image with the same dimensions as the original file.
*/
image?: string
/**
* Optional: A text excerpt or summary of the file's content.
*/
summary?: string
/**
* Optional: A description for accessibility, providing context or a brief description of the file.
*/
alt?: string
/**
* Optional: fallback URLs in case url fails.
*/
fallback?: string[]
}
/**
* Generates an event template based on a file metadata object.
*
* @param fileMetadata - The file metadata object.
* @returns The event template.
*/
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
const eventTemplate: EventTemplate = {
content: fileMetadata.content,
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', fileMetadata.url],
['m', fileMetadata.m],
['x', fileMetadata.x],
['ox', fileMetadata.ox],
],
}
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
return eventTemplate
}
/**
* Validates an event to ensure it is a valid file metadata event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (event.kind !== FileMetadataKind) return false
if (!event.content) return false
const requiredTags = ['url', 'm', 'x', 'ox'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate optional size tag
const sizeTag = event.tags.find(([t]) => t == 'size')
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
// validate optional dim tag
const dimTag = event.tags.find(([t]) => t == 'dim')
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
return true
}
/**
* Parses an event and returns a file metadata object.
* @param event - The event to parse.
* @returns The file metadata object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): FileMetadataObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const fileMetadata: FileMetadataObject = {
content: event.content,
url: '',
m: '',
x: '',
ox: '',
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'url':
fileMetadata.url = value
break
case 'm':
fileMetadata.m = value
break
case 'x':
fileMetadata.x = value
break
case 'ox':
fileMetadata.ox = value
break
case 'size':
fileMetadata.size = value
break
case 'dim':
fileMetadata.dim = value
break
case 'magnet':
fileMetadata.magnet = value
break
case 'i':
fileMetadata.i = value
break
case 'blurhash':
fileMetadata.blurhash = value
break
case 'thumb':
fileMetadata.thumb = value
break
case 'image':
fileMetadata.image = value
break
case 'summary':
fileMetadata.summary = value
break
case 'alt':
fileMetadata.alt = value
break
case 'fallback':
fileMetadata.fallback ??= []
fileMetadata.fallback.push(value)
break
}
}
return fileMetadata
}

View File

@@ -1,350 +0,0 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { describe, expect, test } from 'bun:test'
import { HTTPAuth } from './kinds.ts'
import {
getToken,
hashPayload,
unpackEventFromToken,
validateEvent,
validateEventKind,
validateEventMethodTag,
validateEventPayloadTag,
validateEventTimestamp,
validateEventUrlTag,
validateToken,
} from './nip98.ts'
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { utf8Encoder } from './utils.ts'
describe('getToken', () => {
test('returns without authorization scheme for GET', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get'],
])
})
test('returns token without authorization scheme for POST', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token WITH authorization scheme for POST', async () => {
const authorizationScheme = 'Nostr '
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(token.startsWith(authorizationScheme)).toBe(true)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token with a valid payload tag when payload is present', async () => {
const sk = generateSecretKey()
const payload = { test: 'payload' }
const payloadHash = hashPayload(payload)
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
])
})
})
describe('validateToken', () => {
test('returns true for valid token without authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
})
test('returns true for valid token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
})
test('throws an error for invalid token', async () => {
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for missing token', async () => {
const isTokenValid = validateToken('', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.kind = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.created_at = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid url', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('throws an error for invalid method', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://test.com', 'post')
expect(isTokenValid).rejects.toThrow(Error)
})
})
describe('validateEvent', () => {
test('returns true for valid decoded token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).toBe(true)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
expect(isEventValid).rejects.toThrow(Error)
})
test('returns true for valid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
expect(isEventValid).toBe(true)
})
test('returns false for invalid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(isEventValid).rejects.toThrow(Error)
})
})
describe('validateEventTimestamp', () => {
test('returns true for valid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(true)
})
test('returns false for invalid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(false)
})
})
describe('validateEventKind', () => {
test('returns true for valid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(true)
})
test('returns false for invalid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(false)
})
})
describe('validateEventUrlTag', () => {
test('returns true for valid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
expect(isEventUrlTagValid).toBe(true)
})
test('returns false for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
expect(isEventUrlTagValid).toBe(false)
})
})
describe('validateEventMethodTag', () => {
test('returns true for valid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
expect(isEventMethodTagValid).toBe(true)
})
test('returns false for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
expect(isEventMethodTagValid).toBe(false)
})
})
describe('validateEventPayloadTag', () => {
test('returns true for valid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
expect(isEventPayloadTagValid).toBe(true)
})
test('returns false for invalid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
expect(isEventPayloadTagValid).toBe(false)
})
test('returns false for missing payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
expect(isEventPayloadTagValid).toBe(false)
})
})
describe('hashPayload', () => {
test('returns hash for valid payload', async () => {
const payload = { test: 'payload' }
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
test('returns hash for empty payload', async () => {
const payload = {}
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
})

206
nip98.ts
View File

@@ -1,206 +0,0 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { HTTPAuth } from './kinds.ts'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
const _authorizationScheme = 'Nostr '
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: (e: EventTemplate) => Promise<Event> | Event,
includeAuthorizationScheme: boolean = false,
payload?: Record<string, any>,
): Promise<string> {
const event: EventTemplate = {
kind: HTTPAuth,
tags: [
['u', loginUrl],
['method', httpMethod],
],
created_at: Math.round(new Date().getTime() / 1000),
content: '',
}
if (payload) {
event.tags.push(['payload', hashPayload(payload)])
}
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
}
/**
* Validate token for NIP-98 flow.
*
* @example
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
const event = await unpackEventFromToken(token).catch(error => {
throw error
})
const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
return valid
}
/**
* Unpacks an event from a token.
*
* @param token - The token to unpack.
* @returns A promise that resolves to the unpacked event.
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
*/
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
throw new Error('Invalid token')
}
const event = JSON.parse(eventB64) as Event
return event
}
/**
* Validates the timestamp of an event.
* @param event - The event object to validate.
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
*/
export function validateEventTimestamp(event: Event): boolean {
if (!event.created_at) {
return false
}
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
}
/**
* Validates the kind of an event.
* @param event The event to validate.
* @returns A boolean indicating whether the event kind is valid.
*/
export function validateEventKind(event: Event): boolean {
return event.kind === HTTPAuth
}
/**
* Validates if the given URL matches the URL tag of the event.
* @param event - The event object.
* @param url - The URL to validate.
* @returns A boolean indicating whether the URL is valid or not.
*/
export function validateEventUrlTag(event: Event, url: string): boolean {
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag) {
return false
}
return urlTag.length > 0 && urlTag[1] === url
}
/**
* Validates if the given event has a method tag that matches the specified method.
* @param event - The event to validate.
* @param method - The method to match against the method tag.
* @returns A boolean indicating whether the event has a matching method tag.
*/
export function validateEventMethodTag(event: Event, method: string): boolean {
const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag) {
return false
}
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
}
/**
* Calculates the hash of a payload.
* @param payload - The payload to be hashed.
* @returns The hash value as a string.
*/
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Validates the event payload tag against the provided payload.
* @param event The event object.
* @param payload The payload to validate.
* @returns A boolean indicating whether the payload tag is valid.
*/
export function validateEventPayloadTag(event: Event, payload: any): boolean {
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (!payloadTag) {
return false
}
const payloadHash = hashPayload(payload)
return payloadTag.length > 0 && payloadTag[1] === payloadHash
}
/**
* Validates a Nostr event for the NIP-98 flow.
*
* @param event - The Nostr event to validate.
* @param url - The URL associated with the event.
* @param method - The HTTP method associated with the event.
* @param body - The request body associated with the event (optional).
* @returns A promise that resolves to a boolean indicating whether the event is valid.
* @throws An error if the event is invalid.
*/
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (!validateEventKind(event)) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!validateEventTimestamp(event)) {
throw new Error('Invalid nostr event, created_at timestamp invalid')
}
if (!validateEventUrlTag(event, url)) {
throw new Error('Invalid nostr event, url tag invalid')
}
if (!validateEventMethodTag(event, method)) {
throw new Error('Invalid nostr event, method tag invalid')
}
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
if (!validateEventPayloadTag(event, body)) {
throw new Error('Invalid nostr event, payload tag does not match request body hash')
}
}
return true
}

View File

@@ -1,506 +0,0 @@
import { describe, expect, test } from 'bun:test'
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', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
test('should return false when the "d" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "title" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
// Missing 'title' tag
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "summary" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
// Missing 'summary' tag
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
// Missing 'published_at' tag
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "location" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
// Missing 'location' tag
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
// Missing 'price' tag
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is not a valid timestamp', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', 'not-a-valid-timestamp'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid price', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', 'not-a-valid-price', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid currency', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'not-a-valid-currency'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
})
test('should return false when the "a" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['a', 'extra1'],
['a', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
const event2: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['e', 'extra1'],
['e', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
expect(validateEvent(event2)).toBe(false)
})
})
describe('parseEvent', () => {
test('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
const expectedListing = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
e: ['value1', 'value2'],
a: ['value1', 'value2'],
},
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
expect(parseEvent(event)).toEqual(expectedListing)
})
test('should throw an error for an invalid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow(Error)
})
})
describe('generateEventTemplate', () => {
test('should generate the correct event template for a classified listing', () => {
const listing: ClassifiedListingObject = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
extra1: 'value1',
extra2: 'value2',
},
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
const expectedEventTemplate = {
kind: DraftClassifiedListing,
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['published_at', '1296962229'],
['summary', 'Sample Summary'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['extra1', 'value1'],
['extra2', 'value2'],
],
created_at: expect.any(Number),
}
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
})
})

228
nip99.ts
View File

@@ -1,228 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
/**
* Represents the details of a price.
* @example { amount: '100', currency: 'USD', frequency: 'month' }
* @example { amount: '100', currency: 'EUR' }
*/
export type PriceDetails = {
/**
* The amount of the price.
*/
amount: string
/**
* The currency of the price in 3-letter ISO 4217 format.
* @example 'USD'
*/
currency: string
/**
* The optional frequency of payment.
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
*/
frequency?: string
}
/**
* Represents a classified listing object.
*/
export type ClassifiedListingObject = {
/**
* Whether the listing is a draft or not.
*/
isDraft: boolean
/**
* A title of the listing.
*/
title: string
/**
* A short summary or tagline.
*/
summary: string
/**
* A description in Markdown format.
*/
content: string
/**
* Timestamp in unix seconds of when the listing was published.
*/
publishedAt: string
/**
* Location of the listing.
* @example 'NYC'
*/
location: string
/**
* Price details.
*/
price: PriceDetails
/**
* Images of the listing with optional dimensions.
*/
images: Array<{
url: string
dimensions?: string
}>
/**
* Tags/Hashtags (i.e. categories, keywords, etc.)
*/
hashtags: string[]
/**
* Other standard tags.
* @example "g", a geohash for more precise location
*/
additionalTags: Record<string, string | string[]>
}
/**
* Validates an event to ensure it is a valid classified listing event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
const requiredTagCount = requiredTags.length
const tagCounts: Record<string, number> = {}
if (event.tags.length < requiredTagCount) return false
for (const tag of event.tags) {
if (tag.length < 2) return false
const [tagName, ...tagValues] = tag
if (tagName == 'published_at') {
const timestamp = parseInt(tagValues[0])
if (isNaN(timestamp)) return false
} else if (tagName == 'price') {
if (tagValues.length < 2) return false
const price = parseInt(tagValues[0])
if (isNaN(price) || tagValues[1].length != 3) return false
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
return false
}
if (requiredTags.includes(tagName)) {
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
}
}
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
}
/**
* Parses an event and returns a classified listing object.
* @param event - The event to parse.
* @returns The classified listing object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): ClassifiedListingObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const listing: ClassifiedListingObject = {
isDraft: event.kind === DraftClassifiedListing,
title: '',
summary: '',
content: event.content,
publishedAt: '',
location: '',
price: {
amount: '',
currency: '',
},
images: [],
hashtags: [],
additionalTags: {},
}
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
const [tagName, ...tagValues] = tag
if (tagName == 'title') {
listing.title = tagValues[0]
} else if (tagName == 'summary') {
listing.summary = tagValues[0]
} else if (tagName == 'published_at') {
listing.publishedAt = tagValues[0]
} else if (tagName == 'location') {
listing.location = tagValues[0]
} else if (tagName == 'price') {
listing.price.amount = tagValues[0]
listing.price.currency = tagValues[1]
if (tagValues.length == 3) {
listing.price.frequency = tagValues[2]
}
} else if (tagName == 'image') {
listing.images.push({
url: tagValues[0],
dimensions: tagValues?.[1] ?? undefined,
})
} else if (tagName == 't') {
listing.hashtags.push(tagValues[0])
} else if (tagName == 'e' || tagName == 'a') {
listing.additionalTags[tagName] = [...tagValues]
}
}
return listing
}
/**
* Generates an event template based on a classified listing object.
*
* @param listing - The classified listing object.
* @returns The event template.
*/
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
const priceTag = ['price', listing.price.amount, listing.price.currency]
if (listing.price.frequency) priceTag.push(listing.price.frequency)
const tags: string[][] = [
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
['title', listing.title],
['published_at', listing.publishedAt],
['summary', listing.summary],
['location', listing.location],
priceTag,
]
for (let i = 0; i < listing.images.length; i++) {
const image = listing.images[i]
const imageTag = ['image', image.url]
if (image.dimensions) imageTag.push(image.dimensions)
tags.push(imageTag)
}
for (let i = 0; i < listing.hashtags.length; i++) {
const t = listing.hashtags[i]
tags.push(['t', t])
}
for (const [key, value] of Object.entries(listing.additionalTags)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const val = value[i]
tags.push([key, val])
}
} else {
tags.push([key, value])
}
}
return {
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
content: listing.content,
tags,
created_at: Math.floor(Date.now() / 1000),
}
}

View File

@@ -1,55 +0,0 @@
import { test, expect } from 'bun:test'
import { BlossomClient } from './nipb7.ts'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from './utils.ts'
import { PlainKeySigner } from './signer.ts'
import { generateSecretKey } from './pure.ts'
test('blossom', async () => {
const BLOSSOM_SERVER = 'blossom.primal.net'
const TEST_CONTENT = 'hello world'
const TEST_BLOB = new Blob([TEST_CONTENT], { type: 'text/plain' })
const expectedHash = bytesToHex(sha256(new TextEncoder().encode(TEST_CONTENT)))
const signer = new PlainKeySigner(generateSecretKey())
const client = new BlossomClient(BLOSSOM_SERVER, signer)
expect(client).toBeDefined()
// check for non-existent file should throw
const invalidHash = expectedHash.slice(0, 62) + 'ba'
let hasThrown = false
try {
await client.check(invalidHash)
} catch (err) {
hasThrown = true
}
expect(hasThrown).toBeTrue()
// upload hello world blob
const descriptor = await client.uploadBlob(TEST_BLOB, 'text/plain')
expect(descriptor).toBeDefined()
expect(descriptor.sha256).toBe(expectedHash)
expect(descriptor.size).toBe(TEST_CONTENT.length)
expect(descriptor.type).toBe('text/plain')
expect(descriptor.url).toContain(expectedHash)
expect(descriptor.uploaded).toBeGreaterThan(0)
await client.check(expectedHash)
// download and verify
const downloadedBuffer = await client.download(expectedHash)
const downloadedContent = new TextDecoder().decode(downloadedBuffer)
expect(downloadedContent).toBe(TEST_CONTENT)
// list blobs should include our uploaded file
const blobs = await client.list()
expect(Array.isArray(blobs)).toBe(true)
const ourBlob = blobs.find(blob => blob.sha256 === expectedHash)
expect(ourBlob).toBeDefined()
expect(ourBlob?.type).toBe('text/plain')
expect(ourBlob?.size).toBe(TEST_CONTENT.length)
// delete
await client.delete(expectedHash)
})

203
nipb7.ts
View File

@@ -1,203 +0,0 @@
import { sha256 } from '@noble/hashes/sha256'
import { EventTemplate } from './core.ts'
import { Signer } from './signer.ts'
import { bytesToHex } from './utils.ts'
export type BlobDescriptor = {
url: string
sha256: string
size: number
type: string
uploaded: number
}
export class BlossomClient {
private mediaserver: string
private signer: Signer
constructor(mediaserver: string, signer: Signer) {
if (!mediaserver.startsWith('http')) {
mediaserver = 'https://' + mediaserver
}
this.mediaserver = mediaserver.replace(/\/$/, '') + '/'
this.signer = signer
}
private async httpCall(
method: string,
url: string,
contentType?: string,
addAuthorization?: () => Promise<string>,
body?: File | Blob,
result?: any,
): Promise<any> {
const headers: { [_: string]: string } = {}
if (contentType) {
headers['Content-Type'] = contentType
}
if (addAuthorization) {
const auth = await addAuthorization()
if (auth) {
headers['Authorization'] = auth
}
}
const response = await fetch(this.mediaserver + url, {
method,
headers,
body,
})
if (response.status >= 300) {
const reason = response.headers.get('X-Reason') || response.statusText
throw new Error(`${url} returned an error (${response.status}): ${reason}`)
}
if (result !== null && response.headers.get('content-type')?.includes('application/json')) {
return await response.json()
}
return response
}
private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const event: EventTemplate = {
created_at: now,
kind: 24242,
content: 'blossom stuff',
tags: [['expiration', String(now + 60)]],
}
if (modify) {
modify(event)
}
try {
const signedEvent = await this.signer.signEvent(event)
const eventJson = JSON.stringify(signedEvent)
return 'Nostr ' + btoa(eventJson)
} catch (error) {
return ''
}
}
private isValid32ByteHex(hash: string): boolean {
return /^[a-f0-9]{64}$/i.test(hash)
}
async check(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall('HEAD', hash)
} catch (error) {
throw new Error(`failed to check for ${hash}: ${error}`)
}
}
async uploadBlob(file: File | Blob, contentType?: string): Promise<BlobDescriptor> {
const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
const actualContentType = contentType || file.type || 'application/octet-stream'
const bd = await this.httpCall(
'PUT',
'upload',
actualContentType,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'upload'])
evt.tags.push(['x', hash])
}),
file,
{},
)
return bd
}
async uploadFile(file: File): Promise<BlobDescriptor> {
return this.uploadBlob(file, file.type)
}
async download(hash: string): Promise<ArrayBuffer> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
const authHeader = await this.authorizationHeader(evt => {
evt.tags.push(['t', 'get'])
evt.tags.push(['x', hash])
})
const response = await fetch(this.mediaserver + hash, {
method: 'GET',
headers: {
Authorization: authHeader,
},
})
if (response.status >= 300) {
throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`)
}
return await response.arrayBuffer()
}
async downloadAsBlob(hash: string): Promise<Blob> {
const arrayBuffer = await this.download(hash)
return new Blob([arrayBuffer])
}
async list(): Promise<BlobDescriptor[]> {
const pubkey = await this.signer.getPublicKey()
if (!this.isValid32ByteHex(pubkey)) {
throw new Error(`pubkey ${pubkey} is not valid`)
}
try {
const bds = await this.httpCall(
'GET',
`list/${pubkey}`,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'list'])
}),
undefined,
[],
)
return bds
} catch (error) {
throw new Error(`failed to list blobs: ${error}`)
}
}
async delete(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall(
'DELETE',
hash,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'delete'])
evt.tags.push(['x', hash])
}),
undefined,
null,
)
} catch (error) {
throw new Error(`failed to delete ${hash}: ${error}`)
}
}
}

View File

@@ -1,281 +1,39 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.15.2",
"version": "0.6.1",
"description": "Tools for making a Nostr client.",
"main": "dist/nostr-tools.esm.min.js",
"module": "dist/nostr-tools.esm.min.js",
"browser": "dist/nostr-tools.umd.min.js",
"repository": {
"type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git"
"url": "https://github.com/fiatjaf/nostr-tools.git"
},
"files": [
"lib"
],
"sideEffects": false,
"module": "./lib/esm/index.js",
"main": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts",
"exports": {
".": {
"import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./core": {
"import": "./lib/esm/core.js",
"require": "./lib/cjs/core.js",
"types": "./lib/types/core.d.ts"
},
"./pure": {
"import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts"
},
"./wasm": {
"import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts"
},
"./kinds": {
"import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./abstract-relay": {
"import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts"
},
"./relay": {
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./abstract-pool": {
"import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.d.ts"
},
"./pool": {
"import": "./lib/esm/pool.js",
"require": "./lib/cjs/pool.js",
"types": "./lib/types/pool.d.ts"
},
"./references": {
"import": "./lib/esm/references.js",
"require": "./lib/cjs/references.js",
"types": "./lib/types/references.d.ts"
},
"./nip04": {
"import": "./lib/esm/nip04.js",
"require": "./lib/cjs/nip04.js",
"types": "./lib/types/nip04.d.ts"
},
"./nip05": {
"import": "./lib/esm/nip05.js",
"require": "./lib/cjs/nip05.js",
"types": "./lib/types/nip05.d.ts"
},
"./nip06": {
"import": "./lib/esm/nip06.js",
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
},
"./nip07": {
"types": "./lib/types/nip07.d.ts"
},
"./nip10": {
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts"
},
"./nip11": {
"import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts"
},
"./nip13": {
"import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": {
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",
"types": "./lib/types/nip18.d.ts"
},
"./nip19": {
"import": "./lib/esm/nip19.js",
"require": "./lib/cjs/nip19.js",
"types": "./lib/types/nip19.d.ts"
},
"./nip21": {
"import": "./lib/esm/nip21.js",
"require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts"
},
"./nip25": {
"import": "./lib/esm/nip25.js",
"require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts"
},
"./nip27": {
"import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js",
"types": "./lib/types/nip27.d.ts"
},
"./nip28": {
"import": "./lib/esm/nip28.js",
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip29": {
"import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.d.ts"
},
"./nip30": {
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts"
},
"./nip39": {
"import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js",
"types": "./lib/types/nip39.d.ts"
},
"./nip42": {
"import": "./lib/esm/nip42.js",
"require": "./lib/cjs/nip42.js",
"types": "./lib/types/nip42.d.ts"
},
"./nip44": {
"import": "./lib/esm/nip44.js",
"require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts"
},
"./nip46": {
"import": "./lib/esm/nip46.js",
"require": "./lib/cjs/nip46.js",
"types": "./lib/types/nip46.d.ts"
},
"./nip49": {
"import": "./lib/esm/nip49.js",
"require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts"
},
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": {
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip59": {
"import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.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",
"types": "./lib/types/nip75.d.ts"
},
"./nip94": {
"import": "./lib/esm/nip94.js",
"require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts"
},
"./nip98": {
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./nip99": {
"import": "./lib/esm/nip99.js",
"require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts"
},
"./nipb7": {
"import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts"
},
"./fakejson": {
"import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts"
},
"./signer": {
"import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts"
},
"./utils": {
"import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js",
"types": "./lib/types/utils.d.ts"
}
},
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
"@noble/secp256k1": "^1.3.0",
"buffer": "^6.0.3",
"dns-packet": "^5.2.4",
"websocket-polyfill": "^0.0.3"
},
"keywords": [
"decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social",
"unstoppable",
"censorship",
"censorship-resistance",
"client",
"nostr"
"client"
],
"devDependencies": {
"@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.18",
"esbuild": "0.16.9",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0",
"events": "^3.3.0",
"mitata": "^0.1.6",
"mock-socket": "^9.3.1",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"typescript": "^5.8.2"
"rollup": "^2.61.1"
},
"files": [
"dist"
],
"scripts": {
"prepublish": "just build"
"prepublish": "rollup -c"
}
}

View File

@@ -1,237 +0,0 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { SimplePool, useWebSocketImplementation } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
import { hexToBytes } from '@noble/hashes/utils'
useWebSocketImplementation(MockWebSocketClient)
let pool: SimplePool
let mockRelays: MockRelay[]
let relayURLs: string[]
beforeEach(() => {
pool = new SimplePool()
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
relayURLs = mockRelays.map(mr => mr.url)
})
afterEach(() => {
pool.close(relayURLs)
})
test('removing duplicates when subscribing', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: [],
},
priv,
)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
})
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double subs', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 22346,
tags: [],
},
priv,
)
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
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.subscribeMap(
[
{ url: relayA, filter: { authors: [pub], kinds: [20001] } },
{ url: relayB, filter: { authors: [pub], kinds: [20002] } },
{ url: relayC, filter: { 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>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
})
})
expect(events.size).toBeGreaterThan(50)
})
test('querySync()', async () => {
let authors = mockRelays.flatMap(mr => mr.authors)
let events = await pool.querySync(relayURLs, {
authors: authors,
kinds: [1],
limit: 2,
})
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toBeGreaterThan(2)
expect(events).toHaveLength(uniqueEventCount)
})
test('get()', async () => {
let ids = mockRelays.flatMap(mr => mr.ids)
let event = await pool.get(relayURLs, {
ids: [ids[0]],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', ids[0])
})
test('track relays when publishing', async () => {
let event1 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
let event2 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
pool.trackRelays = true
await Promise.all(pool.publish(relayURLs, event1))
expect(pool.seenOn.get(event1.id)).toBeDefined()
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
pool.trackRelays = false
await Promise.all(pool.publish(relayURLs, event2))
expect(pool.seenOn.get(event2.id)).toBeUndefined()
})

22
pool.ts
View File

@@ -1,22 +0,0 @@
/* 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, websocketImplementation: _WebSocket })
}
}
export * from './abstract-pool.ts'

View File

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

59
pure.ts
View File

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

View File

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

View File

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

View File

@@ -1,119 +0,0 @@
import { expect, test } from 'bun:test'
import { Server } from 'mock-socket'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay, useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
useWebSocketImplementation(MockWebSocketClient)
test('connectivity', async () => {
const mockRelay = new MockRelay()
const relay = new Relay(mockRelay.url)
await relay.connect()
expect(relay.connected).toBeTrue()
relay.close()
})
test('connectivity, with Relay.connect()', async () => {
const mockRelay = new MockRelay()
const relay = await Relay.connect(mockRelay.url)
expect(relay.connected).toBeTrue()
relay.close()
})
test('querying', async done => {
const mockRelay = new MockRelay()
const kind = 0
const relay = new Relay(mockRelay.url)
await relay.connect()
relay.subscribe(
[
{
authors: mockRelay.authors,
kinds: [kind],
},
],
{
onevent(event) {
expect(mockRelay.authors).toContain(event.pubkey)
expect(event).toHaveProperty('kind', kind)
relay.close()
done()
},
},
)
})
test('listening and publishing and closing', async done => {
const mockRelay = new MockRelay()
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const kind = 23571
const relay = new Relay(mockRelay.url)
await relay.connect()
let sub = relay.subscribe(
[
{
kinds: [kind],
authors: [pk],
},
],
{
onevent(event) {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', kind)
expect(event).toHaveProperty('content', 'content')
sub.close() // close the subscription and will trigger onclose()
},
onclose() {
relay.close()
done()
},
},
)
relay.publish(
finalizeEvent(
{
kind,
content: 'content',
created_at: 0,
tags: [],
},
sk,
),
)
})
test('publish timeout', async () => {
const url = 'wss://relay.example.com'
new Server(url)
const relay = new Relay(url)
relay.publishTimeout = 100
await relay.connect()
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
expect(
relay.publish(
finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
),
),
).rejects.toThrow('publish timed out')
})

View File

@@ -1,30 +0,0 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
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, websocketImplementation: _WebSocket })
}
static async connect(url: string): Promise<Relay> {
const relay = new Relay(url)
await relay.connect()
return relay
}
}
export type RelayRecord = Record<string, { read: boolean; write: boolean }>
export * from './abstract-relay.ts'

Some files were not shown because too many files have changed in this diff Show More