mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9082953ede | ||
|
|
61f397463d | ||
|
|
312b6fd035 | ||
|
|
7f1bd4f4a8 | ||
|
|
26089ef958 | ||
|
|
2e305b7cd4 | ||
|
|
51c1a54ddf | ||
|
|
cb05ee188f | ||
|
|
fa9e169c46 | ||
|
|
bb1e3f2fa6 | ||
|
|
160987472f | ||
|
|
8b18341ebb | ||
|
|
901445dea1 | ||
|
|
91b67cd0d5 | ||
|
|
1e696e0f3b | ||
|
|
4b36848b2d | ||
|
|
3cb351a5f4 | ||
|
|
5db1934fa4 | ||
|
|
50c3f24b25 |
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -1,8 +1,7 @@
|
|||||||
name: test every commit
|
name: test every commit
|
||||||
on:
|
on:
|
||||||
push:
|
- push
|
||||||
branches:
|
- pull_request
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||||
|
|
||||||
Very lean on dependencies.
|
Only depends on _@scure_ and _@noble_ packages.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ let event = {
|
|||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'hello'
|
content: 'hello',
|
||||||
|
pubkey: getPublicKey(privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.pubkey = getPublicKey(privateKey)
|
|
||||||
event.sig = signEvent(event, privateKey)
|
event.sig = signEvent(event, privateKey)
|
||||||
|
|
||||||
let ok = validateEvent(event)
|
let ok = validateEvent(event)
|
||||||
@@ -102,13 +102,13 @@ event.sig = signEvent(event, sk)
|
|||||||
|
|
||||||
let pub = relay.publish(event)
|
let pub = relay.publish(event)
|
||||||
pub.on('ok', () => {
|
pub.on('ok', () => {
|
||||||
console.log(`{relay.url} has accepted our event`)
|
console.log(`${relay.url} has accepted our event`)
|
||||||
})
|
})
|
||||||
pub.on('seen', () => {
|
pub.on('seen', () => {
|
||||||
console.log(`we saw the event on {relay.url}`)
|
console.log(`we saw the event on ${relay.url}`)
|
||||||
})
|
})
|
||||||
pub.on('failed', reason => {
|
pub.on('failed', reason => {
|
||||||
console.log(`failed to publish to {relay.url}: ${reason}`)
|
console.log(`failed to publish to ${relay.url}: ${reason}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
await relay.close()
|
await relay.close()
|
||||||
@@ -182,7 +182,7 @@ let pk2 = getPublicKey(sk2)
|
|||||||
|
|
||||||
// on the sender side
|
// on the sender side
|
||||||
let message = 'hello'
|
let message = 'hello'
|
||||||
let ciphertext = await nip04.encrypt(sk1, pk2, 'hello')
|
let ciphertext = await nip04.encrypt(sk1, pk2, message)
|
||||||
|
|
||||||
let event = {
|
let event = {
|
||||||
kind: 4,
|
kind: 4,
|
||||||
@@ -196,7 +196,7 @@ sendEvent(event)
|
|||||||
|
|
||||||
// on the receiver side
|
// on the receiver side
|
||||||
sub.on('event', (event) => {
|
sub.on('event', (event) => {
|
||||||
let sender = event.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1]
|
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||||
pk1 === sender
|
pk1 === sender
|
||||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const {
|
|||||||
validateEvent,
|
validateEvent,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
signEvent,
|
signEvent,
|
||||||
getEventHash,
|
|
||||||
getPublicKey
|
getPublicKey
|
||||||
} = require('./lib/nostr.cjs')
|
} = require('./lib/nostr.cjs')
|
||||||
|
|
||||||
@@ -35,15 +34,15 @@ test('validate event', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('check signature', async () => {
|
test('check signature', async () => {
|
||||||
expect(await verifySignature(event)).toBeTruthy()
|
expect(verifySignature(event)).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sign event', async () => {
|
test('sign event', async () => {
|
||||||
let sig = await signEvent(unsigned, privateKey)
|
|
||||||
let hash = getEventHash(unsigned)
|
|
||||||
let pubkey = getPublicKey(privateKey)
|
let pubkey = getPublicKey(privateKey)
|
||||||
|
let authored = {...unsigned, pubkey}
|
||||||
|
|
||||||
let signed = {...unsigned, id: hash, sig, pubkey}
|
let sig = signEvent(authored, privateKey)
|
||||||
|
let signed = {...authored, sig}
|
||||||
|
|
||||||
expect(await verifySignature(signed)).toBeTruthy()
|
expect(verifySignature(signed)).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|||||||
19
event.ts
19
event.ts
@@ -3,7 +3,6 @@ import {sha256} from '@noble/hashes/sha256'
|
|||||||
|
|
||||||
import {utf8Encoder} from './utils'
|
import {utf8Encoder} from './utils'
|
||||||
|
|
||||||
|
|
||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
export enum Kind {
|
export enum Kind {
|
||||||
Metadata = 0,
|
Metadata = 0,
|
||||||
@@ -17,7 +16,7 @@ export enum Kind {
|
|||||||
ChannelMetadata = 41,
|
ChannelMetadata = 41,
|
||||||
ChannelMessage = 42,
|
ChannelMessage = 42,
|
||||||
ChannelHideMessage = 43,
|
ChannelHideMessage = 43,
|
||||||
ChannelMuteUser = 44,
|
ChannelMuteUser = 44
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = {
|
export type Event = {
|
||||||
@@ -41,6 +40,9 @@ export function getBlankEvent(): Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeEvent(evt: Event): string {
|
export function serializeEvent(evt: Event): string {
|
||||||
|
if (!validateEvent(evt))
|
||||||
|
throw new Error("can't serialize event with wrong or missing properties")
|
||||||
|
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
0,
|
0,
|
||||||
evt.pubkey,
|
evt.pubkey,
|
||||||
@@ -57,9 +59,10 @@ export function getEventHash(event: Event): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateEvent(event: Event): boolean {
|
export function validateEvent(event: Event): boolean {
|
||||||
if (event.id !== getEventHash(event)) return false
|
|
||||||
if (typeof event.content !== 'string') return false
|
if (typeof event.content !== 'string') return false
|
||||||
if (typeof event.created_at !== 'number') 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
|
if (!Array.isArray(event.tags)) return false
|
||||||
for (let i = 0; i < event.tags.length; i++) {
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
@@ -73,10 +76,12 @@ export function validateEvent(event: Event): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifySignature(
|
export function verifySignature(event: Event & {sig: string}): boolean {
|
||||||
event: Event & {id: string; sig: string}
|
return secp256k1.schnorr.verifySync(
|
||||||
): boolean {
|
event.sig,
|
||||||
return secp256k1.schnorr.verifySync(event.sig, event.id, event.pubkey)
|
getEventHash(event),
|
||||||
|
event.pubkey
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signEvent(event: Event, key: string): string {
|
export function signEvent(event: Event, key: string): string {
|
||||||
|
|||||||
35
fakejson.test.js
Normal file
35
fakejson.test.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
const {fj} = require('./lib/nostr.cjs')
|
||||||
|
|
||||||
|
test('match id', () => {
|
||||||
|
expect(
|
||||||
|
fj.matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fj.matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('match kind', () => {
|
||||||
|
expect(
|
||||||
|
fj.matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fj.matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||||
|
12720
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
26
fakejson.ts
Normal file
26
fakejson.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 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')
|
||||||
|
}
|
||||||
3
index.ts
3
index.ts
@@ -9,6 +9,9 @@ export * as nip06 from './nip06'
|
|||||||
export * as nip19 from './nip19'
|
export * as nip19 from './nip19'
|
||||||
export * as nip26 from './nip26'
|
export * as nip26 from './nip26'
|
||||||
|
|
||||||
|
export * as fj from './fakejson'
|
||||||
|
export * as utils from './utils'
|
||||||
|
|
||||||
// monkey patch secp256k1
|
// monkey patch secp256k1
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
import * as secp256k1 from '@noble/secp256k1'
|
||||||
import {hmac} from '@noble/hashes/hmac'
|
import {hmac} from '@noble/hashes/hmac'
|
||||||
|
|||||||
10
nip04.ts
10
nip04.ts
@@ -1,6 +1,6 @@
|
|||||||
import {randomBytes} from '@noble/hashes/utils'
|
import {randomBytes} from '@noble/hashes/utils'
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
import * as secp256k1 from '@noble/secp256k1'
|
||||||
import {encode as b64encode, decode as b64decode} from 'base64-arraybuffer'
|
import {base64} from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
import {utf8Decoder, utf8Encoder} from './utils'
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ export async function encrypt(
|
|||||||
cryptoKey,
|
cryptoKey,
|
||||||
plaintext
|
plaintext
|
||||||
)
|
)
|
||||||
let ctb64 = b64encode(ciphertext)
|
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||||
let ivb64 = b64encode(iv.buffer)
|
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||||
|
|
||||||
return `${ctb64}?iv=${ivb64}`
|
return `${ctb64}?iv=${ivb64}`
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,8 @@ export async function decrypt(
|
|||||||
false,
|
false,
|
||||||
['decrypt']
|
['decrypt']
|
||||||
)
|
)
|
||||||
let ciphertext = b64decode(ctb64)
|
let ciphertext = base64.decode(ctb64)
|
||||||
let iv = b64decode(ivb64)
|
let iv = base64.decode(ivb64)
|
||||||
|
|
||||||
let plaintext = await crypto.subtle.decrypt(
|
let plaintext = await crypto.subtle.decrypt(
|
||||||
{name: 'AES-CBC', iv},
|
{name: 'AES-CBC', iv},
|
||||||
|
|||||||
2
nip05.ts
2
nip05.ts
@@ -36,7 +36,7 @@ export async function queryProfile(
|
|||||||
name = '_'
|
name = '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name.match(/^[a-z0-9-_]+$/)) return null
|
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
|
||||||
|
|
||||||
let res = await (
|
let res = await (
|
||||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||||
|
|||||||
15
nip06.test.js
Normal file
15
nip06.test.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
const {nip06} = require('./lib/nostr.cjs')
|
||||||
|
|
||||||
|
test('generate private key from a mnemonic', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
|
||||||
|
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generate private key from a mnemonic and passphrase', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const passphrase = '123'
|
||||||
|
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
|
||||||
|
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||||
|
})
|
||||||
4
nip06.ts
4
nip06.ts
@@ -7,8 +7,8 @@ import {
|
|||||||
} from '@scure/bip39'
|
} from '@scure/bip39'
|
||||||
import {HDKey} from '@scure/bip32'
|
import {HDKey} from '@scure/bip32'
|
||||||
|
|
||||||
export function privateKeyFromSeedWords(mnemonic: string): string {
|
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
|
||||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic))
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||||
if (!privateKey) throw new Error('could not derive private key')
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
return secp256k1.utils.bytesToHex(privateKey)
|
return secp256k1.utils.bytesToHex(privateKey)
|
||||||
|
|||||||
12
nip19.ts
12
nip19.ts
@@ -1,8 +1,10 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import * as secp256k1 from '@noble/secp256k1'
|
||||||
import {bech32} from 'bech32'
|
import {bech32} from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
import {utf8Decoder, utf8Encoder} from './utils'
|
||||||
|
|
||||||
|
const Bech32MaxSize = 5000
|
||||||
|
|
||||||
export type ProfilePointer = {
|
export type ProfilePointer = {
|
||||||
pubkey: string // hex
|
pubkey: string // hex
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
@@ -17,7 +19,7 @@ export function decode(nip19: string): {
|
|||||||
type: string
|
type: string
|
||||||
data: ProfilePointer | EventPointer | string
|
data: ProfilePointer | EventPointer | string
|
||||||
} {
|
} {
|
||||||
let {prefix, words} = bech32.decode(nip19, 1000)
|
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
||||||
let data = new Uint8Array(bech32.fromWords(words))
|
let data = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
if (prefix === 'nprofile') {
|
if (prefix === 'nprofile') {
|
||||||
@@ -87,7 +89,7 @@ export function noteEncode(hex: string): string {
|
|||||||
function encodeBytes(prefix: string, hex: string): string {
|
function encodeBytes(prefix: string, hex: string): string {
|
||||||
let data = secp256k1.utils.hexToBytes(hex)
|
let data = secp256k1.utils.hexToBytes(hex)
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode(prefix, words, 1000)
|
return bech32.encode(prefix, words, Bech32MaxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nprofileEncode(profile: ProfilePointer): string {
|
export function nprofileEncode(profile: ProfilePointer): string {
|
||||||
@@ -96,7 +98,7 @@ export function nprofileEncode(profile: ProfilePointer): string {
|
|||||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode('nprofile', words, 1000)
|
return bech32.encode('nprofile', words, Bech32MaxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function neventEncode(event: EventPointer): string {
|
export function neventEncode(event: EventPointer): string {
|
||||||
@@ -105,7 +107,7 @@ export function neventEncode(event: EventPointer): string {
|
|||||||
1: (event.relays || []).map(url => utf8Encoder.encode(url))
|
1: (event.relays || []).map(url => utf8Encoder.encode(url))
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode('nevent', words, 1000)
|
return bech32.encode('nevent', words, Bech32MaxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "1.0.1",
|
"version": "1.2.1",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -11,10 +11,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^0.5.7",
|
"@noble/hashes": "^0.5.7",
|
||||||
"@noble/secp256k1": "^1.7.0",
|
"@noble/secp256k1": "^1.7.0",
|
||||||
|
"@scure/base": "^1.1.1",
|
||||||
"@scure/bip32": "^1.1.1",
|
"@scure/bip32": "^1.1.1",
|
||||||
"@scure/bip39": "^1.1.0",
|
"@scure/bip39": "^1.1.0"
|
||||||
"base64-arraybuffer": "^1.0.2",
|
|
||||||
"bech32": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"decentralization",
|
"decentralization",
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ test('listening (twice) and publishing', async () => {
|
|||||||
content: 'nostr-tools test suite'
|
content: 'nostr-tools test suite'
|
||||||
}
|
}
|
||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.sig = await signEvent(event, sk)
|
event.sig = signEvent(event, sk)
|
||||||
|
|
||||||
relay.publish(event)
|
relay.publish(event)
|
||||||
return expect(
|
return expect(
|
||||||
|
|||||||
76
relay.ts
76
relay.ts
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {Event, verifySignature, validateEvent} from './event'
|
import {Event, verifySignature, validateEvent} from './event'
|
||||||
import {Filter, matchFilters} from './filter'
|
import {Filter, matchFilters} from './filter'
|
||||||
|
import {getHex64} from './fakejson'
|
||||||
|
|
||||||
type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'
|
type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'
|
||||||
|
|
||||||
@@ -31,15 +32,21 @@ type SubscriptionOptions = {
|
|||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relayInit(url: string): Relay {
|
export function relayInit(
|
||||||
|
url: string,
|
||||||
|
alreadyHaveEvent: (id: string) => boolean = () => false
|
||||||
|
): Relay {
|
||||||
var ws: WebSocket
|
var ws: WebSocket
|
||||||
var resolveClose: () => void
|
var resolveClose: () => void
|
||||||
var untilOpen: Promise<void>
|
var setOpen: (value: PromiseLike<void> | void) => void
|
||||||
|
var untilOpen = new Promise<void>(resolve => {
|
||||||
|
setOpen = resolve
|
||||||
|
})
|
||||||
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
||||||
var listeners: {
|
var listeners: {
|
||||||
connect: Array<() => void>
|
connect: Array<() => void>
|
||||||
disconnect: Array<() => void>
|
disconnect: Array<() => void>
|
||||||
error: Array<() => void>
|
error: Array<(e: globalThis.Event) => void>
|
||||||
notice: Array<(msg: string) => void>
|
notice: Array<(msg: string) => void>
|
||||||
} = {
|
} = {
|
||||||
connect: [],
|
connect: [],
|
||||||
@@ -67,30 +74,48 @@ export function relayInit(url: string): Relay {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
listeners.connect.forEach(cb => cb())
|
listeners.connect.forEach(cb => cb())
|
||||||
|
setOpen()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
ws.onerror = () => {
|
ws.onerror = (e: globalThis.Event) => {
|
||||||
listeners.error.forEach(cb => cb())
|
listeners.error.forEach(cb => cb(e))
|
||||||
reject()
|
reject(e)
|
||||||
}
|
}
|
||||||
ws.onclose = async () => {
|
ws.onclose = async () => {
|
||||||
listeners.disconnect.forEach(cb => cb())
|
listeners.disconnect.forEach(cb => cb())
|
||||||
resolveClose && resolveClose()
|
resolveClose && resolveClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = async e => {
|
let incomingMessageQueue: string[] = []
|
||||||
var data
|
let handleNextInterval: any
|
||||||
try {
|
|
||||||
data = JSON.parse(e.data)
|
ws.onmessage = e => {
|
||||||
} catch (err) {
|
incomingMessageQueue.push(e.data)
|
||||||
data = e.data
|
if (!handleNextInterval) {
|
||||||
|
handleNextInterval = setInterval(handleNext, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (incomingMessageQueue.length === 0) {
|
||||||
|
clearInterval(handleNextInterval)
|
||||||
|
handleNextInterval = null
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length >= 1) {
|
var json = incomingMessageQueue.shift()
|
||||||
|
if (!json || alreadyHaveEvent(getHex64(json, 'id'))) {
|
||||||
|
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]) {
|
switch (data[0]) {
|
||||||
case 'EVENT':
|
case 'EVENT':
|
||||||
if (data.length !== 3) return // ignore empty or malformed EVENT
|
|
||||||
|
|
||||||
let id = data[1]
|
let id = data[1]
|
||||||
let event = data[2]
|
let event = data[2]
|
||||||
if (
|
if (
|
||||||
@@ -104,13 +129,11 @@ export function relayInit(url: string): Relay {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
case 'EOSE': {
|
case 'EOSE': {
|
||||||
if (data.length !== 2) return // ignore empty or malformed EOSE
|
|
||||||
let id = data[1]
|
let id = data[1]
|
||||||
;(subListeners[id]?.eose || []).forEach(cb => cb())
|
;(subListeners[id]?.eose || []).forEach(cb => cb())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'OK': {
|
case 'OK': {
|
||||||
if (data.length < 3) return // ignore empty or malformed OK
|
|
||||||
let id: string = data[1]
|
let id: string = data[1]
|
||||||
let ok: boolean = data[2]
|
let ok: boolean = data[2]
|
||||||
let reason: string = data[3] || ''
|
let reason: string = data[3] || ''
|
||||||
@@ -119,11 +142,12 @@ export function relayInit(url: string): Relay {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'NOTICE':
|
case 'NOTICE':
|
||||||
if (data.length !== 2) return // ignore empty or malformed NOTICE
|
|
||||||
let notice = data[1]
|
let notice = data[1]
|
||||||
listeners.notice.forEach(cb => cb(notice))
|
listeners.notice.forEach(cb => cb(notice))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -138,7 +162,11 @@ export function relayInit(url: string): Relay {
|
|||||||
let msg = JSON.stringify(params)
|
let msg = JSON.stringify(params)
|
||||||
|
|
||||||
await untilOpen
|
await untilOpen
|
||||||
ws.send(msg)
|
try {
|
||||||
|
ws.send(msg)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = (
|
const sub = (
|
||||||
@@ -186,19 +214,13 @@ export function relayInit(url: string): Relay {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
sub,
|
sub,
|
||||||
on: (
|
on: (type: RelayEvent, cb: any): void => {
|
||||||
type: RelayEvent,
|
|
||||||
cb: any
|
|
||||||
): void => {
|
|
||||||
listeners[type].push(cb)
|
listeners[type].push(cb)
|
||||||
if (type === 'connect' && ws?.readyState === 1) {
|
if (type === 'connect' && ws?.readyState === 1) {
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
off: (
|
off: (type: RelayEvent, cb: any): void => {
|
||||||
type: RelayEvent,
|
|
||||||
cb: any
|
|
||||||
): void => {
|
|
||||||
let index = listeners[type].indexOf(cb)
|
let index = listeners[type].indexOf(cb)
|
||||||
if (index !== -1) listeners[type].splice(index, 1)
|
if (index !== -1) listeners[type].splice(index, 1)
|
||||||
},
|
},
|
||||||
|
|||||||
183
utils.test.js
Normal file
183
utils.test.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
const {utils} = require('./lib/nostr.cjs')
|
||||||
|
|
||||||
|
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
|
||||||
|
|
||||||
|
describe('inserting into a desc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0 = []
|
||||||
|
expect(
|
||||||
|
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
|
||||||
|
).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [{created_at: 20}, {created_at: 10}]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 30},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 10},
|
||||||
|
{created_at: 1}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 15
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 10}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 5
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 10}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 10, id: 'abc'}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inserting into a asc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0 = []
|
||||||
|
expect(
|
||||||
|
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
|
||||||
|
).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [{created_at: 10}, {created_at: 20}]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 1
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 10},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 30},
|
||||||
|
{created_at: 40}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 25
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 40}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 50
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 30}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 20},
|
||||||
|
{created_at: 30, id: 'abc'}
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(list0, {
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30
|
||||||
|
})
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
95
utils.ts
95
utils.ts
@@ -1,2 +1,97 @@
|
|||||||
|
import {Event} from './event'
|
||||||
|
|
||||||
export const utf8Decoder = new TextDecoder('utf-8')
|
export const utf8Decoder = new TextDecoder('utf-8')
|
||||||
export const utf8Encoder = new TextEncoder()
|
export const utf8Encoder = new TextEncoder()
|
||||||
|
|
||||||
|
//
|
||||||
|
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
||||||
|
//
|
||||||
|
export function insertEventIntoDescendingList(
|
||||||
|
sortedArray: Event[],
|
||||||
|
event: Event
|
||||||
|
) {
|
||||||
|
let start = 0
|
||||||
|
let end = sortedArray.length - 1
|
||||||
|
let midPoint
|
||||||
|
let position = start
|
||||||
|
|
||||||
|
if (end < 0) {
|
||||||
|
position = 0
|
||||||
|
} else if (event.created_at < sortedArray[end].created_at) {
|
||||||
|
position = end + 1
|
||||||
|
} else if (event.created_at >= sortedArray[start].created_at) {
|
||||||
|
position = start
|
||||||
|
} else
|
||||||
|
while (true) {
|
||||||
|
if (end <= start + 1) {
|
||||||
|
position = end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
midPoint = Math.floor(start + (end - start) / 2)
|
||||||
|
if (sortedArray[midPoint].created_at > event.created_at) {
|
||||||
|
start = midPoint
|
||||||
|
} else if (sortedArray[midPoint].created_at < event.created_at) {
|
||||||
|
end = midPoint
|
||||||
|
} else {
|
||||||
|
// aMidPoint === num
|
||||||
|
position = midPoint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert when num is NOT already in (no duplicates)
|
||||||
|
if (sortedArray[position]?.id !== event.id) {
|
||||||
|
return [
|
||||||
|
...sortedArray.slice(0, position),
|
||||||
|
event,
|
||||||
|
...sortedArray.slice(position)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventIntoAscendingList(
|
||||||
|
sortedArray: Event[],
|
||||||
|
event: Event
|
||||||
|
) {
|
||||||
|
let start = 0
|
||||||
|
let end = sortedArray.length - 1
|
||||||
|
let midPoint
|
||||||
|
let position = start
|
||||||
|
|
||||||
|
if (end < 0) {
|
||||||
|
position = 0
|
||||||
|
} else if (event.created_at > sortedArray[end].created_at) {
|
||||||
|
position = end + 1
|
||||||
|
} else if (event.created_at <= sortedArray[start].created_at) {
|
||||||
|
position = start
|
||||||
|
} else
|
||||||
|
while (true) {
|
||||||
|
if (end <= start + 1) {
|
||||||
|
position = end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
midPoint = Math.floor(start + (end - start) / 2)
|
||||||
|
if (sortedArray[midPoint].created_at < event.created_at) {
|
||||||
|
start = midPoint
|
||||||
|
} else if (sortedArray[midPoint].created_at > event.created_at) {
|
||||||
|
end = midPoint
|
||||||
|
} else {
|
||||||
|
// aMidPoint === num
|
||||||
|
position = midPoint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert when num is NOT already in (no duplicates)
|
||||||
|
if (sortedArray[position]?.id !== event.id) {
|
||||||
|
return [
|
||||||
|
...sortedArray.slice(0, position),
|
||||||
|
event,
|
||||||
|
...sortedArray.slice(position)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user