nostr package part 1

This commit is contained in:
ennmichael 2023-02-15 21:46:05 +01:00
parent 20f2f40549
commit 0193436d96
No known key found for this signature in database
GPG Key ID: 6E6E183431A26AF7
23 changed files with 511 additions and 86 deletions

View File

@ -9,5 +9,8 @@
},
"devDependencies": {
"typescript": "^4.9.5"
},
"prettier": {
"semi": false
}
}

View File

@ -0,0 +1,9 @@
/**
* An error in the protocol. This error is thrown when a relay sends invalid or
* unexpected data, or otherwise behaves in an unexpected way.
*/
export class ProtocolError extends Error {
constructor(message?: string) {
super(message)
}
}

174
packages/nostr/src/event.ts Normal file
View File

@ -0,0 +1,174 @@
import { ProtocolError } from "./error"
import { RawEvent } from "./raw"
import * as secp from "@noble/secp256k1"
import { PublicKey } from "./keypair"
import { parseHex } from "./util"
// TODO This file is missing proper documentation
// TODO Add remaining event types
export enum EventKind {
SetMetadata = 0, // NIP-01
TextNote = 1, // NIP-01
RecommendServer = 2, // NIP-01
ContactList = 3, // NIP-02
DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
Relays = 10002, // NIP-65
Auth = 22242, // NIP-42
PubkeyLists = 30000, // NIP-51a
NoteLists = 30001, // NIP-51b
TagLists = 30002, // NIP-51c
ZapRequest = 9734, // NIP 57
ZapReceipt = 9735, // NIP 57
}
export type Event = SetMetadataEvent | TextNoteEvent | UnknownEvent
interface EventCommon {
pubkey: PublicKey
createdAt: Date
}
export interface SetMetadataEvent extends EventCommon {
kind: EventKind.SetMetadata
userMetadata: UserMetadata
}
export interface UserMetadata {
name: string
about: string
picture: string
}
export interface TextNoteEvent extends EventCommon {
kind: EventKind.TextNote
note: string
}
export interface UnknownEvent extends EventCommon {
kind: Exclude<EventKind, EventKind.SetMetadata | EventKind.TextNote>
}
export async function createEvent(raw: RawEvent): Promise<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)
)
}
function createSetMetadataEvent(
raw: RawEvent,
event: EventCommon
): SetMetadataEvent | undefined {
if (raw.kind !== EventKind.SetMetadata) {
return undefined
}
const userMetadata = parseJson(raw.content)
if (
typeof userMetadata["name"] !== "string" ||
typeof userMetadata["about"] !== "string" ||
typeof userMetadata["picture"] !== "string"
) {
throw new ProtocolError(`invalid user metadata: ${userMetadata}`)
}
return {
...event,
kind: EventKind.SetMetadata,
userMetadata,
}
}
function createTextNodeEvent(
raw: RawEvent,
event: EventCommon
): TextNoteEvent | undefined {
if (raw.kind !== EventKind.TextNote) {
return undefined
}
return {
...event,
kind: EventKind.TextNote,
note: raw.content,
}
}
function createUnknownEvent(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
}
}
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}")
}
}
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)
} catch (e) {
throw new ProtocolError(`invalid json: ${data}`)
}
}
function* charCodes(data: string): Iterable<number> {
for (let i = 0; i < 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)
}
}

View File

@ -1,87 +1,3 @@
export * from "./System";
export * from "./Connection";
export { default as EventKind } from "./EventKind";
export { Subscriptions } from "./Subscriptions";
export { default as Event } from "./Event";
export { default as Tag } from "./Tag";
export * from "./Links";
export * from "./legacy"
export type RawEvent = {
id: u256;
pubkey: HexKey;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
export interface TaggedRawEvent extends RawEvent {
/**
* A list of relays this event was seen on
*/
relays: string[];
}
/**
* Basic raw key as hex
*/
export type HexKey = string;
/**
* Optional HexKey
*/
export type MaybeHexKey = HexKey | undefined;
/**
* A 256bit hex id
*/
export type u256 = string;
/**
* Raw REQ filter object
*/
export type RawReqFilter = {
ids?: u256[];
authors?: u256[];
kinds?: number[];
"#e"?: u256[];
"#p"?: u256[];
"#t"?: string[];
"#d"?: string[];
"#r"?: string[];
search?: string;
since?: number;
until?: number;
limit?: number;
};
/**
* Medatadata event content
*/
export type UserMetadata = {
name?: string;
display_name?: string;
about?: string;
picture?: string;
website?: string;
banner?: string;
nip05?: string;
lud06?: string;
lud16?: string;
};
/**
* NIP-51 list types
*/
export enum Lists {
Muted = "mute",
Pinned = "pin",
Bookmarked = "bookmark",
Followed = "follow",
}
export interface FullRelaySettings {
url: string;
settings: { read: boolean; write: boolean };
}
// TODO This file should only contain re-exports and only re-export what is needed

View File

@ -0,0 +1,38 @@
import { ProtocolError } from "./error"
import { parseHex } from "./util"
/**
* A 32-byte secp256k1 public key.
*/
export class PublicKey {
#hex: string
constructor(hex: string | Uint8Array) {
this.#hex = parseHex(hex)
if (this.#hex.length !== 64) {
throw new ProtocolError(`invalid pubkey: ${hex}`)
}
}
toString(): string {
return this.#hex
}
}
/**
* A 32-byte secp256k1 private key.
*/
export class PrivateKey {
#hex: string
constructor(hex: string | Uint8Array) {
this.#hex = parseHex(hex)
if (this.#hex.length !== 64) {
throw new ProtocolError(`invalid private key: ${this.#hex}`)
}
}
toString(): string {
return this.#hex
}
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,87 @@
export * from "./System";
export * from "./Connection";
export { default as EventKind } from "./EventKind";
export { Subscriptions } from "./Subscriptions";
export { default as Event } from "./Event";
export { default as Tag } from "./Tag";
export * from "./Links";
export type RawEvent = {
id: u256;
pubkey: HexKey;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
export interface TaggedRawEvent extends RawEvent {
/**
* A list of relays this event was seen on
*/
relays: string[];
}
/**
* Basic raw key as hex
*/
export type HexKey = string;
/**
* Optional HexKey
*/
export type MaybeHexKey = HexKey | undefined;
/**
* A 256bit hex id
*/
export type u256 = string;
/**
* Raw REQ filter object
*/
export type RawReqFilter = {
ids?: u256[];
authors?: u256[];
kinds?: number[];
"#e"?: u256[];
"#p"?: u256[];
"#t"?: string[];
"#d"?: string[];
"#r"?: string[];
search?: string;
since?: number;
until?: number;
limit?: number;
};
/**
* Medatadata event content
*/
export type UserMetadata = {
name?: string;
display_name?: string;
about?: string;
picture?: string;
website?: string;
banner?: string;
nip05?: string;
lud06?: string;
lud16?: string;
};
/**
* NIP-51 list types
*/
export enum Lists {
Muted = "mute",
Pinned = "pin",
Bookmarked = "bookmark",
Followed = "follow",
}
export interface FullRelaySettings {
url: string;
settings: { read: boolean; write: boolean };
}

134
packages/nostr/src/nostr.ts Normal file
View File

@ -0,0 +1,134 @@
import { ProtocolError } from "./error"
import { EventId, Event } from "./event"
import { RawEvent } from "./raw"
/**
* A nostr client.
*/
export class Nostr {
/**
* Open connections to relays.
*/
#conns: Conn[] = []
/**
* Is this client closed?
*/
#closed: boolean = false
/**
* Mapping of subscription IDs to corresponding filters.
*/
#subscriptions: Map<string, Filters> = new Map()
#eventCallbacks: EventCallback[] = []
#noticeCallbacks: NoticeCallback[] = []
#errorCallbacks: ErrorCallback[] = []
/**
* Add a new callback for received events.
*/
onEvent(cb: EventCallback): void {
this.#eventCallbacks.push(cb)
}
/**
* Add a new callback for received notices.
*/
onNotice(cb: NoticeCallback): void {
this.#noticeCallbacks.push(cb)
}
/**
* Add a new callback for errors.
*/
onError(cb: ErrorCallback): void {
this.#errorCallbacks.push(cb)
}
/**
* Connect and start communicating with a relay. This method recreates all existing
* subscriptions on the new relay as well.
*/
async connect(relay: URL | string): Promise<void> {
this.#checkClosed()
throw new Error("todo try to connect and send subscriptions")
}
/**
* Create a new subscription.
*
* @param subscriptionId An optional subscription ID, otherwise a random subscription ID will be used.
* @returns The subscription ID.
*/
async subscribe(
filters: Filters,
subscriptionId?: SubscriptionId | string
): Promise<SubscriptionId> {
this.#checkClosed()
throw new Error("todo subscribe to the relays and add the subscription")
}
/**
* Remove a subscription.
*/
async unsubscribe(subscriptionId: SubscriptionId): Promise<SubscriptionId> {
this.#checkClosed()
throw new Error(
"todo unsubscribe from the relays and remove the subscription"
)
}
/**
* Publish an event.
*/
async publish(event: Event): Promise<void> {
this.#checkClosed()
throw new Error("todo")
}
/**
* Close connections to all relays. This method can only be called once. After the
* connections have been closed, no other methods can be called.
*/
async close(): Promise<void> {}
#checkClosed() {
if (this.#closed) {
throw new Error("the client has been closed")
}
}
}
/**
* A string uniquely identifying a client subscription.
*/
export class SubscriptionId {
#id: string
constructor(subscriptionId: string) {
this.#id = subscriptionId
}
toString() {
return this.#id
}
}
/**
* Subscription filters.
*/
export interface Filters {}
export type EventCallback = (params: EventParams, nostr: Nostr) => unknown
export type NoticeCallback = (notice: string, nostr: Nostr) => unknown
export type ErrorCallback = (error: ProtocolError, nostr: Nostr) => unknown
export interface EventParams {
event: Event
id: EventId
raw: RawEvent
}
/**
* The connection to a relay.
*/
class Conn {}

41
packages/nostr/src/raw.ts Normal file
View File

@ -0,0 +1,41 @@
import { ProtocolError } from "./error"
/**
* Raw event to be transferred over the wire.
*/
export interface RawEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}
export 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}`)
}
}

View File

@ -0,0 +1,22 @@
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()
}
}