diff --git a/package.json b/package.json index 3e46cf7..297e051 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@wisehodl/roots", "version": "0.1.0", "description": "A minimal Nostr protocol library for typescript", - "type": "./dist/types.d.ts", + "types": "./dist/types.d.ts", "main": "./dist/index.js", "exports": { ".": { diff --git a/src/event.ts b/src/event.ts index e69de29..a2d675f 100644 --- a/src/event.ts +++ b/src/event.ts @@ -0,0 +1,7 @@ +import { EventID } from "./id"; +import { Sign } from "./sign"; + +export const Event = { + ...EventID, + ...Sign, +}; diff --git a/src/id.test.ts b/src/id.test.ts index b0b0462..743eb5b 100644 --- a/src/id.test.ts +++ b/src/id.test.ts @@ -1,2 +1,220 @@ -import { test } from "vitest"; -test("placeholder", () => {}); +import { describe, test, expect } from "vitest"; +import { EventID } from "./id"; +import { testEvent, testPK } from "./util.test"; +import type { EventData } from "./types"; + +interface IDTestCase { + name: string; + event: EventData; + expected: string; +} + +const idTestCases: IDTestCase[] = [ + { + name: "minimal event", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [], + content: "", + id: "", + sig: "", + }, + expected: + "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39", + }, + { + name: "alphanumeric content", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [], + content: "hello world", + id: "", + sig: "", + }, + expected: + "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", + }, + { + name: "unicode content", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [], + content: "hello world 😀", + id: "", + sig: "", + }, + expected: + "e42083fafbf9a39f97914fd9a27cedb38c429ac3ca8814288414eaad1f472fe8", + }, + { + name: "escaped content", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [], + content: '"You say yes."\\n\\t"I say no."', + id: "", + sig: "", + }, + expected: + "343de133996a766bf00561945b6f2b2717d4905275976ca75c1d7096b7d1900c", + }, + { + name: "json content", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [], + content: '{"field": ["value","value"],"numeral": 123}', + id: "", + sig: "", + }, + expected: + "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270", + }, + { + name: "empty tag", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [["a", ""]], + content: "", + id: "", + sig: "", + }, + expected: + "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4", + }, + { + name: "single tag", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [["a", "value"]], + content: "", + id: "", + sig: "", + }, + expected: + "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe", + }, + { + name: "optional tag values", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [["a", "value", "optional"]], + content: "", + id: "", + sig: "", + }, + expected: + "656b47884200959e0c03054292c453cfc4beea00b592d92c0f557bff765e9d34", + }, + { + name: "multiple tags", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [ + ["a", "value", "optional"], + ["b", "another"], + ["c", "data"], + ], + content: "", + id: "", + sig: "", + }, + expected: + "f7c27f2eacda7ece5123a4f82db56145ba59f7c9e6c5eeb88552763664506b06", + }, + { + name: "unicode tag", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 1, + tags: [["a", "😀"]], + content: "", + id: "", + sig: "", + }, + expected: + "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986", + }, + { + name: "zero timestamp", + event: { + pubkey: testPK, + created_at: 0, + kind: 1, + tags: [], + content: "", + id: "", + sig: "", + }, + expected: + "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2", + }, + { + name: "negative timestamp", + event: { + pubkey: testPK, + created_at: -1760740551, + kind: 1, + tags: [], + content: "", + id: "", + sig: "", + }, + expected: + "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3", + }, + { + name: "max int64 timestamp", + event: { + pubkey: testPK, + created_at: 9007199254740991, + kind: 1, + tags: [], + content: "", + id: "", + sig: "", + }, + expected: + "7aa9e4bca8058ab819b6ce062efb2f8423f598bcb3d9f4b5b46b2f587b182a55", + }, + { + name: "different kind", + event: { + pubkey: testPK, + created_at: testEvent.created_at, + kind: 20021, + tags: [], + content: "", + id: "", + sig: "", + }, + expected: + "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3", + }, +]; + +describe("EventID.getID", () => { + test.each(idTestCases)("$name", ({ event, expected }) => { + const actual = EventID.getID(event); + expect(actual).toBe(expected); + }); +}); diff --git a/src/id.ts b/src/id.ts index e69de29..0721980 100644 --- a/src/id.ts +++ b/src/id.ts @@ -0,0 +1,36 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import type { EventData } from "./types"; + +/** + * Serializes an event into canonical JSON array format for ID computation. + * Returns: [0, pubkey, created_at, kind, tags, content] + * @param event - Event to serialize + * @returns Canonical JSON string + */ +function serialize(event: EventData): string { + const serialized = [ + 0, + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content, + ]; + return JSON.stringify(serialized); +} + +/** + * Computes the event ID as a lowercase hex-encoded SHA-256 hash. + * @param event - Event to compute ID for + * @returns 64-character lowercase hexadecimal event ID + */ +function getID(event: EventData): string { + const serialized = serialize(event); + const hash = sha256(new TextEncoder().encode(serialized)); + return Buffer.from(hash).toString("hex"); +} + +export const EventID = { + serialize, + getID, +}; diff --git a/src/index.ts b/src/index.ts index e69de29..528df97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./constants"; +export * from "./errors"; +export { Event } from "./event"; +export { Keys } from "./keys"; diff --git a/src/keys.test.ts b/src/keys.test.ts index 8d4e39d..f1f118f 100644 --- a/src/keys.test.ts +++ b/src/keys.test.ts @@ -5,32 +5,32 @@ import { testSK, testPK } from "./util.test"; describe("Keys.generatePrivate", () => { test("returns 64 hex characters", () => { - const privateKey = Keys.generatePrivate(); + const privateKey = Keys.generatePrivateKey(); expect(privateKey).toMatch(HEX_64_PATTERN); }); test("generates unique keys", () => { - const key1 = Keys.generatePrivate(); - const key2 = Keys.generatePrivate(); + const key1 = Keys.generatePrivateKey(); + const key2 = Keys.generatePrivateKey(); expect(key1).not.toBe(key2); }); }); describe("Keys.getPublic", () => { test("derives correct public key", () => { - const publicKey = Keys.getPublic(testSK); + const publicKey = Keys.getPublicKey(testSK); expect(publicKey).toBe(testPK); }); test("throws on invalid private key - too short", () => { - expect(() => Keys.getPublic("abc123")).toThrow( + expect(() => Keys.getPublicKey("abc123")).toThrow( "private key must be 64 lowercase hex characters", ); }); test("throws on invalid private key - non-hex", () => { expect(() => - Keys.getPublic( + Keys.getPublicKey( "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", ), ).toThrow("private key must be 64 lowercase hex characters"); @@ -38,7 +38,7 @@ describe("Keys.getPublic", () => { test("throws on invalid private key - uppercase", () => { expect(() => - Keys.getPublic( + Keys.getPublicKey( "F43A0435F69529F310BBD1D6263D2FBF0977F54BFE2310CC37AE5904B83BB167", ), ).toThrow("private key must be 64 lowercase hex characters"); diff --git a/src/keys.ts b/src/keys.ts index be54515..b5795f1 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -6,7 +6,7 @@ import { MalformedPrivKeyError } from "./errors"; * Generates a new random secp256k1 private key. * @returns 64-character lowercase hexadecimal string */ -function generatePrivate(): string { +function generatePrivateKey(): string { const { secretKey } = schnorr.keygen(); return Buffer.from(secretKey).toString("hex"); } @@ -17,7 +17,7 @@ function generatePrivate(): string { * @returns 64-character lowercase hexadecimal public key (x-coordinate only) * @throws {MalformedPrivKeyError} If private key is not 64 lowercase hex characters */ -function getPublic(privateKey: string): string { +function getPublicKey(privateKey: string): string { if (!HEX_64_PATTERN.test(privateKey)) { throw new MalformedPrivKeyError(); } @@ -29,6 +29,6 @@ function getPublic(privateKey: string): string { } export const Keys = { - generatePrivate, - getPublic, + generatePrivateKey, + getPublicKey, }; diff --git a/src/sign.ts b/src/sign.ts index ece5344..61c9c31 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,11 +1,11 @@ -import * as secp from "@noble/secp256k1"; +import { hashes as secp_hashes } from "@noble/secp256k1"; import { schnorr } from "@noble/secp256k1"; import { hmac } from "@noble/hashes/hmac.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { MalformedIDError, MalformedPrivKeyError } from "./errors"; -secp.hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); -secp.hashes.sha256 = sha256; +secp_hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); +secp_hashes.sha256 = sha256; /** * Generates a Schnorr signature for the given event ID using the provided private key. diff --git a/src/types.ts b/src/types.ts index 95f50ed..72f79c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,10 +6,10 @@ export type Tag = string[]; /** - * Event represents a Nostr protocol event with its seven required fields. + * EventData represents a Nostr protocol event with its seven required fields. * All fields must be present for a valid event. */ -export interface Event { +export interface EventData { id: string; pubkey: string; created_at: number; @@ -36,10 +36,10 @@ export interface FilterExtensions { } /** - * Filter defines subscription criteria for events. + * FilterData defines subscription criteria for events. * All conditions within a filter are applied with AND logic. */ -export interface Filter { +export interface FilterData { ids?: string[]; authors?: string[]; kinds?: number[]; diff --git a/src/util.test.ts b/src/util.test.ts index 2677400..37975db 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,5 +1,5 @@ import { test } from "vitest"; -import type { Event } from "./types"; +import type { EventData } from "./types"; test("placeholder", () => {}); @@ -8,7 +8,7 @@ export const testSK = export const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"; -export const testEvent: Event = { +export const testEvent: EventData = { id: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", pubkey: testPK, created_at: 1760740551,