nostr package part 3
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
import { ProtocolError } from "../error"
|
||||
import { Filters, SubscriptionId } from "."
|
||||
import { formatOutgoingMessage, parseIncomingMessage, RawEvent } from "../raw"
|
||||
import { Event } from "../event"
|
||||
import { RawEvent, SignedEvent } from "../event"
|
||||
import WebSocket from "ws"
|
||||
|
||||
/**
|
||||
* The connection to a relay. This is the lowest layer of the nostr protocol.
|
||||
@ -23,8 +23,8 @@ export class Conn {
|
||||
// different, and the NIP-44 stuff should be handled by Nostr.
|
||||
#pending: OutgoingMessage[] = []
|
||||
|
||||
readonly #msgCallbacks: IncomingMessageCallback[] = []
|
||||
readonly #errorCallbacks: ConnErrorCallback[] = []
|
||||
#msgCallback?: IncomingMessageCallback
|
||||
#errorCallback?: ErrorCallback
|
||||
|
||||
get url(): string {
|
||||
return this.#socket.url
|
||||
@ -34,27 +34,22 @@ export class Conn {
|
||||
this.#socket = new WebSocket(endpoint)
|
||||
|
||||
// Handle incoming messages.
|
||||
this.#socket.addEventListener("message", (msgData) => {
|
||||
this.#socket.addEventListener("message", async (msgData) => {
|
||||
const value = msgData.data.valueOf()
|
||||
// Validate and parse the message.
|
||||
if (typeof value !== "string") {
|
||||
const err = new ProtocolError(`invalid message data: ${value}`)
|
||||
for (const cb of this.#errorCallbacks) {
|
||||
cb(err)
|
||||
}
|
||||
this.#errorCallback?.(err)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const msg = parseIncomingMessage(value)
|
||||
for (const cb of this.#msgCallbacks) {
|
||||
cb(msg)
|
||||
}
|
||||
const msg = await parseIncomingMessage(value)
|
||||
this.#msgCallback?.(msg)
|
||||
} catch (err) {
|
||||
if (err instanceof ProtocolError) {
|
||||
for (const cb of this.#errorCallbacks) {
|
||||
cb(err)
|
||||
}
|
||||
this.#errorCallback?.(err)
|
||||
} else {
|
||||
// TODO Not sure if this is the case?
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@ -69,12 +64,16 @@ export class Conn {
|
||||
})
|
||||
}
|
||||
|
||||
onMessage(cb: IncomingMessageCallback): void {
|
||||
this.#msgCallbacks.push(cb)
|
||||
}
|
||||
|
||||
onError(cb: ConnErrorCallback): void {
|
||||
this.#errorCallbacks.push(cb)
|
||||
on(on: "message", cb: IncomingMessageCallback): void
|
||||
on(on: "error", cb: ErrorCallback): void
|
||||
on(on: "message" | "error", cb: IncomingMessageCallback | ErrorCallback) {
|
||||
if (on === "message") {
|
||||
this.#msgCallback = cb as IncomingMessageCallback
|
||||
} else if (on === "error") {
|
||||
this.#errorCallback = cb as ErrorCallback
|
||||
} else {
|
||||
throw new Error(`unexpected input: ${on}`)
|
||||
}
|
||||
}
|
||||
|
||||
send(msg: OutgoingMessage): void {
|
||||
@ -82,7 +81,7 @@ export class Conn {
|
||||
this.#pending.push(msg)
|
||||
return
|
||||
}
|
||||
this.#socket.send(formatOutgoingMessage(msg))
|
||||
this.#socket.send(serializeOutgoingMessage(msg))
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@ -106,7 +105,7 @@ export const enum IncomingKind {
|
||||
export interface IncomingEvent {
|
||||
kind: IncomingKind.Event
|
||||
subscriptionId: SubscriptionId
|
||||
event: Event
|
||||
signed: SignedEvent
|
||||
raw: RawEvent
|
||||
}
|
||||
|
||||
@ -137,7 +136,7 @@ export const enum OutgoingKind {
|
||||
*/
|
||||
export interface OutgoingEvent {
|
||||
kind: OutgoingKind.Event
|
||||
event: Event
|
||||
signed: SignedEvent
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,7 +145,7 @@ export interface OutgoingEvent {
|
||||
export interface OutgoingSubscription {
|
||||
kind: OutgoingKind.Subscription
|
||||
id: SubscriptionId
|
||||
filters: Filters[]
|
||||
filters: Filters
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,4 +157,123 @@ export interface OutgoingUnsubscription {
|
||||
}
|
||||
|
||||
type IncomingMessageCallback = (message: IncomingMessage) => unknown
|
||||
type ConnErrorCallback = (error: ProtocolError) => unknown
|
||||
type ErrorCallback = (error: ProtocolError) => unknown
|
||||
|
||||
interface RawFilters {
|
||||
ids?: string[]
|
||||
authors?: string[]
|
||||
kinds?: number[]
|
||||
["#e"]?: string[]
|
||||
["#p"]?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
async function parseIncomingMessage(data: string): Promise<IncomingMessage> {
|
||||
const json = parseJson(data)
|
||||
if (!(json instanceof Array)) {
|
||||
throw new ProtocolError(`incoming message is not an array: ${data}`)
|
||||
}
|
||||
if (json.length === 3) {
|
||||
if (json[0] !== "EVENT") {
|
||||
throw new ProtocolError(`expected "EVENT" message, but got: ${data}`)
|
||||
}
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new ProtocolError(
|
||||
`second element of "EVENT" should be a string, but wasn't: ${data}`
|
||||
)
|
||||
}
|
||||
if (typeof json[2] !== "object") {
|
||||
throw new ProtocolError(
|
||||
`second element of "EVENT" should be an object, but wasn't: ${data}`
|
||||
)
|
||||
}
|
||||
const raw = parseEventData(json[2])
|
||||
return {
|
||||
kind: IncomingKind.Event,
|
||||
subscriptionId: new SubscriptionId(json[1]),
|
||||
signed: await SignedEvent.verify(raw),
|
||||
raw,
|
||||
}
|
||||
} else if (json.length === 2) {
|
||||
if (json[0] !== "NOTICE") {
|
||||
throw new ProtocolError(`expected "NOTICE" message, but got: ${data}`)
|
||||
}
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new ProtocolError(
|
||||
`second element of "NOTICE" should be a string, but wasn't: ${data}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: IncomingKind.Notice,
|
||||
notice: json[1],
|
||||
}
|
||||
} else {
|
||||
throw new ProtocolError(
|
||||
`incoming message has unexpected number of elements: ${data}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeOutgoingMessage(msg: OutgoingMessage): string {
|
||||
if (msg.kind === OutgoingKind.Event) {
|
||||
return JSON.stringify(["EVENT", msg.signed.serialize()])
|
||||
} else if (msg.kind === OutgoingKind.Subscription) {
|
||||
return JSON.stringify([
|
||||
"REQ",
|
||||
msg.id.toString(),
|
||||
serializeFilters(msg.filters),
|
||||
])
|
||||
} else if (msg.kind === OutgoingKind.Unsubscription) {
|
||||
return JSON.stringify(["CLOSE", msg.id.toString()])
|
||||
} else {
|
||||
throw new Error(`invalid message: ${JSON.stringify(msg)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFilters(filters: Filters): RawFilters {
|
||||
return {
|
||||
ids: filters.ids?.map((id) => id.toString()),
|
||||
authors: filters.authors?.map((author) => author.toString()),
|
||||
kinds: filters.kinds?.map((kind) => kind),
|
||||
["#e"]: filters.eventTags?.map((e) => e.toString()),
|
||||
["#p"]: filters.pubkeyTags?.map((p) => p.toString()),
|
||||
// TODO The Math.floor has been repeated too many times at this point, have a unix timestamp function in event.ts
|
||||
since:
|
||||
filters.since !== undefined
|
||||
? Math.floor(filters.since.getTime() / 1000)
|
||||
: undefined,
|
||||
until:
|
||||
filters.until !== undefined
|
||||
? Math.floor(filters.until.getTime() / 1000)
|
||||
: undefined,
|
||||
limit: filters.limit,
|
||||
}
|
||||
}
|
||||
|
||||
function parseEventData(json: object): RawEvent {
|
||||
if (
|
||||
typeof json["id"] !== "string" ||
|
||||
typeof json["pubkey"] !== "string" ||
|
||||
typeof json["created_at"] !== "number" ||
|
||||
typeof json["kind"] !== "number" ||
|
||||
!(json["tags"] instanceof Array) ||
|
||||
!json["tags"].every(
|
||||
(x) => x instanceof Array && x.every((y) => typeof y === "string")
|
||||
) ||
|
||||
typeof json["content"] !== "string" ||
|
||||
typeof json["sig"] !== "string"
|
||||
) {
|
||||
throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`)
|
||||
}
|
||||
return json as RawEvent
|
||||
}
|
||||
|
||||
function parseJson(data: string) {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new ProtocolError(`invalid event json: ${data}`)
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,8 @@
|
||||
import { ProtocolError } from "../error"
|
||||
import {
|
||||
EventId,
|
||||
Event,
|
||||
serializeId as serializeEventId,
|
||||
EventKind,
|
||||
} from "../event"
|
||||
import { PublicKey } from "../keypair"
|
||||
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
|
||||
import { PrivateKey, PublicKey } from "../keypair"
|
||||
import { Conn, IncomingKind, OutgoingKind } from "./conn"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { RawEvent } from "../raw"
|
||||
|
||||
/**
|
||||
* A nostr client.
|
||||
@ -35,36 +29,44 @@ export class Nostr {
|
||||
*/
|
||||
write: boolean
|
||||
}
|
||||
>
|
||||
> = new Map()
|
||||
|
||||
/**
|
||||
* Mapping of subscription IDs to corresponding filters.
|
||||
*/
|
||||
readonly #subscriptions: Map<string, Filters[]> = new Map()
|
||||
readonly #subscriptions: Map<string, Filters> = new Map()
|
||||
|
||||
readonly #eventCallbacks: EventCallback[] = []
|
||||
readonly #noticeCallbacks: NoticeCallback[] = []
|
||||
readonly #errorCallbacks: ErrorCallback[] = []
|
||||
#eventCallback?: EventCallback
|
||||
#noticeCallback?: NoticeCallback
|
||||
#errorCallback?: ErrorCallback
|
||||
|
||||
/**
|
||||
* Add a new callback for received events.
|
||||
*/
|
||||
onEvent(cb: EventCallback): void {
|
||||
this.#eventCallbacks.push(cb)
|
||||
}
|
||||
|
||||
on(on: "event", cb: EventCallback | undefined | null): void
|
||||
/**
|
||||
* Add a new callback for received notices.
|
||||
*/
|
||||
onNotice(cb: NoticeCallback): void {
|
||||
this.#noticeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
on(on: "notice", cb: NoticeCallback | undefined | null): void
|
||||
/**
|
||||
* Add a new callback for errors.
|
||||
*/
|
||||
onError(cb: ErrorCallback): void {
|
||||
this.#errorCallbacks.push(cb)
|
||||
on(on: "error", cb: ErrorCallback | undefined | null): void
|
||||
// TODO Also add on: ("subscribed", subscriptionId) which checks "OK"/"NOTICE" and makes a callback?
|
||||
// TODO Also add on: ("sent", eventId) which checks "OK"/"NOTICE" and makes a callback?
|
||||
on(
|
||||
on: "event" | "notice" | "error",
|
||||
cb: EventCallback | NoticeCallback | ErrorCallback | undefined | null
|
||||
) {
|
||||
if (on === "event") {
|
||||
this.#eventCallback = (cb as EventCallback) ?? undefined
|
||||
} else if (on === "notice") {
|
||||
this.#noticeCallback = (cb as NoticeCallback) ?? undefined
|
||||
} else if (on === "error") {
|
||||
this.#errorCallback = (cb as ErrorCallback) ?? undefined
|
||||
} else {
|
||||
throw new Error(`unexpected input: ${on}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,7 +75,7 @@ export class Nostr {
|
||||
* this method will only update it with the new options, and an exception will be thrown
|
||||
* if no options are specified.
|
||||
*/
|
||||
connect(url: URL | string, opts?: { read?: boolean; write?: boolean }): void {
|
||||
open(url: URL | string, opts?: { read?: boolean; write?: boolean }): void {
|
||||
// If the connection already exists, update the options.
|
||||
const existingConn = this.#conns.get(url.toString())
|
||||
if (existingConn !== undefined) {
|
||||
@ -95,36 +97,27 @@ export class Nostr {
|
||||
const conn = new Conn(url)
|
||||
|
||||
// Handle messages on this connection.
|
||||
conn.onMessage(async (msg) => {
|
||||
conn.on("message", async (msg) => {
|
||||
if (msg.kind === IncomingKind.Event) {
|
||||
for (const cb of this.#eventCallbacks) {
|
||||
cb(
|
||||
{
|
||||
event: msg.event,
|
||||
eventId: await serializeEventId(msg.raw),
|
||||
subscriptionId: msg.subscriptionId,
|
||||
raw: msg.raw,
|
||||
},
|
||||
this
|
||||
)
|
||||
}
|
||||
this.#eventCallback?.(
|
||||
{
|
||||
signed: msg.signed,
|
||||
subscriptionId: msg.subscriptionId,
|
||||
raw: msg.raw,
|
||||
},
|
||||
this
|
||||
)
|
||||
} else if (msg.kind === IncomingKind.Notice) {
|
||||
for (const cb of this.#noticeCallbacks) {
|
||||
cb(msg.notice, this)
|
||||
}
|
||||
this.#noticeCallback?.(msg.notice, this)
|
||||
} else {
|
||||
const err = new ProtocolError(`invalid message ${msg}`)
|
||||
for (const cb of this.#errorCallbacks) {
|
||||
cb(err, this)
|
||||
}
|
||||
this.#errorCallback?.(err, this)
|
||||
}
|
||||
})
|
||||
|
||||
// Forward connection errors to the error callbacks.
|
||||
conn.onError((err) => {
|
||||
for (const cb of this.#errorCallbacks) {
|
||||
cb(err, this)
|
||||
}
|
||||
conn.on("error", (err) => {
|
||||
this.#errorCallback?.(err, this)
|
||||
})
|
||||
|
||||
// Resend existing subscriptions to this connection.
|
||||
@ -146,12 +139,23 @@ export class Nostr {
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a relay. If there is no open connection to this relay, an exception is thrown.
|
||||
* Disconnect from relays.
|
||||
*
|
||||
* @param url If specified, only close the connection to this relay. If the connection does
|
||||
* not exist, an exception will be thrown. If this parameter is not specified, all connections
|
||||
* will be closed.
|
||||
*
|
||||
* TODO There needs to be a way to check connection state. isOpen(), isReady(), isClosing() maybe?
|
||||
* Because of how WebSocket states work this isn't as simple as it seems.
|
||||
*/
|
||||
disconnect(url: URL | string): void {
|
||||
close(url?: URL | string): void {
|
||||
if (url === undefined) {
|
||||
for (const { conn } of this.#conns.values()) {
|
||||
conn.close()
|
||||
}
|
||||
this.#conns.clear()
|
||||
return
|
||||
}
|
||||
const c = this.#conns.get(url.toString())
|
||||
if (c === undefined) {
|
||||
throw new Error(`connection to ${url} doesn't exist`)
|
||||
@ -175,10 +179,9 @@ export class Nostr {
|
||||
* @returns The subscription ID.
|
||||
*/
|
||||
subscribe(
|
||||
filters: Filters[],
|
||||
subscriptionId?: SubscriptionId
|
||||
filters: Filters = {},
|
||||
subscriptionId: SubscriptionId = SubscriptionId.random()
|
||||
): SubscriptionId {
|
||||
subscriptionId ??= SubscriptionId.random()
|
||||
this.#subscriptions.set(subscriptionId.toString(), filters)
|
||||
for (const { conn, read } of this.#conns.values()) {
|
||||
if (!read) {
|
||||
@ -216,14 +219,18 @@ export class Nostr {
|
||||
/**
|
||||
* Publish an event.
|
||||
*/
|
||||
async publish(event: Event): Promise<void> {
|
||||
async publish(event: Event, key: PrivateKey): Promise<void> {
|
||||
if (event.pubkey.toString() !== key.pubkey.toString()) {
|
||||
throw new Error("invalid private key")
|
||||
}
|
||||
for (const { conn, write } of this.#conns.values()) {
|
||||
if (!write) {
|
||||
continue
|
||||
}
|
||||
const signed = await SignedEvent.sign(event, key)
|
||||
conn.send({
|
||||
kind: OutgoingKind.Event,
|
||||
event,
|
||||
signed: signed,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -240,7 +247,7 @@ export class SubscriptionId {
|
||||
}
|
||||
|
||||
static random(): SubscriptionId {
|
||||
return new SubscriptionId(secp.utils.randomBytes(32).toString())
|
||||
return new SubscriptionId(secp.utils.bytesToHex(secp.utils.randomBytes(32)))
|
||||
}
|
||||
|
||||
toString() {
|
||||
@ -290,8 +297,7 @@ export type NoticeCallback = (notice: string, nostr: Nostr) => unknown
|
||||
export type ErrorCallback = (error: ProtocolError, nostr: Nostr) => unknown
|
||||
|
||||
export interface EventParams {
|
||||
event: Event
|
||||
eventId: EventId
|
||||
signed: SignedEvent
|
||||
subscriptionId: SubscriptionId
|
||||
raw: RawEvent
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { ProtocolError } from "./error"
|
||||
import { RawEvent } from "./raw"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { PublicKey } from "./keypair"
|
||||
import { parseHex } from "./util"
|
||||
import { PublicKey, PrivateKey } from "./keypair"
|
||||
|
||||
// TODO This file is missing proper documentation
|
||||
// TODO Add remaining event types
|
||||
@ -52,22 +50,146 @@ export interface UnknownEvent extends EventCommon {
|
||||
kind: Exclude<EventKind, EventKind.SetMetadata | EventKind.TextNote>
|
||||
}
|
||||
|
||||
export async function createEvent(raw: RawEvent): Promise<Event> {
|
||||
// TODO Doc comment
|
||||
export class EventId {
|
||||
#hex: string
|
||||
|
||||
static async create(event: Event | RawEvent): Promise<EventId> {
|
||||
// It's not defined whether JSON.stringify produces a string with whitespace stripped.
|
||||
// Building the JSON string manually as follows ensures that there's no whitespace.
|
||||
// In hindsight using JSON as a data format for hashing and signing is not the best
|
||||
// design decision.
|
||||
if ("id" in event) {
|
||||
// Raw event.
|
||||
const serializedTags = `[${event.tags
|
||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||
.join(",")}]`
|
||||
const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]`
|
||||
const hash = await secp.utils.sha256(
|
||||
Uint8Array.from(charCodes(serialized))
|
||||
)
|
||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
||||
} else {
|
||||
// Not a raw event.
|
||||
const tags = serializeEventTags(event)
|
||||
const content = serializeEventContent(event)
|
||||
const serializedTags = `[${tags
|
||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||
.join(",")}]`
|
||||
const serialized = `[0,"${event.pubkey}",${Math.floor(
|
||||
event.createdAt.getTime() / 1000
|
||||
)},${event.kind},${serializedTags},"${content}"]`
|
||||
const hash = await secp.utils.sha256(
|
||||
Uint8Array.from(charCodes(serialized))
|
||||
)
|
||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(hex: string) {
|
||||
this.#hex = hex
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.#hex
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Document
|
||||
export class SignedEvent {
|
||||
#event: Readonly<Event>
|
||||
#eventId: EventId
|
||||
#signature: string
|
||||
|
||||
static async sign(event: Event, key: PrivateKey): Promise<SignedEvent> {
|
||||
const id = await EventId.create(event)
|
||||
const sig = secp.utils
|
||||
.bytesToHex(await secp.schnorr.sign(id.toString(), key.leak()))
|
||||
.toLowerCase()
|
||||
return new SignedEvent(event, id, sig)
|
||||
}
|
||||
|
||||
static async verify(raw: RawEvent): Promise<SignedEvent> {
|
||||
const id = await EventId.create(raw)
|
||||
if (id.toString() !== raw.id) {
|
||||
throw new ProtocolError(`invalid event id: ${raw.id}, expected ${id}`)
|
||||
}
|
||||
if (!(await secp.schnorr.verify(raw.sig, id.toString(), raw.pubkey))) {
|
||||
throw new ProtocolError(`invalid signature: ${raw.sig}`)
|
||||
}
|
||||
return new SignedEvent(parseEvent(raw), id, raw.sig)
|
||||
}
|
||||
|
||||
private constructor(event: Event, eventId: EventId, signature: string) {
|
||||
// TODO Copy the event data and document that it's being copied
|
||||
this.#event = event
|
||||
this.#eventId = eventId
|
||||
this.#signature = signature
|
||||
}
|
||||
|
||||
// TODO Document this
|
||||
get eventId(): EventId {
|
||||
return this.#eventId
|
||||
}
|
||||
|
||||
// TODO Document this
|
||||
get event(): Event {
|
||||
// TODO Copy the event data and document that it's being copied
|
||||
return this.#event
|
||||
}
|
||||
|
||||
// TODO Document this
|
||||
get signature(): string {
|
||||
return this.#signature
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the event into its raw format.
|
||||
*/
|
||||
serialize(): RawEvent {
|
||||
const { event, eventId: id, signature } = this
|
||||
const tags = serializeEventTags(event)
|
||||
const content = serializeEventContent(event)
|
||||
return {
|
||||
id: id.toString(),
|
||||
pubkey: event.pubkey.toString(),
|
||||
created_at: Math.floor(event.createdAt.getTime() / 1000),
|
||||
kind: event.kind,
|
||||
tags,
|
||||
content,
|
||||
sig: signature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an event from its raw format.
|
||||
*/
|
||||
function parseEvent(raw: RawEvent): Event {
|
||||
const pubkey = new PublicKey(raw.pubkey)
|
||||
const createdAt = new Date(raw.created_at * 1000)
|
||||
const event = {
|
||||
pubkey,
|
||||
createdAt,
|
||||
}
|
||||
await checkSignature(raw, event)
|
||||
return (
|
||||
createSetMetadataEvent(raw, event) ??
|
||||
createTextNodeEvent(raw, event) ??
|
||||
createUnknownEvent(raw, event)
|
||||
parseSetMetadataEvent(raw, event) ??
|
||||
parseTextNodeEvent(raw, event) ??
|
||||
parseUnknownEvent(raw, event)
|
||||
)
|
||||
}
|
||||
|
||||
function createSetMetadataEvent(
|
||||
function parseSetMetadataEvent(
|
||||
raw: RawEvent,
|
||||
event: EventCommon
|
||||
): SetMetadataEvent | undefined {
|
||||
@ -89,7 +211,7 @@ function createSetMetadataEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function createTextNodeEvent(
|
||||
function parseTextNodeEvent(
|
||||
raw: RawEvent,
|
||||
event: EventCommon
|
||||
): TextNoteEvent | undefined {
|
||||
@ -103,53 +225,30 @@ function createTextNodeEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function createUnknownEvent(raw: RawEvent, event: EventCommon): UnknownEvent {
|
||||
function parseUnknownEvent(raw: RawEvent, event: EventCommon): UnknownEvent {
|
||||
return {
|
||||
...event,
|
||||
kind: raw.kind,
|
||||
}
|
||||
}
|
||||
|
||||
export class EventId {
|
||||
#hex: string
|
||||
|
||||
constructor(hex: string | Uint8Array) {
|
||||
this.#hex = parseHex(hex)
|
||||
if (this.#hex.length !== 128) {
|
||||
throw new ProtocolError(`invalid event id: ${this.#hex}`)
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.#hex
|
||||
}
|
||||
function serializeEventTags(_event: Event): string[][] {
|
||||
// TODO As I add different event kinds, this will change
|
||||
return []
|
||||
}
|
||||
|
||||
async function checkSignature(
|
||||
raw: RawEvent,
|
||||
event: EventCommon
|
||||
): Promise<void> {
|
||||
const id = serializeId(raw)
|
||||
const bytes = await secp.schnorr.sign(id.toString(), event.pubkey.toString())
|
||||
const hex = secp.utils.bytesToHex(bytes).toLowerCase()
|
||||
if (hex.toString() !== raw.sig) {
|
||||
throw new ProtocolError("invalid signature: ${hex}")
|
||||
function serializeEventContent(event: Event): string {
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
// TODO Maybe rename userMetadata to content?
|
||||
return JSON.stringify(event.userMetadata)
|
||||
} else if (event.kind === EventKind.TextNote) {
|
||||
// TODO Maybe rename note to content?
|
||||
return event.note
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export async function serializeId(raw: RawEvent): Promise<EventId> {
|
||||
// It's not defined whether JSON.stringify produces a string with whitespace stripped.
|
||||
// Building the JSON string manually this way ensures that there's no whitespace.
|
||||
// In hindsight using JSON as a data format for hashing and signing is not the best
|
||||
// design decision.
|
||||
const serializedTags = `[${raw.tags
|
||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||
.join(",")}]`
|
||||
const serialized = `[0,"${raw.pubkey}",${raw.created_at},${raw.kind},${serializedTags},"${raw.content}"]`
|
||||
const hash = await secp.utils.sha256(Uint8Array.from(charCodes(serialized)))
|
||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
||||
}
|
||||
|
||||
function parseJson(data: string) {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
@ -159,16 +258,7 @@ function parseJson(data: string) {
|
||||
}
|
||||
|
||||
function* charCodes(data: string): Iterable<number> {
|
||||
for (let i = 0; i < length; i++) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
yield data.charCodeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO This is an example of how this API can be used, remove this later
|
||||
function isItNice(e: Event): void {
|
||||
if (e.kind === EventKind.SetMetadata) {
|
||||
console.log(e.userMetadata)
|
||||
} else if (e.kind === EventKind.TextNote) {
|
||||
console.log(e.note)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { bech32 } from "bech32"
|
||||
import { ProtocolError } from "./error"
|
||||
import { parseHex } from "./util"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
|
||||
/**
|
||||
* A 32-byte secp256k1 public key.
|
||||
@ -7,10 +8,13 @@ import { parseHex } from "./util"
|
||||
export class PublicKey {
|
||||
#hex: string
|
||||
|
||||
constructor(hex: string | Uint8Array) {
|
||||
this.#hex = parseHex(hex)
|
||||
/**
|
||||
* Expects the key encoded as an npub1-prefixed bech32 string, hex string, or byte buffer.
|
||||
*/
|
||||
constructor(key: string | Uint8Array) {
|
||||
this.#hex = parseKey(key, "npub1")
|
||||
if (this.#hex.length !== 64) {
|
||||
throw new ProtocolError(`invalid pubkey: ${hex}`)
|
||||
throw new ProtocolError(`invalid pubkey: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,14 +29,49 @@ export class PublicKey {
|
||||
export class PrivateKey {
|
||||
#hex: string
|
||||
|
||||
constructor(hex: string | Uint8Array) {
|
||||
this.#hex = parseHex(hex)
|
||||
/**
|
||||
* Expects the key encoded as an nsec1-prefixed bech32 string, hex string, or byte buffer.
|
||||
*/
|
||||
constructor(key: string | Uint8Array) {
|
||||
this.#hex = parseKey(key, "nsec1")
|
||||
if (this.#hex.length !== 64) {
|
||||
throw new ProtocolError(`invalid private key: ${this.#hex}`)
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
get pubkey(): PublicKey {
|
||||
return new PublicKey(secp.schnorr.getPublicKey(this.#hex))
|
||||
}
|
||||
|
||||
// TODO Document this
|
||||
leak(): string {
|
||||
return this.#hex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a key into its hex representation.
|
||||
*/
|
||||
function parseKey(key: string | Uint8Array, bechPrefix: string): string {
|
||||
if (typeof key === "string") {
|
||||
// Is the key encoded in bech32?
|
||||
if (key.startsWith(bechPrefix)) {
|
||||
const { words } = bech32.decode(key)
|
||||
const bytes = Uint8Array.from(bech32.fromWords(words))
|
||||
return secp.utils.bytesToHex(bytes).toLowerCase()
|
||||
}
|
||||
// If not, it must be lowercase hex.
|
||||
const valid = "0123456789abcdef"
|
||||
if (key.length % 2 != 0) {
|
||||
throw new ProtocolError(`invalid lowercase hex string: ${key}`)
|
||||
}
|
||||
for (const c of key) {
|
||||
if (!valid.includes(c)) {
|
||||
throw new ProtocolError(`invalid lowercase hex string: ${key}`)
|
||||
}
|
||||
}
|
||||
return key
|
||||
} else {
|
||||
return secp.utils.bytesToHex(key).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Types defining data in the format sent over the wire.
|
||||
*/
|
||||
|
||||
import { ProtocolError } from "./error"
|
||||
import { IncomingMessage, OutgoingMessage } from "./client/conn"
|
||||
|
||||
export interface RawEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
interface RawFilters {
|
||||
ids: string[]
|
||||
authors: string[]
|
||||
kinds: number[]
|
||||
["#e"]: string[]
|
||||
["#p"]: string[]
|
||||
since: number
|
||||
until: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
type RawIncomingMessage = ["EVENT", string, RawEvent] | ["NOTICE", string]
|
||||
|
||||
type RawOutgoingMessage =
|
||||
| ["EVENT", RawEvent]
|
||||
| ["REQ", string, RawFilters]
|
||||
| ["CLOSE", string]
|
||||
|
||||
export function parseIncomingMessage(msg: string): IncomingMessage {
|
||||
throw new Error("todo")
|
||||
}
|
||||
|
||||
export function formatOutgoingMessage(msg: OutgoingMessage): string {
|
||||
throw new Error("todo")
|
||||
}
|
||||
|
||||
function parseRawEvent(data: string): RawEvent {
|
||||
const json = parseJson(data)
|
||||
if (
|
||||
typeof json["id"] !== "string" ||
|
||||
typeof json["pubkey"] !== "string" ||
|
||||
typeof json["created_at"] !== "number" ||
|
||||
typeof json["kind"] !== "number" ||
|
||||
!(json["tags"] instanceof Array) ||
|
||||
!json["tags"].every(
|
||||
(x) => x instanceof Array && x.every((y) => typeof y === "string")
|
||||
) ||
|
||||
typeof json["content"] !== "string" ||
|
||||
typeof json["sig"] !== "string"
|
||||
) {
|
||||
throw new ProtocolError(`invalid event: ${data}`)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
function parseJson(data: string) {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new ProtocolError(`invalid event json: ${data}`)
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { ProtocolError } from "./error"
|
||||
|
||||
/**
|
||||
* Check that the input is a valid lowercase hex string.
|
||||
*/
|
||||
export function parseHex(hex: string | Uint8Array): string {
|
||||
if (typeof hex === "string") {
|
||||
const valid = "0123456789abcdef"
|
||||
if (hex.length % 2 != 0) {
|
||||
throw new ProtocolError(`invalid hex string: ${hex}`)
|
||||
}
|
||||
for (const c of hex) {
|
||||
if (!valid.includes(c)) {
|
||||
throw new ProtocolError(`invalid hex string: ${hex}`)
|
||||
}
|
||||
}
|
||||
return hex
|
||||
} else {
|
||||
return secp.utils.bytesToHex(hex).toLowerCase()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user