mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
new nostr-tools which abstracts all the relay activity.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
|
|||||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# nostr-tools
|
||||||
|
|
||||||
|
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {relayPool} from 'nostr-tools'
|
||||||
|
|
||||||
|
const pool = relayPool()
|
||||||
|
|
||||||
|
pool.setPrivateKey('<hex>') // optional
|
||||||
|
|
||||||
|
pool.addRelay('ws://some.relay.com', {read: true, write: true})
|
||||||
|
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
|
||||||
|
|
||||||
|
pool.onEvent((context, event, relay) => {
|
||||||
|
console.log(`got a relay with context ${context} from ${relay.url} which is already validated.`, event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// subscribing to users and requesting specific users or events:
|
||||||
|
pool.subKey('<hex>')
|
||||||
|
pool.subKey('<hex>')
|
||||||
|
pool.subKey('<hex>')
|
||||||
|
pool.reqFeed()
|
||||||
|
pool.reqEvent({id: '<hex>'})
|
||||||
|
pool.reqKey({key: '<hex>'})
|
||||||
|
// upon request the events will be received on .onEvent above
|
||||||
|
|
||||||
|
// publishing events:
|
||||||
|
pool.publish(<event object>)
|
||||||
|
// 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 keys called with .subKey above
|
||||||
|
```
|
||||||
|
|
||||||
|
For other utils please read the source (for now).
|
||||||
39
event.js
39
event.js
@@ -1,8 +1,6 @@
|
|||||||
import shajs from 'sha.js'
|
import * as secp256k1 from 'noble-secp256k1'
|
||||||
import BigInteger from 'bigi'
|
|
||||||
import schnorr from 'bip-schnorr'
|
|
||||||
|
|
||||||
import {makeRandom32} from './utils'
|
import {sha256} from './utils'
|
||||||
|
|
||||||
export function serializeEvent(evt) {
|
export function serializeEvent(evt) {
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
@@ -15,27 +13,22 @@ export function serializeEvent(evt) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventID(event) {
|
export function getEventHash(event) {
|
||||||
let hash = shajs('sha256').update(serializeEvent(event)).digest()
|
let eventHash = sha256(Buffer.from(serializeEvent(event)))
|
||||||
return hash.toString('hex')
|
return Buffer.from(eventHash).toString('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifySignature(event) {
|
export async function verifySignature(event) {
|
||||||
try {
|
return await secp256k1.schnorr.verify(
|
||||||
schnorr.verify(
|
event.signature,
|
||||||
Buffer.from(event.pubkey, 'hex'),
|
getEventHash(event),
|
||||||
Buffer.from(getEventID(event), 'hex'),
|
event.pubkey
|
||||||
Buffer.from(event.sig, 'hex')
|
)
|
||||||
)
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signEvent(event, key) {
|
export async function signEvent(event, key) {
|
||||||
let eventHash = shajs('sha256').update(serializeEvent(event)).digest()
|
let eventHash = getEventHash(event)
|
||||||
schnorr
|
return Buffer.from(await secp256k1.schnorr.sign(key, eventHash)).toString(
|
||||||
.sign(new BigInteger(key, 16), eventHash, makeRandom32())
|
'hex'
|
||||||
.toString('hex')
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
20
index.js
20
index.js
@@ -1,4 +1,16 @@
|
|||||||
export {relayConnect} from './relay'
|
import {relayConnect} from './relay'
|
||||||
export {signEvent, verifySignature, serializeEvent, getEventID} from './event'
|
import {relayPool} from './pool'
|
||||||
export {pubkeyFromPrivate} from './schnorr'
|
import {signEvent, verifySignature, serializeEvent, getEventHash} from './event'
|
||||||
export {makeRandom32} from './utils'
|
import {makeRandom32, sha256, getPublicKey} from './utils'
|
||||||
|
|
||||||
|
export {
|
||||||
|
relayConnect,
|
||||||
|
relayPool,
|
||||||
|
signEvent,
|
||||||
|
verifySignature,
|
||||||
|
serializeEvent,
|
||||||
|
getEventHash,
|
||||||
|
makeRandom32,
|
||||||
|
sha256,
|
||||||
|
getPublicKey
|
||||||
|
}
|
||||||
|
|||||||
40
package.json
40
package.json
@@ -1,13 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
|
"description": "Tools for making a Nostr client.",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/fiatjaf/nostr.git"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"assert": "^2.0.0",
|
|
||||||
"bigi": "^1.4.2",
|
|
||||||
"bip-schnorr": "^0.6.2",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"ecurve": "^1.0.6",
|
"noble-secp256k1": "^1.1.1",
|
||||||
"pws": "^5.0.2",
|
"pws": "^5.0.2"
|
||||||
"sha.js": "^2.4.11"
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepublish": "rollup -c rollup.config.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"decentralization",
|
||||||
|
"twitter",
|
||||||
|
"p2p",
|
||||||
|
"mastodon",
|
||||||
|
"ssb",
|
||||||
|
"social",
|
||||||
|
"unstoppable",
|
||||||
|
"censorship",
|
||||||
|
"censorship-resistance",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.10",
|
||||||
|
"@babel/preset-env": "^7.12.11",
|
||||||
|
"@rollup/plugin-babel": "^5.2.2",
|
||||||
|
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||||
|
"rollup": "^2.36.1",
|
||||||
|
"rollup-plugin-ignore": "^1.0.9",
|
||||||
|
"rollup-plugin-terser": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
pool.js
Normal file
142
pool.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {getEventHash, signEvent} from './event'
|
||||||
|
import {relayConnect, normalizeRelayURL} from './relay'
|
||||||
|
|
||||||
|
export function relayPool(globalPrivateKey) {
|
||||||
|
const relays = {}
|
||||||
|
const globalSub = []
|
||||||
|
const attemptCallbacks = []
|
||||||
|
const eventCallbacks = []
|
||||||
|
const noticeCallbacks = []
|
||||||
|
|
||||||
|
function propagateEvent(context, event, relayURL) {
|
||||||
|
for (let i = 0; i < eventCallbacks.length; i++) {
|
||||||
|
let {relay} = relays[relayURL]
|
||||||
|
eventCallbacks[i](context, event, relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function propagateNotice(notice, relayURL) {
|
||||||
|
for (let i = 0; i < noticeCallbacks.length; i++) {
|
||||||
|
let {relay} = relays[relayURL]
|
||||||
|
noticeCallbacks[i](notice, relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function propagateAttempt(eventId, status, relayURL) {
|
||||||
|
for (let i = 0; i < attemptCallbacks.length; i++) {
|
||||||
|
let {relay} = relays[relayURL]
|
||||||
|
attemptCallbacks[i](eventId, status, relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relaysEach(fn, policyFilter) {
|
||||||
|
for (let relayURL in relays) {
|
||||||
|
let {relay, policy} = relays[relayURL]
|
||||||
|
if (policyFilter.write && policy.write) {
|
||||||
|
await fn(relay)
|
||||||
|
} else if (policyFilter.read && policy.read) {
|
||||||
|
await fn(relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
relays,
|
||||||
|
setPrivateKey(privateKey) {
|
||||||
|
globalPrivateKey = privateKey
|
||||||
|
},
|
||||||
|
addRelay(url, policy = {read: true, write: true}) {
|
||||||
|
let relayURL = normalizeRelayURL(url)
|
||||||
|
if (relayURL in relays) return
|
||||||
|
|
||||||
|
let relay = relayConnect(
|
||||||
|
url,
|
||||||
|
(context, event) => {
|
||||||
|
propagateEvent(context, event, relayURL)
|
||||||
|
},
|
||||||
|
notice => {
|
||||||
|
propagateNotice(notice, relayURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
relays[relayURL] = {relay, policy}
|
||||||
|
|
||||||
|
// automatically subscribe to everybody on this
|
||||||
|
for (let key in globalSub) {
|
||||||
|
relay.subKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relay
|
||||||
|
},
|
||||||
|
removeRelay(url) {
|
||||||
|
let relayURL = normalizeRelayURL(url)
|
||||||
|
let {relay} = relays[relayURL]
|
||||||
|
if (!relay) return
|
||||||
|
relay.close()
|
||||||
|
delete relays[relayURL]
|
||||||
|
},
|
||||||
|
onEvent(cb) {
|
||||||
|
eventCallbacks.push(cb)
|
||||||
|
},
|
||||||
|
offEvent(cb) {
|
||||||
|
let index = eventCallbacks.indexOf(cb)
|
||||||
|
if (index !== -1) eventCallbacks.splice(index, 1)
|
||||||
|
},
|
||||||
|
onNotice(cb) {
|
||||||
|
noticeCallbacks(cb)
|
||||||
|
},
|
||||||
|
offNotice(cb) {
|
||||||
|
let index = noticeCallbacks.indexOf(cb)
|
||||||
|
if (index !== -1) noticeCallbacks.splice(index, 1)
|
||||||
|
},
|
||||||
|
onAttempt(cb) {
|
||||||
|
attemptCallbacks(cb)
|
||||||
|
},
|
||||||
|
offAttempt(cb) {
|
||||||
|
let index = attemptCallbacks.indexOf(cb)
|
||||||
|
if (index !== -1) attemptCallbacks.splice(index, 1)
|
||||||
|
},
|
||||||
|
async publish(event) {
|
||||||
|
if (!event.signature) {
|
||||||
|
event.tags = event.tags || []
|
||||||
|
|
||||||
|
if (globalPrivateKey) {
|
||||||
|
event.id = getEventHash(event)
|
||||||
|
event.signature = await signEvent(event, globalPrivateKey)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"can't publish unsigned event. either sign this event beforehand or pass a private key while initializing this relay pool so it can be signed automatically."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await relaysEach(
|
||||||
|
async relay => {
|
||||||
|
try {
|
||||||
|
await relay.publish(event)
|
||||||
|
propagateAttempt(event.id, 'sent', relay.url)
|
||||||
|
} catch (err) {
|
||||||
|
propagateAttempt(event.id, 'failed', relay.url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{write: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
},
|
||||||
|
async subKey(key) {
|
||||||
|
globalSub[key] = true
|
||||||
|
await relaysEach(async relay => relay.subKey(key), {read: true})
|
||||||
|
},
|
||||||
|
async unsubKey(key) {
|
||||||
|
delete globalSub[key]
|
||||||
|
await relaysEach(async relay => relay.unsubKey(key), {read: true})
|
||||||
|
},
|
||||||
|
async reqFeed(params = {}) {
|
||||||
|
await relaysEach(async relay => relay.reqFeed(params), {read: true})
|
||||||
|
},
|
||||||
|
async reqEvent(params) {
|
||||||
|
await relaysEach(async relay => relay.reqEvent(params), {read: true})
|
||||||
|
},
|
||||||
|
async reqKey(params) {
|
||||||
|
await relaysEach(async relay => relay.reqKey(params), {read: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
relay.js
61
relay.js
@@ -1,44 +1,81 @@
|
|||||||
import PersistentWebSocket from 'pws'
|
import PersistentWebSocket from 'pws'
|
||||||
|
import {verifySignature} from './event'
|
||||||
|
|
||||||
export function relayConnect(url, onEventCallback) {
|
export function normalizeRelayURL(url) {
|
||||||
if (url.length && url[url.length - 1] === '/') url = url.slice(0, -1)
|
let [host, ...qs] = url.split('?')
|
||||||
|
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
|
||||||
|
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
|
||||||
|
if (host.slice(-3) !== '/ws') host = host + '/ws'
|
||||||
|
return [host, ...qs].join('?')
|
||||||
|
}
|
||||||
|
|
||||||
const ws = new PersistentWebSocket(url + '/ws?session=' + Math.random(), {
|
export function relayConnect(url, onEvent, onNotice) {
|
||||||
|
url = normalizeRelayURL(url)
|
||||||
|
url = url +=
|
||||||
|
(url.indexOf('?') !== -1 ? '&' : '?') + `session=${Math.random()}`
|
||||||
|
|
||||||
|
const ws = new PersistentWebSocket(url, {
|
||||||
pingTimeout: 30 * 1000
|
pingTimeout: 30 * 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.onopen = () => console.log('connected to ', url)
|
var isOpen
|
||||||
|
let untilOpen = new Promise(resolve => {
|
||||||
|
isOpen = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('connected to ', url)
|
||||||
|
isOpen()
|
||||||
|
}
|
||||||
ws.onerror = err => console.log('error connecting', url, err)
|
ws.onerror = err => console.log('error connecting', url, err)
|
||||||
|
|
||||||
ws.onmessage = e => {
|
ws.onmessage = async e => {
|
||||||
let data = JSON.parse(e.data)
|
let data = JSON.parse(e.data)
|
||||||
if (data.length > 1) {
|
if (data.length > 1) {
|
||||||
if (data[0] === 'notice') {
|
if (data[0] === 'notice') {
|
||||||
console.log('message from relay ' + url + ' :' + data[1])
|
console.log('message from relay ' + url + ' :' + data[1])
|
||||||
|
onNotice(data[1])
|
||||||
} else if (typeof data[0] === 'object') {
|
} else if (typeof data[0] === 'object') {
|
||||||
onEventCallback(data[0], data[1])
|
let context = data[0]
|
||||||
|
let event = data[1]
|
||||||
|
|
||||||
|
if (await verifySignature(event)) {
|
||||||
|
onEvent(context, event)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'got event with invalid signature from ' + url,
|
||||||
|
event,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
subKey(key) {
|
async subKey(key) {
|
||||||
|
await untilOpen
|
||||||
ws.send('sub-key:' + key)
|
ws.send('sub-key:' + key)
|
||||||
},
|
},
|
||||||
unsubKey(key) {
|
async unsubKey(key) {
|
||||||
|
await untilOpen
|
||||||
ws.send('unsub-key:' + key)
|
ws.send('unsub-key:' + key)
|
||||||
},
|
},
|
||||||
homeFeed(params = {}) {
|
async reqFeed(params = {}) {
|
||||||
|
await untilOpen
|
||||||
ws.send('req-feed:' + JSON.stringify(params))
|
ws.send('req-feed:' + JSON.stringify(params))
|
||||||
},
|
},
|
||||||
reqEvent(params) {
|
async reqEvent(params) {
|
||||||
|
await untilOpen
|
||||||
ws.send('req-key:' + JSON.stringify(params))
|
ws.send('req-key:' + JSON.stringify(params))
|
||||||
},
|
},
|
||||||
reqKey(params) {
|
async reqKey(params) {
|
||||||
|
await untilOpen
|
||||||
ws.send('req-key:' + JSON.stringify(params))
|
ws.send('req-key:' + JSON.stringify(params))
|
||||||
},
|
},
|
||||||
sendEvent(event) {
|
async publish(event) {
|
||||||
|
await untilOpen
|
||||||
ws.send(JSON.stringify(event))
|
ws.send(JSON.stringify(event))
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
|
|||||||
26
rollup.config.js
Normal file
26
rollup.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {nodeResolve} from '@rollup/plugin-node-resolve'
|
||||||
|
import {terser} from 'rollup-plugin-terser'
|
||||||
|
import babel from '@rollup/plugin-babel'
|
||||||
|
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
|
const input = 'index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
input,
|
||||||
|
output: {
|
||||||
|
sourcemap: true,
|
||||||
|
format: 'umd',
|
||||||
|
name: 'nostr',
|
||||||
|
file: `dist/${pkg.name}.min.js`,
|
||||||
|
exports: 'named',
|
||||||
|
esModule: false
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
nodeResolve(),
|
||||||
|
babel({
|
||||||
|
babelHelpers: 'bundled'
|
||||||
|
}),
|
||||||
|
terser()
|
||||||
|
]
|
||||||
|
}
|
||||||
10
schnorr.js
10
schnorr.js
@@ -1,10 +0,0 @@
|
|||||||
import BigInteger from 'bigi'
|
|
||||||
import ecurve from 'ecurve'
|
|
||||||
|
|
||||||
const curve = ecurve.getCurveByName('secp256k1')
|
|
||||||
const G = curve.G
|
|
||||||
|
|
||||||
export function pubkeyFromPrivate(privateHex) {
|
|
||||||
const privKey = BigInteger.fromHex(privateHex)
|
|
||||||
return G.multiply(privKey).getEncoded(true).slice(1).toString('hex')
|
|
||||||
}
|
|
||||||
11
utils.js
11
utils.js
@@ -1,5 +1,6 @@
|
|||||||
export function makeRandom32() {
|
import * as secp256k1 from 'noble-secp256k1'
|
||||||
var array = new Uint32Array(32)
|
|
||||||
window.crypto.getRandomValues(array)
|
export const makeRandom32 = () => secp256k1.utils.generateRandomPrivateKey()
|
||||||
return Buffer.from(array)
|
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
|
||||||
}
|
export const getPublicKey = privateKey =>
|
||||||
|
secp256k1.schnorr.getPublicKey(privateKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user