├── .gitignore ├── tests ├── relay-list.test.ts ├── relay-common.test.ts ├── relay-single.test.ts ├── websocket.test.ts ├── example.test.ts ├── relay-pool.test.ts └── relay-single-test.ts ├── README.md ├── ctx.test.ts ├── nip7.test.ts ├── relay.interface.ts ├── .github └── workflows │ └── test.yml ├── nip25.ts ├── _helper.ts ├── makefile ├── LICENSE ├── nip11.ts ├── nip06.ts ├── nip96.test.ts ├── nip06.test.ts ├── nostr.test.ts ├── deno.json ├── cli └── nostr.ts ├── event.test.ts ├── space-member.ts ├── nip44.test.ts ├── event.ts ├── nip96.ts ├── nip9-test.ts ├── v2.ts ├── nip4.ts ├── key.ts ├── nip4.test.ts ├── nip44.ts ├── websocket.ts ├── nip19.test.ts ├── nip19.ts ├── relay-pool.ts ├── nostr.ts ├── deno.test.lock ├── scure.ts └── relay-single.ts /.gitignore: -------------------------------------------------------------------------------- 1 | cov_profile* 2 | .DS_Store 3 | nostr 4 | nodejs/index.js 5 | nodejs/index.mjs 6 | test.ts 7 | # SQLite files 8 | *.db 9 | .vscode 10 | -------------------------------------------------------------------------------- /tests/relay-list.test.ts: -------------------------------------------------------------------------------- 1 | export const damus = "wss://relay.damus.io"; 2 | export const nos = "wss://nos.lol"; 3 | export const wirednet = "wss://relay.nostr.wirednet.jp"; 4 | export const blowater = "wss://blowater.nostr1.com"; 5 | export const satlantis = "wss://relay.satlantis.io/"; 6 | 7 | export const relays = [ 8 | nos, 9 | wirednet, 10 | satlantis, 11 | ]; 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A strongly typed, fast nostr client protocol implementation. 2 | 3 | #### CLI 4 | 5 | ``` 6 | deno install https://jsr.io/@blowater/nostr-sdk/cli/nostr.ts 7 | ``` 8 | 9 | ### Examples 10 | 11 | See [tests](tests/example.test.ts) for examples. 12 | 13 | The auto complete provided by your editors should give you enough information to get started! 14 | 15 | Have fun. 16 | -------------------------------------------------------------------------------- /ctx.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { PrivateKey } from "./key.ts"; 3 | import { InMemoryAccountContext } from "./nostr.ts"; 4 | 5 | Deno.test("InMemoryAccountContext nip44", async () => { 6 | const ctx = InMemoryAccountContext.Generate(); 7 | 8 | const pri = PrivateKey.Generate(); 9 | const pub = pri.toPublicKey().hex; 10 | const encrypted_text = await ctx.encrypt(pub, "test", "nip44") as string; 11 | 12 | const plain_text = await ctx.decrypt(pub, encrypted_text) as string; 13 | assertEquals(plain_text, "test"); 14 | }); 15 | -------------------------------------------------------------------------------- /nip7.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, fail } from "@std/assert"; 2 | import { PrivateKey } from "./key.ts"; 3 | import { InMemoryAccountContext } from "./nostr.ts"; 4 | 5 | Deno.test("nip07", async () => { 6 | const pri = PrivateKey.Generate(); 7 | const ctx = InMemoryAccountContext.New(pri); 8 | 9 | const f = await ctx.decrypt(ctx.publicKey.hex, "{}", "nip4"); 10 | if (f instanceof Error) { 11 | assertEquals( 12 | f.message, 13 | "failed to decode, InvalidCharacterError: Failed to decode base64", 14 | ); 15 | } else { 16 | fail(`${f} should be an error`); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /relay.interface.ts: -------------------------------------------------------------------------------- 1 | import type { NostrEvent, NostrFilter } from "./nostr.ts"; 2 | import { type SubscriptionStream, WebSocketClosed } from "./relay-single.ts"; 3 | 4 | export type Subscriber = { 5 | newSub: (subID: string, ...filters: NostrFilter[]) => Promise< 6 | Error | SubscriptionStream 7 | >; 8 | }; 9 | 10 | export type SubscriptionCloser = { 11 | closeSub: (subID: string) => Promise; 12 | }; 13 | 14 | export type EventSender = { 15 | sendEvent: (nostrEvent: NostrEvent) => Promise; 16 | }; 17 | 18 | export type Closer = { 19 | close: () => Promise; 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | timeout-minutes: 1 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Setup repo 19 | uses: actions/checkout@v3 20 | 21 | - name: Setup Deno 22 | uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: 2.0.4 25 | 26 | - name: Verify formatting & other checks 27 | run: make check 28 | 29 | - name: Run tests 30 | run: make test 31 | -------------------------------------------------------------------------------- /nip25.ts: -------------------------------------------------------------------------------- 1 | import { prepareNostrEvent } from "./event.ts"; 2 | import * as nostr from "./nostr.ts"; 3 | 4 | export async function prepareReactionEvent( 5 | author: nostr.Signer, 6 | args: { 7 | content: string; 8 | targetEvent: nostr.NostrEvent; 9 | }, 10 | ): Promise | Error> { 11 | const { content, targetEvent } = args; 12 | 13 | // https://github.com/nostr-protocol/nips/blob/master/25.md#tags 14 | // There is currently no need to support replaceable events 15 | const tags: nostr.Tag[] = [ 16 | ["e", targetEvent.id], 17 | ["p", targetEvent.pubkey], 18 | ]; 19 | return prepareNostrEvent( 20 | author, 21 | { 22 | kind: nostr.NostrKind.REACTION, 23 | content, 24 | tags, 25 | }, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /_helper.ts: -------------------------------------------------------------------------------- 1 | export function parseJSON(content: string): T | SyntaxError { 2 | try { 3 | return JSON.parse(content) as T; 4 | } catch (e) { 5 | return e as SyntaxError; 6 | } 7 | } 8 | 9 | // Datetime format string for Event V2 10 | export const RFC3339 = "yyyy-MM-ddTHH:mm:ss.SSSZ"; 11 | 12 | export class RESTRequestFailed extends Error { 13 | constructor(public readonly res: Response, public override readonly message: string) { 14 | super(`Failed to request rest api, ${res.status}:${res.statusText}`); 15 | this.name = RESTRequestFailed.name; 16 | } 17 | } 18 | 19 | export function newURL(url: string | URL): URL | TypeError { 20 | try { 21 | // https://developer.mozilla.org/en-US/docs/Web/API/URL/URL#exceptions 22 | return new URL(url); 23 | } catch (e) { 24 | return e as TypeError; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | test: fmt 2 | deno --version 3 | rm -rf cov_profile* 4 | deno test --lock deno.test.lock \ 5 | --allow-net \ 6 | --allow-read=relayed.db,relayed.db-journal \ 7 | --allow-write=relayed.db,relayed.db-journal \ 8 | --trace-leaks --coverage=cov_profile \ 9 | --unstable-kv 10 | 11 | # https://deno.com/manual@main/basics/testing/coverage 12 | cov: 13 | deno coverage cov_profile --lcov --output=cov_profile.lcov 14 | genhtml --ignore-errors unmapped -o cov_profile/html cov_profile.lcov 15 | file_server -p 4508 cov_profile/html 16 | 17 | fmt: 18 | deno fmt 19 | 20 | fmt-check: 21 | deno fmt --check 22 | 23 | check: fmt-check 24 | deno lint 25 | deno compile cli/nostr.ts 26 | 27 | install: 28 | deno install --allow-net --allow-read https://deno.land/std@0.202.0/http/file_server.ts 29 | 30 | build: 31 | deno bundle *.ts mod.ts 32 | 33 | publish: 34 | deno publish --allow-slow-types 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 BlowaterNostr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nip11.ts: -------------------------------------------------------------------------------- 1 | import { newURL, RESTRequestFailed } from "./_helper.ts"; 2 | 3 | export async function getRelayInformation(url: URL | string) { 4 | const httpURL = newURL(url); 5 | if (httpURL instanceof TypeError) { 6 | return httpURL; 7 | } 8 | httpURL.protocol = httpURL.protocol == "wss:" ? "https" : "http"; 9 | try { 10 | const res = await fetch(httpURL, { 11 | headers: { 12 | "accept": "application/nostr+json", 13 | }, 14 | }); 15 | if (!res.ok) { 16 | return new RESTRequestFailed(res, await res.text()); 17 | } 18 | const detail = await res.text(); 19 | const info = JSON.parse(detail) as RelayInformation; 20 | if (!info.icon) { 21 | info.icon = robohash(url); 22 | } 23 | return info; 24 | } catch (e) { 25 | return e as Error; 26 | } 27 | } 28 | 29 | export type RelayInformation = { 30 | name?: string; 31 | description?: string; 32 | pubkey?: string; 33 | contact?: string; 34 | supported_nips?: number[]; 35 | software?: string; 36 | version?: string; 37 | icon?: string; 38 | }; 39 | 40 | export function robohash(url: string | URL) { 41 | return `https://robohash.org/${url}`; 42 | } 43 | -------------------------------------------------------------------------------- /nip06.ts: -------------------------------------------------------------------------------- 1 | // copied from https://github.com/nbd-wtf/nostr-tools/blob/master/nip06.ts 2 | import { encodeHex } from "@std/encoding"; 3 | import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39"; 4 | import { wordlist } from "@scure/bip39/wordlists/english"; 5 | import { HDKey } from "@scure/bip32"; 6 | import { PrivateKey } from "./key.ts"; 7 | 8 | export function privateKeyFromSeedWords(mnemonic: string | string[], passphrase?: string) { 9 | if (mnemonic instanceof Array) { 10 | mnemonic = mnemonic.join(" "); 11 | } 12 | let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)); 13 | let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey; 14 | if (!privateKey) return Error("could not derive private key"); 15 | const hex = encodeHex(privateKey); 16 | return PrivateKey.FromHex(hex); 17 | } 18 | 19 | // 128: 12 words 20 | // 192: 18 words 21 | // 256: 24 words 22 | export function generateSeedWords(strength: 128 | 192 | 256): string[] { 23 | return generateMnemonic(wordlist, strength).split(" "); 24 | } 25 | 26 | export function validateWords(words: string | string[]): boolean { 27 | if (words instanceof Array) { 28 | words = words.join(" "); 29 | } 30 | return validateMnemonic(words, wordlist); 31 | } 32 | -------------------------------------------------------------------------------- /nip96.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, fail } from "@std/assert"; 2 | import { uploadFile } from "./nip96.ts"; 3 | import { InMemoryAccountContext } from "./nostr.ts"; 4 | 5 | Deno.test("Upload File", async () => { 6 | const ctx = InMemoryAccountContext.Generate(); 7 | const api_url = "https://nostr.build/api/v2/nip96/upload"; 8 | const image_url = 9 | "https://image.nostr.build/655007ae74f24ea1c611889f48b25cb485b83ab67408daddd98f95782f47e1b5.jpg"; 10 | 11 | try { 12 | const imageBuffer = await fetch(image_url).then((res) => res.arrayBuffer()); 13 | const image = new File([imageBuffer], "test.jpg"); 14 | const uploaded = await uploadFile(ctx, { api_url, file: image }); 15 | if (uploaded instanceof Error) { 16 | fail(uploaded.message); 17 | } 18 | if (uploaded.status === "error") { 19 | fail(uploaded.message); 20 | } 21 | assertEquals(uploaded.nip94_event.tags[0][0], "url"); 22 | assertEquals(uploaded.nip94_event.tags[0][1].substring(0, 26), "https://image.nostr.build/"); 23 | assertEquals(uploaded.nip94_event.tags[3], ["m", "image/jpeg"]); 24 | assertEquals(uploaded.nip94_event.tags[4], ["dim", "460x460"]); 25 | } catch (error) { 26 | fail((error as Error).message); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /nip06.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertNotInstanceOf } from "@std/assert"; 2 | import { generateSeedWords, privateKeyFromSeedWords, validateWords } from "./nip06.ts"; 3 | import { PrivateKey } from "./key.ts"; 4 | 5 | Deno.test("generate private key from a mnemonic", async () => { 6 | const mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; 7 | const privateKey = privateKeyFromSeedWords(mnemonic) as PrivateKey; 8 | assertEquals(privateKey.hex, "c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2"); 9 | }); 10 | 11 | Deno.test("generate private key from a mnemonic and passphrase", async () => { 12 | const mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; 13 | const passphrase = "123"; 14 | const privateKey = privateKeyFromSeedWords(mnemonic, passphrase) as PrivateKey; 15 | assertEquals(privateKey.hex, "55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4"); 16 | }); 17 | 18 | Deno.test("generateSeedWords & validateWords", async () => { 19 | for (const bitsize of [128, 192, 256]) { 20 | const words = generateSeedWords(bitsize as 128 | 192 | 256); 21 | assertEquals(true, validateWords(words)); 22 | const privateKey = privateKeyFromSeedWords(words); 23 | assertNotInstanceOf(privateKey, Error, `${bitsize}`); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /nostr.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { RFC3339 } from "./_helper.ts"; 3 | import { format } from "@std/datetime"; 4 | import { InMemoryAccountContext_V2, verify_event_v2 } from "./v2.ts"; 5 | import { prepareNostrEvent } from "./event.ts"; 6 | import { PrivateKey } from "./key.ts"; 7 | import { getTags, InMemoryAccountContext, type NostrEvent, NostrKind } from "./nostr.ts"; 8 | 9 | Deno.test("verify event v2", async () => { 10 | const ctx = InMemoryAccountContext_V2.Generate(); 11 | const event = await ctx.signEventV2({ 12 | pubkey: ctx.publicKey.hex, 13 | kind: "SpaceMember", 14 | created_at: format(new Date(), RFC3339), 15 | }); 16 | const ok = await verify_event_v2(event); 17 | assertEquals(ok, true); 18 | }); 19 | 20 | Deno.test("getTags", async () => { 21 | const d = PrivateKey.Generate().hex; 22 | const e = PrivateKey.Generate().hex; 23 | const p = PrivateKey.Generate().hex; 24 | const event = await prepareNostrEvent(InMemoryAccountContext.Generate(), { 25 | kind: NostrKind.TEXT_NOTE, 26 | content: "", 27 | tags: [ 28 | ["d", d], 29 | ["e", e], 30 | ["p", p], 31 | ["client", "Deno"], 32 | ["t", "food"], 33 | ], 34 | }) as NostrEvent; 35 | const tags = getTags(event); 36 | assertEquals(tags, { 37 | e: [e], 38 | p: [p], 39 | d: d, 40 | t: ["food"], 41 | client: "Deno", 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@blowater/nostr-sdk", 3 | "version": "0.1.6", 4 | "exports": { 5 | ".": "./nostr.ts", 6 | "./key": "./key.ts", 7 | "./v2": "./v2.ts" 8 | }, 9 | "fmt": { 10 | "indentWidth": 4, 11 | "lineWidth": 110, 12 | "exclude": ["cov_profile"] 13 | }, 14 | "compilerOptions": { 15 | "verbatimModuleSyntax": true, 16 | "useUnknownInCatchVariables": true 17 | }, 18 | "lint": { 19 | "rules": { 20 | "exclude": [ 21 | "prefer-const", 22 | "no-slow-types", 23 | "require-await", 24 | "verbatim-module-syntax", 25 | "ban-types" 26 | ] 27 | }, 28 | "exclude": ["scure.ts", "**/*.test.ts"] 29 | }, 30 | "test": { 31 | "exclude": ["relay-pool.test.ts", "relay-single.test.ts", "websocket.test.ts"] 32 | }, 33 | "imports": { 34 | "@blowater/collections": "jsr:@blowater/collections@^0.0.0-rc1", 35 | "@blowater/csp": "jsr:@blowater/csp@1.0.0", 36 | "@noble/ciphers": "npm:@noble/ciphers@0.4.1", 37 | "@noble/curves": "npm:@noble/curves@1.4.0", 38 | "@noble/hashes": "npm:@noble/hashes@1.4.0", 39 | "@noble/secp256k1": "jsr:@noble/secp256k1@2.1.0", 40 | "@scure/bip32": "npm:@scure/bip32@1.3.2", 41 | "@scure/bip39": "npm:@scure/bip39@1.2.1", 42 | "@std/assert": "jsr:@std/assert@0.226.0", 43 | "@std/datetime": "jsr:@std/datetime@0.224.1", 44 | "@std/encoding": "jsr:@std/encoding@0.224.3", 45 | "zod": "npm:zod@3.23.8" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cli/nostr.ts: -------------------------------------------------------------------------------- 1 | import { PrivateKey } from "../key.ts"; 2 | import { NoteID } from "../nip19.ts"; 3 | import { relays } from "../tests/relay-list.test.ts"; 4 | import { ConnectionPool } from "../relay-pool.ts"; 5 | 6 | async function main() { 7 | let command: string; 8 | if (Deno.args.length == 0) { 9 | command = "h"; 10 | } else { 11 | command = Deno.args[0].toLowerCase(); 12 | } 13 | 14 | if (command == "keygen") { 15 | const pri = PrivateKey.Generate(); 16 | console.log("Private Key:"); 17 | console.log(pri.hex); 18 | console.log(pri.bech32); 19 | const pub = pri.toPublicKey(); 20 | console.log("\nPublic Key:"); 21 | console.log(pub.hex); 22 | console.log(pub.bech32()); 23 | } else if (command == "get") { 24 | const id = Deno.args[1]; 25 | if (id == undefined) { 26 | console.log("need an id, for example:"); 27 | console.log("nostr get key/event id/nip19"); 28 | return; 29 | } 30 | const eventID = NoteID.FromString(id); 31 | const pool = new ConnectionPool(); 32 | const err = await pool.addRelayURLs(relays); 33 | if (err instanceof Error) { 34 | return err; 35 | } 36 | const event = await pool.getEvent(eventID.hex); 37 | console.log(event); 38 | await pool.close(); 39 | } else { 40 | console.log("nostr keygen - generate key pairs"); 41 | console.log("nostr get [event id] - get event from relays"); 42 | } 43 | } 44 | 45 | const err = await main(); 46 | if (err instanceof Error) { 47 | console.error(err); 48 | } 49 | -------------------------------------------------------------------------------- /tests/relay-common.test.ts: -------------------------------------------------------------------------------- 1 | import { assertNotInstanceOf, fail } from "@std/assert"; 2 | import { SingleRelayConnection } from "../relay-single.ts"; 3 | import { ConnectionPool } from "../relay-pool.ts"; 4 | 5 | Deno.test("url acceptance", async (t) => { 6 | { 7 | const relay = SingleRelayConnection.New("nos.lol"); 8 | if (relay instanceof Error) { 9 | fail(relay.message); 10 | } 11 | await relay.close(); 12 | } 13 | { 14 | const relay = SingleRelayConnection.New("wss://nos.lol"); 15 | if (relay instanceof Error) { 16 | fail(relay.message); 17 | } 18 | await relay.close(); 19 | } 20 | { 21 | const pool = new ConnectionPool(); 22 | const err = await pool.addRelayURL("nos.lol"); 23 | if (err instanceof Error) { 24 | fail(err.message); 25 | } 26 | await pool.close(); 27 | } 28 | { 29 | const pool = new ConnectionPool(); 30 | const err = await pool.addRelayURL("wss://nos.lol"); 31 | if (err instanceof Error) { 32 | fail(err.message); 33 | } 34 | const err2 = await pool.addRelayURL("nos.lol"); 35 | assertNotInstanceOf(err2, Error); 36 | await pool.close(); 37 | } 38 | { 39 | // now switch the order of urls 40 | const pool = new ConnectionPool(); 41 | const err = await pool.addRelayURL("nos.lol"); 42 | if (err instanceof Error) { 43 | fail(err.message); 44 | } 45 | const err2 = await pool.addRelayURL("wss://nos.lol"); 46 | assertNotInstanceOf(err2, Error); 47 | await pool.close(); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /event.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, fail } from "@std/assert"; 2 | import { PrivateKey } from "./key.ts"; 3 | import { InMemoryAccountContext, type NostrEvent, NostrKind, verifyEvent } from "./nostr.ts"; 4 | import { prepareEncryptedNostrEvent, prepareNostrEvent } from "./event.ts"; 5 | 6 | Deno.test("Verify Event", async (t) => { 7 | let pri = PrivateKey.Generate(); 8 | let event = await prepareNostrEvent(InMemoryAccountContext.New(pri), { 9 | kind: NostrKind.TEXT_NOTE, 10 | content: "", 11 | }) as NostrEvent; 12 | let ok = await verifyEvent(event); 13 | assertEquals(ok, true); 14 | 15 | await t.step("invalid", async () => { 16 | let ok = await verifyEvent({ 17 | content: "", 18 | created_at: 1, 19 | id: "", 20 | kind: 1, 21 | pubkey: "", 22 | sig: "", 23 | tags: [], 24 | }); 25 | assertEquals(ok, false); 26 | }); 27 | }); 28 | 29 | Deno.test({ 30 | name: "wrong encryption key causing decryption failure", 31 | ignore: false, 32 | fn: async () => { 33 | const ctx = InMemoryAccountContext.New(PrivateKey.Generate()); 34 | const key = PrivateKey.Generate().hex; 35 | const event = await prepareEncryptedNostrEvent(ctx, { 36 | encryptKey: ctx.publicKey, 37 | kind: NostrKind.DIRECT_MESSAGE, 38 | tags: [ 39 | ["p", key], 40 | ], 41 | content: "123", 42 | }); 43 | if (event instanceof Error) fail(event.message); 44 | const err = await ctx.decrypt(key, event.content, "nip4"); 45 | if (err instanceof Error) { 46 | // ok 47 | } else { 48 | fail(`should have error, get ${err}`); 49 | } 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /tests/relay-single.test.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "@blowater/csp"; 2 | import { nos } from "./relay-list.test.ts"; 3 | import { wirednet } from "./relay-list.test.ts"; 4 | import { damus, relays, satlantis } from "./relay-list.test.ts"; 5 | import { 6 | close_sub_keep_reading, 7 | get_correct_kind, 8 | get_event_by_id, 9 | get_replaceable_event, 10 | limit, 11 | newSub_close, 12 | newSub_multiple_filters, 13 | no_event, 14 | open_close, 15 | send_event, 16 | sub_exits, 17 | two_clients_communicate, 18 | } from "./relay-single-test.ts"; 19 | 20 | Deno.test("SingleRelayConnection open & close", open_close(relays)); 21 | 22 | Deno.test("SingleRelayConnection newSub & close", async () => { 23 | await newSub_close(damus)(); 24 | }); 25 | 26 | Deno.test("Single Relay Connection", async (t) => { 27 | const relay = { 28 | ws_url: wirednet, 29 | }; 30 | await t.step("SingleRelayConnection subscription already exists", sub_exits(relay.ws_url)); 31 | await sleep(100); 32 | await t.step( 33 | "SingleRelayConnection: close subscription and keep reading", 34 | close_sub_keep_reading(relay.ws_url), 35 | ); 36 | await sleep(100); 37 | await t.step("send event", send_event(relay.ws_url)); 38 | await sleep(100); 39 | await t.step("get_correct_kind", get_correct_kind(relay.ws_url)); 40 | await sleep(100); 41 | await t.step("limit", limit(relay.ws_url)); 42 | await sleep(100); 43 | await t.step("no_event", no_event(relay.ws_url)); 44 | await sleep(100); 45 | await t.step("two_clients_communicate", two_clients_communicate(relay.ws_url)); 46 | }); 47 | 48 | Deno.test("multiple filters", newSub_multiple_filters(damus)); 49 | 50 | Deno.test("get_event_by_id", async () => { 51 | await get_event_by_id(wirednet)(); 52 | await get_event_by_id(damus)(); 53 | }); 54 | 55 | Deno.test("get replaceable event", async () => { 56 | await get_replaceable_event(damus)(); 57 | await get_replaceable_event(satlantis)(); 58 | }); 59 | -------------------------------------------------------------------------------- /space-member.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from "./key.ts"; 2 | import { parseJSON, RESTRequestFailed, RFC3339 } from "./_helper.ts"; 3 | import { format } from "@std/datetime"; 4 | import { z } from "zod"; 5 | import type { Signer_V2, SpaceMember } from "./v2.ts"; 6 | 7 | export function prepareSpaceMember( 8 | author: Signer_V2, 9 | member: PublicKey | string, 10 | ): Error | Promise { 11 | const pubkey = (member instanceof PublicKey) ? member : PublicKey.FromString(member); 12 | if (pubkey instanceof Error) return pubkey; 13 | return author.signEventV2({ 14 | pubkey: author.publicKey.hex, 15 | kind: "SpaceMember", 16 | member: pubkey.hex, 17 | created_at: format(new Date(), RFC3339), 18 | }); 19 | } 20 | 21 | const SpaceMembers_Schema = z.object({ 22 | pubkey: z.string(), 23 | id: z.string(), 24 | sig: z.string(), 25 | created_at: z.string(), 26 | kind: z.literal("SpaceMember"), 27 | member: z.string(), 28 | }).array(); 29 | 30 | export async function getSpaceMembers(url: URL) { 31 | // construct a new URL so that we don't change the old instance 32 | const httpURL = new URL(url); 33 | httpURL.protocol = httpURL.protocol == "wss:" ? "https" : "http"; 34 | httpURL.pathname = "/api/members"; 35 | 36 | let res; 37 | try { 38 | res = await fetch(httpURL); 39 | } catch (e) { 40 | // https://developer.mozilla.org/en-US/docs/Web/API/fetch#exceptions 41 | if (e instanceof TypeError) { 42 | return e; 43 | } 44 | throw e; // impossible 45 | } 46 | 47 | let body; 48 | try { 49 | body = await res.text(); 50 | } catch (e) { 51 | if (e instanceof TypeError) { 52 | return e; 53 | } 54 | throw e; // impossible 55 | } 56 | 57 | if (!res.ok) { 58 | return new RESTRequestFailed(res, body); 59 | } 60 | const data = parseJSON(body); 61 | if (data instanceof SyntaxError) { 62 | return data; 63 | } 64 | return SpaceMembers_Schema.parse(data) as SpaceMember[]; 65 | } 66 | -------------------------------------------------------------------------------- /nip44.test.ts: -------------------------------------------------------------------------------- 1 | import * as nip44 from "./nip44.ts"; 2 | import { default as vec } from "./nip44.json" with { type: "json" }; 3 | import { schnorr } from "@noble/curves/secp256k1"; 4 | import { assertEquals, assertMatch, fail } from "@std/assert"; 5 | import { decodeHex, encodeHex } from "@std/encoding"; 6 | const v2vec = vec.v2; 7 | 8 | Deno.test("get_conversation_key", () => { 9 | for (const v of v2vec.valid.get_conversation_key) { 10 | const key = nip44.getConversationKey(v.sec1, v.pub2); 11 | if (key instanceof Error) fail(key.message); 12 | 13 | assertEquals(encodeHex(key), v.conversation_key); 14 | } 15 | }); 16 | 17 | Deno.test("encrypt_decrypt", () => { 18 | for (const v of v2vec.valid.encrypt_decrypt) { 19 | const pub2 = encodeHex(schnorr.getPublicKey(v.sec2)); 20 | const key = nip44.getConversationKey(v.sec1, pub2); 21 | if (key instanceof Error) fail(key.message); 22 | 23 | assertEquals(encodeHex(key), v.conversation_key); 24 | const ciphertext = nip44.encrypt(v.plaintext, key, decodeHex(v.nonce)); 25 | if (ciphertext instanceof Error) { 26 | fail(ciphertext.message); 27 | } 28 | assertEquals(ciphertext, v.payload); 29 | const decrypted = nip44.decrypt(ciphertext, key); 30 | assertEquals(decrypted, v.plaintext); 31 | } 32 | }); 33 | 34 | Deno.test("calc_padded_len", () => { 35 | for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) { 36 | const actual = nip44.calcPaddedLen(len); 37 | assertEquals(actual, shouldBePaddedTo); 38 | } 39 | }); 40 | 41 | Deno.test("decrypt", async () => { 42 | for (const v of v2vec.invalid.decrypt) { 43 | const err = nip44.decrypt(v.payload, decodeHex(v.conversation_key)) as Error; 44 | assertMatch(err.message, new RegExp(v.note)); 45 | } 46 | }); 47 | 48 | Deno.test("get_conversation_key", async () => { 49 | for (const v of v2vec.invalid.get_conversation_key) { 50 | const err = nip44.getConversationKey(v.sec1, v.pub2) as Error; 51 | assertMatch(err.message, /Cannot find square root|Point is not on curve/); 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /event.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from "./key.ts"; 2 | import { 3 | type Encrypter, 4 | type NostrEvent, 5 | NostrKind, 6 | type Signer, 7 | type Tag, 8 | type UnsignedNostrEvent, 9 | } from "./nostr.ts"; 10 | 11 | export async function prepareEncryptedNostrEvent( 12 | sender: Signer & Encrypter, 13 | args: { 14 | encryptKey: PublicKey; 15 | kind: T; 16 | content: string; 17 | tags?: Tag[]; 18 | algorithm?: "nip4" | "nip44"; 19 | }, 20 | ): Promise | Error> { 21 | const encrypted = await sender.encrypt( 22 | args.encryptKey.hex, 23 | args.content, 24 | args.algorithm ? args.algorithm : "nip44", 25 | ); 26 | if (encrypted instanceof Error) { 27 | return encrypted; 28 | } 29 | return prepareNostrEvent( 30 | sender, 31 | { 32 | kind: args.kind, 33 | tags: args.tags, 34 | content: encrypted, 35 | }, 36 | ); 37 | } 38 | 39 | export async function prepareNostrEvent( 40 | sender: Signer, 41 | args: { 42 | kind: Kind; 43 | content: string; 44 | tags?: Tag[]; 45 | created_at?: number; 46 | }, 47 | ): Promise | Error> { 48 | const event: UnsignedNostrEvent = { 49 | created_at: args.created_at ? Math.floor(args.created_at) : Math.floor(Date.now() / 1000), 50 | kind: args.kind, 51 | pubkey: sender.publicKey.hex, 52 | tags: args.tags || [], 53 | content: args.content, 54 | }; 55 | return sender.signEvent(event); 56 | } 57 | 58 | export async function prepareDeletionEvent( 59 | author: Signer, 60 | content: string, 61 | ...events: NostrEvent[] 62 | ): Promise | Error> { 63 | const eTags = new Set(); 64 | const tags: Tag[] = []; 65 | 66 | for (const e of events) { 67 | if (eTags.has(e.id)) { 68 | continue; 69 | } 70 | eTags.add(e.id); 71 | tags.push(["e", e.id]); 72 | } 73 | 74 | return prepareNostrEvent( 75 | author, 76 | { 77 | kind: NostrKind.DELETE, 78 | content, 79 | tags, 80 | created_at: Math.floor(Date.now() / 1000), 81 | }, 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /nip96.ts: -------------------------------------------------------------------------------- 1 | import { prepareNostrEvent } from "./event.ts"; 2 | import { type NostrAccountContext, type NostrEvent, NostrKind, type Tag } from "./nostr.ts"; 3 | 4 | export type UploadFileResponse = { 5 | status: "success"; 6 | message: string; 7 | nip94_event: { 8 | tags: string[][]; 9 | content: ""; 10 | }; 11 | processing_url?: string; 12 | } | { 13 | status: "error"; 14 | message: string; 15 | }; 16 | 17 | export async function uploadFile( 18 | author: NostrAccountContext, 19 | args: { 20 | api_url: string; 21 | file: Blob; 22 | }, 23 | ): Promise { 24 | const formData = new FormData(); 25 | formData.append("file[]", args.file); 26 | 27 | const httpAuthEvent = await prepareHttpAuthEvent(author, { 28 | url: args.api_url, 29 | method: "POST", 30 | body: formData, 31 | }); 32 | if (httpAuthEvent instanceof Error) { 33 | return httpAuthEvent; 34 | } 35 | try { 36 | const response = await fetch(args.api_url, { 37 | method: "POST", 38 | headers: { 39 | "Authorization": `Nostr ${btoa(JSON.stringify(httpAuthEvent))}`, 40 | }, 41 | body: formData, 42 | }); 43 | if (response.status !== 200) { 44 | return new Error(`Failed to upload file: ${response.statusText}`); 45 | } 46 | const json = await response.json(); 47 | return json; 48 | } catch (error) { 49 | if (error instanceof Error) { 50 | return error; 51 | } else { 52 | throw error; // impossible 53 | } 54 | } 55 | } 56 | 57 | async function prepareHttpAuthEvent( 58 | author: NostrAccountContext, 59 | args: { 60 | url: string; 61 | method: string; 62 | body?: FormData; 63 | }, 64 | ): Promise | Error> { 65 | const { url, method, body } = args; 66 | const tags: Tag[] = [ 67 | ["u", url], 68 | ["method", method], 69 | ]; 70 | if (body) { 71 | const encoder = new TextEncoder(); 72 | const data = encoder.encode(JSON.stringify(body)); 73 | const hashBuffer = await crypto.subtle.digest("SHA-256", data); 74 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 75 | const hashString = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); 76 | tags.push(["payload", hashString]); 77 | } 78 | return prepareNostrEvent( 79 | author, 80 | { 81 | kind: NostrKind.HTTP_AUTH, 82 | content: "", 83 | tags, 84 | }, 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /nip9-test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, fail } from "@std/assert"; 2 | import { prepareDeletionEvent, prepareNostrEvent } from "./event.ts"; 3 | import { InMemoryAccountContext, NostrEvent, NostrKind } from "./nostr.ts"; 4 | import { SingleRelayConnection } from "./relay-single.ts"; 5 | 6 | export const store_deletion_event = (url: string) => async () => { 7 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 8 | const ctx = InMemoryAccountContext.Generate(); 9 | try { 10 | const event = await prepareNostrEvent(ctx, { 11 | content: "test send_deletion_event", 12 | kind: NostrKind.TEXT_NOTE, 13 | }) as NostrEvent; 14 | const deletion = await prepareDeletionEvent(ctx, "test deletion", event); 15 | if (deletion instanceof Error) { 16 | fail(deletion.message); 17 | } 18 | const err1 = await relay.sendEvent(deletion); 19 | if (err1 instanceof Error) fail(err1.message); 20 | const event_1 = await relay.getEvent(deletion.id); 21 | if (event_1 instanceof Error) fail(event_1.message); 22 | assertEquals(event_1, deletion); 23 | } finally { 24 | await relay.close(); 25 | } 26 | }; 27 | 28 | export const delete_regular_events = (url: string) => async () => { 29 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 30 | const ctx = InMemoryAccountContext.Generate(); 31 | const testkind = [NostrKind.TEXT_NOTE, NostrKind.DIRECT_MESSAGE]; 32 | try { 33 | for (const kind of testkind) { 34 | const event = await prepareNostrEvent(ctx, { 35 | content: "test send_deletion_event", 36 | kind, 37 | }) as NostrEvent; 38 | const err1 = await relay.sendEvent(event); 39 | if (err1 instanceof Error) fail(err1.message); 40 | 41 | const event_1 = await relay.getEvent(event.id); 42 | if (event_1 instanceof Error) fail(event_1.message); 43 | assertEquals(event, event_1, "event not create"); 44 | 45 | const deletion = await prepareDeletionEvent(ctx, "test deletion", event); 46 | if (deletion instanceof Error) { 47 | fail(deletion.message); 48 | } 49 | const err2 = await relay.sendEvent(deletion); 50 | if (err2 instanceof Error) { 51 | console.log(err2); 52 | fail(err2.message); 53 | } 54 | 55 | const nothing = await relay.getEvent(event.id); 56 | if (nothing instanceof Error) fail(nothing.message); 57 | assertEquals(nothing, undefined, "event not deleted"); 58 | } 59 | } finally { 60 | await relay.close(); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /tests/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import { AsyncWebSocket, CloseReason, CloseTwice } from "../websocket.ts"; 2 | import { damus, relays } from "./relay-list.test.ts"; 3 | import { WebSocketClosed } from "../relay-single.ts"; 4 | import { assertEquals, assertInstanceOf, fail } from "@std/assert"; 5 | import { PrivateKey } from "../key.ts"; 6 | 7 | Deno.test("websocket open & close", async () => { 8 | let ps = []; 9 | for (let url of relays) { 10 | let p = (async () => { 11 | let ws = AsyncWebSocket.New(url, true); // todo: maybe use a local ws server to speed up 12 | if (ws instanceof Error) { 13 | fail(); 14 | } 15 | 16 | await ws.untilOpen(); 17 | await ws.close(); 18 | })(); 19 | ps.push(p); 20 | } 21 | // no exception should happen 22 | await Promise.all(ps); 23 | }); 24 | 25 | Deno.test("websocket call untilOpen after closed", async () => { 26 | { 27 | const ws = AsyncWebSocket.New(damus, true); 28 | if (ws instanceof Error) { 29 | fail(); 30 | } 31 | const err1 = await ws.close(); 32 | assertEquals(false, err1 instanceof CloseTwice); 33 | const err2 = await ws.untilOpen(); 34 | assertInstanceOf(err2, Error); 35 | } 36 | { 37 | const ws2 = AsyncWebSocket.New(damus, true); 38 | if (ws2 instanceof Error) { 39 | fail(); 40 | } 41 | const p = ws2.close(); // do not wait 42 | const err3 = await ws2.untilOpen(); 43 | assertInstanceOf(err3, Error); 44 | const err4 = await p; 45 | assertEquals(false, err4 instanceof CloseTwice); 46 | } 47 | }); 48 | 49 | Deno.test("websocket close without waiting for openning", async () => { 50 | let ps = []; 51 | for (let url of relays) { 52 | let p = (async () => { 53 | let ws = AsyncWebSocket.New(url, true); // todo: maybe use a local ws server to speed up 54 | if (ws instanceof Error) { 55 | fail(); 56 | } 57 | await ws.close(); // ----------------------------error event will happen but don't care in this test 58 | })(); 59 | ps.push(p); 60 | } 61 | 62 | // no exception should happen 63 | await Promise.all(ps); 64 | }); 65 | 66 | Deno.test("websocket close with a code & reason", async () => { 67 | let skipped = 0; 68 | for (let url of relays) { 69 | let ws = AsyncWebSocket.New(url, true); // todo: maybe use a local ws server to speed up 70 | if (ws instanceof Error) { 71 | fail(); 72 | } 73 | { 74 | let err = await ws.untilOpen(); 75 | if (err instanceof WebSocketClosed) { 76 | console.log(`${ws.url} is clsoed, skip`); 77 | skipped++; 78 | continue; 79 | } 80 | assertEquals(err, undefined); 81 | } 82 | let event = await ws.close( 83 | CloseReason.ByClient, 84 | "some reason", 85 | ); 86 | // the only thing we can be sure is that there is a code 87 | // but relay implementations may have whatever number 88 | if (event instanceof CloseTwice) { 89 | console.error(event); 90 | fail(); 91 | } 92 | 93 | let err = await ws.close(); 94 | if (err instanceof CloseTwice) { 95 | assertEquals(err.url, ws.url.toString()); 96 | } else { 97 | console.error(err); 98 | fail(); 99 | } 100 | } 101 | assertEquals(true, skipped < relays.length / 2, `skipped: ${skipped}`); // at least half of the relays have to succeed 102 | }); 103 | -------------------------------------------------------------------------------- /v2.ts: -------------------------------------------------------------------------------- 1 | import { schnorr } from "@noble/curves/secp256k1"; 2 | import { sha256 } from "@noble/hashes/sha256"; 3 | import { utf8Encode } from "./nip4.ts"; 4 | import { encodeHex } from "@std/encoding"; 5 | import stringify from "npm:json-stable-stringify@1.1.1"; 6 | import { PrivateKey, PublicKey } from "./key.ts"; 7 | import { 8 | InMemoryAccountContext, 9 | type NostrAccountContext, 10 | type NostrEvent, 11 | NostrKind, 12 | signId, 13 | type UnsignedNostrEvent, 14 | } from "./nostr.ts"; 15 | 16 | export type Kind_V2 = "ChannelCreation" | "ChannelEdition" | "SpaceMember"; 17 | 18 | type Event_Base = { 19 | pubkey: string; 20 | id: string; 21 | sig: string; 22 | created_at: string; 23 | }; 24 | 25 | export type ChannelCreation = Event_Base & { 26 | kind: "ChannelCreation"; 27 | name: string; 28 | scope: "server"; 29 | }; 30 | 31 | // EditChannel is a different type from CreateChannel because 32 | // a channel only has one creator but could have multiple admin to modify it 33 | export type ChannelEdition = Event_Base & { 34 | kind: "ChannelEdition"; 35 | channel_id: string; 36 | name: string; 37 | }; 38 | 39 | export type SpaceMember = Event_Base & { 40 | kind: "SpaceMember"; 41 | member: string; // the pubkey of member 42 | }; 43 | 44 | export type Event_V2 = ChannelCreation | ChannelEdition | SpaceMember; 45 | 46 | export async function verify_event_v2( 47 | event: T, 48 | ) { 49 | try { 50 | const event_copy: { sig?: string; pubkey: string; id?: string } = { ...event }; 51 | delete event_copy.sig; 52 | delete event_copy.id; 53 | const buf = utf8Encode(stringify(event_copy)); 54 | const id = encodeHex(sha256(buf)); 55 | return schnorr.verify(event.sig, id, event.pubkey); 56 | } catch { 57 | return false; 58 | } 59 | } 60 | 61 | export interface Signer_V2 { 62 | readonly publicKey: PublicKey; 63 | signEventV2( 64 | event: T, 65 | ): Promise; 66 | } 67 | 68 | export class InMemoryAccountContext_V2 implements NostrAccountContext, Signer_V2 { 69 | static Generate() { 70 | return new InMemoryAccountContext_V2(PrivateKey.Generate()); 71 | } 72 | 73 | constructor(readonly privateKey: PrivateKey) { 74 | this.publicKey = privateKey.toPublicKey(); 75 | this.ctx = InMemoryAccountContext.New(this.privateKey); 76 | } 77 | 78 | public publicKey: PublicKey; 79 | private ctx: InMemoryAccountContext; 80 | 81 | signEvent( 82 | event: UnsignedNostrEvent, 83 | ): Promise | Error> { 84 | return this.ctx.signEvent(event); 85 | } 86 | 87 | encrypt(pubkey: string, plaintext: string, algorithm: "nip44" | "nip4"): Promise { 88 | return this.ctx.encrypt(pubkey, plaintext, algorithm); 89 | } 90 | 91 | decrypt(pubkey: string, ciphertext: string, algorithm?: "nip44" | "nip4"): Promise { 92 | return this.ctx.decrypt(pubkey, ciphertext, algorithm); 93 | } 94 | 95 | async signEventV2( 96 | event: T, 97 | ): Promise { 98 | { 99 | const buf = utf8Encode(stringify(event)); 100 | const id = encodeHex(sha256(buf)); 101 | const sig = encodeHex(await signId(id, this.privateKey.hex)); 102 | return { ...event, id, sig }; 103 | } 104 | } 105 | } 106 | 107 | export * from "./space-member.ts"; 108 | -------------------------------------------------------------------------------- /nip4.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ende stands for encryption decryption 3 | */ 4 | 5 | import { getSharedSecret } from "@noble/secp256k1"; 6 | 7 | export async function encrypt( 8 | publicKey: string, 9 | message: string, 10 | privateKey: string, 11 | ): Promise { 12 | let key; 13 | try { 14 | key = getSharedSecret(privateKey, "02" + publicKey); 15 | } catch (e) { 16 | return e as Error; 17 | } 18 | const normalizedKey = getNormalizedX(key); 19 | const encoder = new TextEncoder(); 20 | const iv = Uint8Array.from(randomBytes(16)); 21 | const plaintext = encoder.encode(message); 22 | const cryptoKey = await crypto.subtle.importKey( 23 | "raw", 24 | normalizedKey, 25 | { name: "AES-CBC" }, 26 | false, 27 | ["encrypt"], 28 | ); 29 | const ciphertext = await crypto.subtle.encrypt( 30 | { name: "AES-CBC", iv }, 31 | cryptoKey, 32 | plaintext, 33 | ); 34 | 35 | const ctb64 = toBase64(new Uint8Array(ciphertext)); 36 | const ivb64 = toBase64(new Uint8Array(iv.buffer)); 37 | return `${ctb64}?iv=${ivb64}`; 38 | } 39 | 40 | export async function decrypt( 41 | privateKey: string, 42 | publicKey: string, 43 | data: string, 44 | ): Promise { 45 | const key = getSharedSecret(privateKey, "02" + publicKey); // this line is very slow 46 | return decrypt_with_shared_secret(data, key); 47 | } 48 | 49 | export async function decrypt_with_shared_secret( 50 | data: string, 51 | sharedSecret: Uint8Array, 52 | ): Promise { 53 | const [ctb64, ivb64] = data.split("?iv="); 54 | const normalizedKey = getNormalizedX(sharedSecret); 55 | 56 | const cryptoKey = await crypto.subtle.importKey( 57 | "raw", 58 | normalizedKey, 59 | { name: "AES-CBC" }, 60 | false, 61 | ["decrypt"], 62 | ); 63 | let ciphertext: BufferSource; 64 | let iv: BufferSource; 65 | try { 66 | ciphertext = decodeBase64(ctb64); 67 | iv = decodeBase64(ivb64); 68 | } catch (e) { 69 | return new Error(`failed to decode, ${e}`); 70 | } 71 | 72 | try { 73 | const plaintext = await crypto.subtle.decrypt( 74 | { name: "AES-CBC", iv }, 75 | cryptoKey, 76 | ciphertext, 77 | ); 78 | const text = utf8Decode(plaintext); 79 | return text; 80 | } catch (e) { 81 | return new Error(`failed to decrypt, ${e}`); 82 | } 83 | } 84 | 85 | export function utf8Encode(str: string) { 86 | let encoder = new TextEncoder(); 87 | return encoder.encode(str); 88 | } 89 | 90 | export function utf8Decode(bin: Uint8Array | ArrayBuffer): string { 91 | let decoder = new TextDecoder(); 92 | return decoder.decode(bin); 93 | } 94 | 95 | function toBase64(uInt8Array: Uint8Array) { 96 | let strChunks = new Array(uInt8Array.length); 97 | let i = 0; 98 | for (let byte of uInt8Array) { 99 | strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string 100 | i++; 101 | } 102 | return btoa(strChunks.join("")); 103 | } 104 | 105 | function decodeBase64(base64String: string) { 106 | const binaryString = atob(base64String); 107 | const length = binaryString.length; 108 | const bytes = new Uint8Array(length); 109 | 110 | for (let i = 0; i < length; i++) { 111 | bytes[i] = binaryString.charCodeAt(i); 112 | } 113 | return bytes; 114 | } 115 | 116 | function getNormalizedX(key: Uint8Array): Uint8Array { 117 | return key.slice(1, 33); 118 | } 119 | 120 | function randomBytes(bytesLength: number = 32) { 121 | return crypto.getRandomValues(new Uint8Array(bytesLength)); 122 | } 123 | -------------------------------------------------------------------------------- /key.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from "./scure.ts"; 2 | import { decodeHex, encodeHex } from "@std/encoding"; 3 | import { utils } from "@noble/secp256k1"; 4 | import { schnorr } from "@noble/curves/secp256k1"; 5 | import { hexToNumber } from "@noble/ciphers/utils"; 6 | 7 | /** 8 | * see examples [here](./tests/example.test.ts) 9 | */ 10 | export class PrivateKey { 11 | static Generate() { 12 | const pri = utils.randomPrivateKey(); 13 | const key = new PrivateKey(pri); 14 | return key; 15 | } 16 | 17 | static FromHex(key: string) { 18 | const ok = is64Hex(key); 19 | if (!ok) { 20 | return new InvalidKey(key, "length " + key.length); 21 | } 22 | if (!utils.isValidPrivateKey(key)) { 23 | return new InvalidKey(key, "not a valid private key"); 24 | } 25 | 26 | const hex = decodeHex(key); 27 | return new PrivateKey(hex); 28 | } 29 | 30 | static FromBech32(key: string) { 31 | if (key.substring(0, 4) === "nsec") { 32 | try { 33 | const code = bech32.decode(key, 1500); 34 | const data = new Uint8Array(bech32.fromWords(code.words)); 35 | const hex = encodeHex(data); 36 | return PrivateKey.FromHex(hex); 37 | } catch (e) { 38 | return e as Error; 39 | } 40 | } 41 | return new Error(`${key} is not valid`); 42 | } 43 | 44 | static FromString(raw: string) { 45 | const key = PrivateKey.FromBech32(raw); 46 | if (key instanceof Error) { 47 | return PrivateKey.FromHex(raw); 48 | } 49 | return key; 50 | } 51 | 52 | public readonly bech32: string; 53 | public readonly hex: string; 54 | 55 | private constructor(private key: Uint8Array) { 56 | this.hex = encodeHex(key); 57 | const words = bech32.toWords(key); 58 | this.bech32 = bech32.encode("nsec", words, 1500); 59 | } 60 | 61 | toPublicKey(): PublicKey { 62 | const pub_bytes = schnorr.getPublicKey(this.key); 63 | const hex = encodeHex(pub_bytes); 64 | const pub = PublicKey.FromHex(hex); 65 | if (pub instanceof Error) { 66 | throw pub; // impossible 67 | } 68 | return pub; 69 | } 70 | } 71 | 72 | /** 73 | * see examples [here](./tests/example.test.ts) 74 | */ 75 | export class PublicKey { 76 | static FromString(key: string) { 77 | const pub = PublicKey.FromBech32(key); 78 | if (pub instanceof Error) { 79 | return PublicKey.FromHex(key); 80 | } 81 | return pub; 82 | } 83 | 84 | static FromHex(key: string) { 85 | const ok = is64Hex(key); 86 | if (!ok) { 87 | return new InvalidKey(key, "length " + key.length); 88 | } 89 | try { 90 | schnorr.utils.lift_x(hexToNumber(key)); 91 | } catch (e) { 92 | if (e instanceof Error) { 93 | return new InvalidKey(key, e.message); 94 | } else { 95 | throw e; // impossible 96 | } 97 | } 98 | return new PublicKey(key); 99 | } 100 | 101 | static FromBech32(key: string) { 102 | if (key.substring(0, 4) != "npub") { 103 | return new InvalidKey(key, "not a npub"); 104 | } 105 | try { 106 | const code = bech32.decode(key, 1500); 107 | const data = new Uint8Array(bech32.fromWords(code.words)); 108 | const hex = encodeHex(data); 109 | return PublicKey.FromHex(hex); 110 | } catch (e) { 111 | if (e instanceof Error) { 112 | return new InvalidKey(key, e.message); 113 | } else { 114 | throw e; // impossible 115 | } 116 | } 117 | } 118 | 119 | bech32(): string { 120 | const array = decodeHex(this.hex); 121 | const words = bech32.toWords(array); 122 | return bech32.encode("npub", words, 1500); 123 | } 124 | 125 | public readonly hex: string; 126 | 127 | private constructor(key: string) { 128 | this.hex = key; 129 | } 130 | } 131 | 132 | export function is64Hex(key: string) { 133 | return /^[0-9a-f]{64}$/.test(key); 134 | } 135 | 136 | export class InvalidKey extends Error { 137 | constructor(key: string, reason: string) { 138 | super(`key '${key}' is invalid, reason: ${reason}`); 139 | this.name = "InvalidKey"; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/example.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { PrivateKey, PublicKey } from "../key.ts"; 3 | import { InMemoryAccountContext, type NostrEvent, NostrKind, prepareNostrEvent } from "../nostr.ts"; 4 | import { SingleRelayConnection } from "../relay-single.ts"; 5 | import * as relayList from "./relay-list.test.ts"; 6 | import { type Signer } from "../nostr.ts"; 7 | 8 | Deno.test("SingleRelayConnection", async () => { 9 | const relay = SingleRelayConnection.New(relayList.blowater); 10 | if (relay instanceof Error) { 11 | console.error(relay); 12 | return; 13 | } 14 | const privateKey = PrivateKey.Generate(); 15 | const signer = InMemoryAccountContext.New(privateKey); 16 | const event = await prepareNostrEvent(signer, { 17 | content: "this is an example", 18 | kind: NostrKind.TEXT_NOTE, 19 | tags: [ 20 | ["p", signer.publicKey.hex], 21 | ], 22 | }) as NostrEvent; 23 | const ok = await relay.sendEvent(event); 24 | if (ok instanceof Error) { 25 | console.error(ok); 26 | return; 27 | } 28 | console.log("send ok", ok); 29 | const event_got = await relay.getEvent(event.id); 30 | if (event_got instanceof Error) { 31 | console.error(event_got); 32 | return; 33 | } 34 | console.log("event_got", event_got); 35 | 36 | // get all events created by this pubkey 37 | const stream = await relay.newSub("sub ID", { 38 | authors: [signer.publicKey.hex], 39 | }); 40 | if (stream instanceof Error) { 41 | console.error(stream); 42 | return; 43 | } 44 | for await (const response_message of stream.chan) { 45 | if (response_message.type == "EVENT") { 46 | console.log(response_message.event); 47 | } else if (response_message.type == "NOTICE") { 48 | console.log(response_message.note); 49 | } else if (response_message.type == "EOSE") { 50 | break; 51 | } else { 52 | console.log(response_message); 53 | } 54 | } 55 | await relay.close(); 56 | }); 57 | 58 | Deno.test("Key Handling", async () => { 59 | { 60 | const pubkey = PublicKey.FromString("invalid"); 61 | console.log(pubkey); // will be an error 62 | } 63 | { 64 | // construct a public key instance from hex 65 | const pubkey1 = PublicKey.FromString( 66 | "4191aff8d9b30ce6653a8eb5cb53c18cd0bd9827563783fe56563919a4616d4f", 67 | ); 68 | console.log(pubkey1); 69 | const pubkey2 = PublicKey.FromHex("4191aff8d9b30ce6653a8eb5cb53c18cd0bd9827563783fe56563919a4616d4f"); 70 | console.log(pubkey1); 71 | assertEquals(pubkey1, pubkey2); // same 72 | } 73 | { 74 | // construct a public key instance from npub 75 | const pubkey1 = PublicKey.FromString( 76 | "npub1gxg6l7xekvxwvef6366uk57p3ngtmxp82cmc8ljk2cu3nfrpd48sc67nft", 77 | ); 78 | console.log(pubkey1); 79 | const pubkey2 = PublicKey.FromBech32( 80 | "npub1gxg6l7xekvxwvef6366uk57p3ngtmxp82cmc8ljk2cu3nfrpd48sc67nft", 81 | ); 82 | console.log(pubkey2); 83 | assertEquals(pubkey1, pubkey2); // same 84 | } 85 | ///////////////// 86 | // Private Key // 87 | ///////////////// 88 | { 89 | const pri = PrivateKey.Generate(); 90 | // usually the program reads 91 | // a stored private string from a secure storage 92 | // instead of having plain text in source code 93 | const pri2 = PrivateKey.FromString( 94 | "6a59b5296384f9eebd3afbea52c4b921d8ec3fc9ea8b2f56d31185663bab561d", 95 | ); 96 | const pri3 = PrivateKey.FromHex("hex format"); 97 | const pri4 = PrivateKey.FromBech32("nsec format"); 98 | 99 | // get the corresponding public key from this private key 100 | const pub = pri.toPublicKey(); 101 | } 102 | //////////// 103 | // Signer // 104 | //////////// 105 | const pri = PrivateKey.Generate(); 106 | // constructing a signer from a private key 107 | const signer1: Signer = InMemoryAccountContext.New(pri); 108 | // generate a signer 109 | const signer2: Signer = InMemoryAccountContext.Generate(); 110 | // constructing a Signer from string may result in an error 111 | // because the string may be malformed 112 | const signer3: Signer | Error = InMemoryAccountContext.FromString( 113 | "private key string in either hex or nsec format", 114 | ); 115 | }); 116 | -------------------------------------------------------------------------------- /nip4.test.ts: -------------------------------------------------------------------------------- 1 | import * as ende from "./nip4.ts"; 2 | import { assertEquals, assertNotInstanceOf, fail } from "@std/assert"; 3 | import { utf8Decode, utf8Encode } from "./nip4.ts"; 4 | import { PrivateKey, PublicKey } from "./key.ts"; 5 | import { InMemoryAccountContext, NostrKind } from "./nostr.ts"; 6 | import { prepareEncryptedNostrEvent } from "./event.ts"; 7 | import { ProjectivePoint } from "@noble/secp256k1"; 8 | Deno.test("utf8 encrypt & decrypt", async (t) => { 9 | let pri1 = PrivateKey.Generate(); 10 | let pub1 = pri1.toPublicKey(); 11 | 12 | let pri2 = PrivateKey.Generate(); 13 | let pub2 = pri2.toPublicKey(); 14 | 15 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax 16 | // the max number of arguments for spread operator is 126991 17 | let originalBin = new Uint8Array(126991); 18 | for (let i = 0; i < originalBin.length; i++) { 19 | // https://en.wikipedia.org/wiki/UTF-8 20 | originalBin.fill(Math.floor(Math.random() * Math.pow(2, 7)), i); 21 | } 22 | 23 | let originalStr = utf8Decode(originalBin); 24 | 25 | let start = Date.now(); 26 | let encrypted = await ende.encrypt(pub2.hex, originalStr, pri1.hex); 27 | if (encrypted instanceof Error) { 28 | fail(encrypted.message); 29 | } 30 | let t2 = Date.now(); 31 | console.log("encrypt cost", t2 - start, "ms"); 32 | let decrypted1 = await ende.decrypt(pri2.hex, pub1.hex, encrypted); // decrypt with receiver pri & sender pub 33 | if (decrypted1 instanceof Error) { 34 | fail(decrypted1.message); 35 | } 36 | let t3 = Date.now(); 37 | console.log("decrypt cost", t3 - t2, "ms"); 38 | let decrypted2 = await ende.decrypt(pri1.hex, pub2.hex, encrypted); // decrypt with sender pri & reciver pub 39 | 40 | assertEquals(decrypted1, originalStr); 41 | assertEquals(decrypted2, originalStr); 42 | 43 | let decryptedBin1 = utf8Encode(decrypted1); 44 | assertEquals(decryptedBin1.length, originalBin.length); 45 | assertEquals(decryptedBin1.byteLength, originalBin.byteLength); 46 | 47 | await t.step("decrpt invalid content", async () => { 48 | const err = await ende.decrypt(pri2.hex, pub1.hex, "a random string"); 49 | assertEquals(err instanceof Error, true); 50 | assertEquals( 51 | err.toString(), 52 | "Error: failed to decode, InvalidCharacterError: Failed to decode base64", 53 | ); 54 | const invalidIv64 = await ende.decrypt(pri2.hex, pub1.hex, "5l2hCloJ8iFAHpfr2UkuYg=="); 55 | assertEquals(invalidIv64 instanceof Error, true); 56 | assertEquals( 57 | invalidIv64.toString(), 58 | "Error: failed to decode, InvalidCharacterError: Failed to decode base64", 59 | ); 60 | }); 61 | }); 62 | 63 | Deno.test("decryption performance", async (t) => { 64 | const ctx = InMemoryAccountContext.New(PrivateKey.Generate()); 65 | const event = await prepareEncryptedNostrEvent( 66 | ctx, 67 | { 68 | encryptKey: ctx.publicKey, 69 | kind: NostrKind.DIRECT_MESSAGE, 70 | tags: [], 71 | content: "whatever", 72 | algorithm: "nip4", 73 | }, 74 | ); 75 | assertNotInstanceOf(event, Error); 76 | 77 | await t.step("decrypt", async () => { 78 | for (let i = 0; i < 100; i++) { 79 | const err = await ctx.decrypt(ctx.publicKey.hex, event.content, "nip4"); 80 | if (err instanceof Error) { 81 | fail(err.message); 82 | } 83 | } 84 | }); 85 | }); 86 | 87 | Deno.test("decryption performance: large data", async (t) => { 88 | const ctx = InMemoryAccountContext.New(PrivateKey.Generate()); 89 | const data = "1".repeat(10 * 1024 * 1024); // 10 MB 90 | const event = await prepareEncryptedNostrEvent(ctx, { 91 | encryptKey: ctx.publicKey, 92 | kind: NostrKind.DIRECT_MESSAGE, 93 | content: data, 94 | algorithm: "nip4", 95 | }); 96 | assertNotInstanceOf(event, Error); 97 | 98 | let result; 99 | await t.step("decrypt", async () => { 100 | result = await ctx.decrypt(ctx.publicKey.hex, event.content, "nip4"); 101 | if (result instanceof Error) fail(result.message); 102 | }); 103 | assertEquals(result, data); 104 | }); 105 | 106 | Deno.test("failure", async () => { 107 | const pub = "ac68c5e86deed0cc0f5b36ddb7eda5d5a1c59f9cc773453a986141a02430de3a"; 108 | const encrypter = InMemoryAccountContext.Generate(); 109 | const err = await encrypter.encrypt(pub, "123", "nip4") as Error; 110 | assertEquals(err.message, "sqrt invalid"); 111 | }); 112 | -------------------------------------------------------------------------------- /nip44.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64, encodeBase64 } from "@std/encoding"; 2 | 3 | import { chacha20 } from "@noble/ciphers/chacha"; 4 | import { ensureBytes, equalBytes } from "@noble/ciphers/utils"; 5 | import { expand as hkdf_expand, extract as hkdf_extract } from "@noble/hashes/hkdf"; 6 | import { hmac } from "@noble/hashes/hmac"; 7 | import { sha256 } from "@noble/hashes/sha256"; 8 | import { concatBytes, randomBytes } from "@noble/hashes/utils"; 9 | import { secp256k1 } from "@noble/curves/secp256k1"; 10 | 11 | const decoder = new TextDecoder(); 12 | const encoder = new TextEncoder(); 13 | 14 | const minPlaintextSize = 0x0001; // 1b msg => padded to 32b 15 | const maxPlaintextSize = 0xffff; // 65535 (64kb-1) => padded to 64kb 16 | 17 | export function encrypt( 18 | plaintext: string, 19 | conversationKey: Uint8Array, 20 | nonce = randomBytes(32), 21 | ): string | Error { 22 | const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce); 23 | const padded = pad(plaintext); 24 | if (padded instanceof Error) { 25 | return padded; 26 | } 27 | const ciphertext = chacha20(chacha_key, chacha_nonce, padded); 28 | const mac = hmacAad(hmac_key, ciphertext, nonce); 29 | if (mac instanceof Error) { 30 | return mac; 31 | } 32 | return encodeBase64(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac)); 33 | } 34 | 35 | export function decrypt(payload: string, conversationKey: Uint8Array): string | Error { 36 | const decoded = decodePayload(payload); 37 | if (decoded instanceof Error) { 38 | return decoded; 39 | } 40 | const { nonce, ciphertext, mac } = decoded; 41 | const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce); 42 | const calculatedMac = hmacAad(hmac_key, ciphertext, nonce); 43 | if (calculatedMac instanceof Error) { 44 | return calculatedMac; 45 | } 46 | if (!equalBytes(calculatedMac, mac)) { 47 | return new Error("invalid MAC"); 48 | } 49 | const padded = chacha20(chacha_key, chacha_nonce, ciphertext); 50 | return unpad(padded); 51 | } 52 | 53 | export function getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array | Error { 54 | try { 55 | const sharedX = secp256k1.getSharedSecret(privkeyA, "02" + pubkeyB).subarray(1, 33); 56 | return hkdf_extract(sha256, sharedX, "nip44-v2"); 57 | } catch (e) { 58 | if (e instanceof Error == false) { 59 | throw e; // impossible 60 | } 61 | return e; 62 | } 63 | } 64 | 65 | export function calcPaddedLen(len: number): number | Error { 66 | if (!Number.isSafeInteger(len) || len < 1) return new Error("expected positive integer"); 67 | if (len <= 32) return 32; 68 | const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1); 69 | const chunk = nextPower <= 256 ? 32 : nextPower / 8; 70 | return chunk * (Math.floor((len - 1) / chunk) + 1); 71 | } 72 | 73 | function pad(plaintext: string): Uint8Array | Error { 74 | const unpadded = encoder.encode(plaintext); 75 | const unpaddedLen = unpadded.length; 76 | const prefix = writeU16BE(unpaddedLen); 77 | if (prefix instanceof Error) return prefix; 78 | const padded_len = calcPaddedLen(unpaddedLen); 79 | if (padded_len instanceof Error) { 80 | return padded_len; 81 | } 82 | const suffix = new Uint8Array(padded_len - unpaddedLen); 83 | return concatBytes(prefix, unpadded, suffix); 84 | } 85 | 86 | function writeU16BE(num: number) { 87 | if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize) { 88 | return new Error("invalid plaintext size: must be between 1 and 65535 bytes"); 89 | } 90 | const arr = new Uint8Array(2); 91 | new DataView(arr.buffer).setUint16(0, num, false); 92 | return arr; 93 | } 94 | 95 | function unpad(padded: Uint8Array): string | Error { 96 | const unpaddedLen = new DataView(padded.buffer).getUint16(0); 97 | const unpadded = padded.subarray(2, 2 + unpaddedLen); 98 | if ( 99 | unpaddedLen < minPlaintextSize || 100 | unpaddedLen > maxPlaintextSize || 101 | unpadded.length !== unpaddedLen 102 | ) { 103 | return new Error("invalid padding"); 104 | } 105 | const padded_len = calcPaddedLen(unpaddedLen); 106 | if (padded_len instanceof Error) { 107 | return padded_len; 108 | } 109 | if (padded.length !== 2 + padded_len) { 110 | return new Error("invalid padding"); 111 | } 112 | return decoder.decode(unpadded); 113 | } 114 | 115 | // metadata: always 65b (version: 1b, nonce: 32b, max: 32b) 116 | // plaintext: 1b to 0xffff 117 | // padded plaintext: 32b to 0xffff 118 | // ciphertext: 32b+2 to 0xffff+2 119 | // raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) 120 | // compressed payload (base64): 132b to 87472b 121 | function decodePayload(payload: string) { 122 | if (typeof payload !== "string") return new Error("payload must be a valid string"); 123 | const plen = payload.length; 124 | if (plen < 132 || plen > 87472) return new Error("invalid payload length: " + plen); 125 | if (payload[0] === "#") return new Error("unknown encryption version"); 126 | let data: Uint8Array; 127 | try { 128 | data = decodeBase64(payload); 129 | } catch (error) { 130 | if (error instanceof Error == false) { 131 | throw error; // impossible 132 | } 133 | return new Error("invalid base64: " + error.message); 134 | } 135 | const dlen = data.length; 136 | if (dlen < 99 || dlen > 65603) return new Error("invalid data length: " + dlen); 137 | const vers = data[0]; 138 | if (vers !== 2) return new Error("unknown encryption version " + vers); 139 | return { 140 | nonce: data.subarray(1, 33), 141 | ciphertext: data.subarray(33, -32), 142 | mac: data.subarray(-32), 143 | }; 144 | } 145 | 146 | function getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) { 147 | ensureBytes(conversationKey, 32); 148 | ensureBytes(nonce, 32); 149 | const keys = hkdf_expand(sha256, conversationKey, nonce, 76); 150 | return { 151 | chacha_key: keys.subarray(0, 32), 152 | chacha_nonce: keys.subarray(32, 44), 153 | hmac_key: keys.subarray(44, 76), 154 | }; 155 | } 156 | 157 | function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) { 158 | if (aad.length !== 32) return new Error("AAD associated data must be 32 bytes"); 159 | const combined = concatBytes(aad, message); 160 | return hmac(sha256, key, combined); 161 | } 162 | -------------------------------------------------------------------------------- /websocket.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any no-unused-vars require-await ban-unused-ignore 2 | import * as csp from "@blowater/csp"; 3 | import { type BidirectionalNetwork, type NextMessageType, WebSocketClosed } from "./relay-single.ts"; 4 | 5 | export enum CloseReason { 6 | ByClient = 4000, 7 | } 8 | 9 | export type WebSocketError = { 10 | readonly message: string; 11 | readonly error: any; 12 | }; 13 | 14 | export type WebSocketClosedEvent = { 15 | // Returns the WebSocket connection close code provided by the server. 16 | readonly code: number; 17 | // Returns the WebSocket connection close reason provided by the server. 18 | readonly reason: string; 19 | // Returns true if the connection closed cleanly; false otherwise. 20 | readonly wasClean: boolean; 21 | }; 22 | 23 | export class AsyncWebSocket implements BidirectionalNetwork { 24 | private readonly isSocketOpen = csp.chan(); 25 | private readonly messageChannel = csp.chan< 26 | | { 27 | type: "message"; 28 | data: string; 29 | } 30 | | { 31 | type: "error"; 32 | error: WebSocketError; 33 | } 34 | | { type: "open" } 35 | | { 36 | type: "closed"; 37 | event: WebSocketClosedEvent; 38 | } 39 | >(); 40 | private readonly onClose = csp.chan(); 41 | private closedEvent?: WebSocketClosedEvent; 42 | public readonly url: URL; 43 | 44 | static New(url: string, log: boolean): AsyncWebSocket | Error { 45 | try { 46 | const ws = new WebSocket(url); // could throw, caller should catch it, not part of the MDN doc 47 | return new AsyncWebSocket(ws, log); 48 | } catch (err) { 49 | if (err instanceof Error) { 50 | return err; 51 | } else { 52 | throw err; // impossible 53 | } 54 | } 55 | } 56 | 57 | private constructor( 58 | private readonly ws: WebSocket, 59 | public log: boolean, 60 | ) { 61 | this.url = new URL(ws.url); 62 | this.ws.onopen = async (event: Event) => { 63 | if (log) { 64 | console.log(ws.url, "openned"); 65 | } 66 | await this.isSocketOpen.close(); 67 | await this.messageChannel.put({ 68 | type: "open", 69 | }); 70 | }; 71 | 72 | this.ws.onmessage = (event: MessageEvent) => { 73 | this.messageChannel.put({ 74 | type: "message", 75 | data: event.data, 76 | }); 77 | }; 78 | 79 | // @ts-ignore: bypass type annotations of onerror 80 | this.ws.onerror = async (event: ErrorEvent) => { 81 | const err = await this.messageChannel.put({ 82 | type: "error", 83 | error: event, 84 | }); 85 | if (err instanceof Error) { 86 | console.error(err); 87 | } 88 | }; 89 | 90 | this.ws.onclose = async (event: CloseEvent) => { 91 | if (this.log) { 92 | console.log(ws.url, "closed", event.code, event.reason); 93 | } 94 | this.closedEvent = { 95 | code: event.code, 96 | reason: event.reason, 97 | wasClean: event.wasClean, 98 | }; 99 | await this.onClose.close(); 100 | await this.messageChannel.put({ 101 | type: "closed", 102 | event: { 103 | code: event.code, 104 | reason: event.reason, 105 | wasClean: event.wasClean, 106 | }, 107 | }); 108 | }; 109 | } 110 | 111 | async nextMessage(): Promise { 112 | const msg = await this.messageChannel.pop(); 113 | if (msg == csp.closed) { 114 | return { 115 | type: "WebSocketClosed", 116 | error: new WebSocketClosed(this.url, this.status()), 117 | }; 118 | } 119 | if (msg.type == "error") { 120 | if ( 121 | msg.error.message == 122 | "Error: failed to lookup address information: nodename nor servname provided, or not known" 123 | ) { 124 | return { 125 | type: "FailedToLookupAddress", 126 | error: msg.error.message, 127 | }; 128 | } else { 129 | return { 130 | type: "OtherError", 131 | error: msg.error, 132 | }; 133 | } 134 | } 135 | if (msg.type == "open" || msg.type == "closed") { 136 | return msg; 137 | } 138 | return { 139 | type: "messsage", 140 | data: msg.data, 141 | }; 142 | } 143 | 144 | async send(str: string | ArrayBufferLike | Blob | ArrayBufferView) { 145 | let err = await this.untilOpen(); 146 | if (err) { 147 | return err; 148 | } 149 | try { 150 | this.ws.send(str); 151 | } catch (e) { 152 | if (e instanceof Error == false) { 153 | throw e; // impossible 154 | } 155 | // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send#invalidstateerror 156 | if (e.message == "readyState not OPEN") { 157 | return new WebSocketClosed(this.url, this.status()); 158 | } 159 | return e; 160 | } 161 | } 162 | 163 | async close( 164 | code?: number, 165 | reason?: string, 166 | force?: boolean, 167 | ): Promise { 168 | if ( 169 | this.ws.readyState == WebSocket.CLOSED || 170 | this.ws.readyState == WebSocket.CLOSING 171 | ) { 172 | return new CloseTwice(this.ws.url); 173 | } 174 | 175 | this.ws.close(code, reason); 176 | 177 | if (this.log) { 178 | console.log(this.status(), this.url.host + this.url.pathname); 179 | } 180 | if (force) { 181 | return; 182 | } 183 | await this.onClose.pop(); 184 | if (this.log) { 185 | console.log(this.status(), this.url.host + this.url.pathname, this.closedEvent); 186 | } 187 | return this.closedEvent; 188 | } 189 | 190 | // only unblocks when the socket is open 191 | // if the socket is closed or closing, blocks forever 192 | async untilOpen() { 193 | if (this.ws.readyState === WebSocket.CLOSED) { 194 | return new WebSocketClosed(this.url, this.status()); 195 | } 196 | if (this.ws.readyState === WebSocket.CLOSING) { 197 | return new WebSocketClosed(this.url, this.status()); 198 | } 199 | if (this.ws.readyState === WebSocket.OPEN) { 200 | return; 201 | } 202 | if (this.ws.readyState === WebSocket.CONNECTING) { 203 | const signal = csp.chan(); 204 | (async () => { 205 | await this.isSocketOpen.pop(); 206 | await signal.put(undefined); 207 | })(); 208 | (async () => { 209 | await this.onClose.pop(); 210 | await signal.put(this.closedEvent); 211 | })(); 212 | const sig = await signal.pop(); 213 | if (sig != undefined) { 214 | if (sig == csp.closed) { 215 | return new WebSocketClosed(this.url, this.status()); 216 | } else { 217 | return new WebSocketClosed(this.url, this.status(), sig); 218 | } 219 | } 220 | return; 221 | } 222 | // unreachable 223 | throw new Error(`readyState:${this.ws.readyState}`); 224 | } 225 | 226 | status = (): WebSocketReadyState => { 227 | switch (this.ws.readyState) { 228 | case WebSocket.CONNECTING: 229 | return "Connecting"; 230 | case WebSocket.OPEN: 231 | return "Open"; 232 | case WebSocket.CLOSING: 233 | return "Closing"; 234 | case WebSocket.CLOSED: 235 | return "Closed"; 236 | } 237 | throw new Error("unreachable"); 238 | }; 239 | } 240 | export type WebSocketReadyState = "Connecting" | "Open" | "Closing" | "Closed"; 241 | 242 | export class CloseTwice extends Error { 243 | constructor(public url: string) { 244 | super(`can not close Web Socket ${url} twice`); 245 | this.url = url; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /nip19.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertIsError, fail } from "@std/assert"; 2 | import { InvalidKey, PrivateKey, PublicKey } from "./key.ts"; 3 | import { 4 | type AddressPointer, 5 | type EventPointer, 6 | Nevent, 7 | NostrAddress, 8 | NostrProfile, 9 | NoteID, 10 | } from "./nip19.ts"; 11 | import { relays } from "./tests/relay-list.test.ts"; 12 | import { NostrKind } from "./nostr.ts"; 13 | 14 | Deno.test("nip19 public key", () => { 15 | const key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 16 | const npub = "npub1424242424242424242424242424242424242424242424242424qamrcaj"; 17 | 18 | const pkey = PublicKey.FromHex(key); 19 | if (pkey instanceof Error) fail(); 20 | assertEquals(key, pkey.hex); 21 | assertEquals(npub, pkey.bech32()); 22 | 23 | const pkey2 = PublicKey.FromBech32(npub); 24 | if (pkey2 instanceof Error) fail(); 25 | assertEquals(pkey.hex, pkey2.hex); 26 | assertEquals(npub, pkey2.bech32()); 27 | 28 | const pkey3 = PublicKey.FromHex(""); 29 | assertIsError(pkey3); 30 | 31 | const pkey4 = PublicKey.FromString(key); 32 | if (pkey4 instanceof Error) fail(); 33 | const pkey5 = PublicKey.FromString(npub); 34 | if (pkey5 instanceof Error) fail(); 35 | 36 | assertEquals(pkey4.hex, pkey5.hex); 37 | }); 38 | 39 | Deno.test("nip19 public key incorrect", () => { 40 | const pub = PublicKey.FromBech32("invalid"); 41 | if (pub instanceof Error) { 42 | assertEquals(pub.message, "key 'invalid' is invalid, reason: not a npub"); 43 | } else { 44 | fail("should be error"); 45 | } 46 | 47 | const pub2 = PublicKey.FromBech32( 48 | "npub1xxxxxxxxrhgtk4fgqdmpuqxv05u9raau3w0shay7msmr0dzs4m7sxxxxxx", 49 | ) as InvalidKey; 50 | assertEquals( 51 | pub2.message, 52 | `key 'npub1xxxxxxxxrhgtk4fgqdmpuqxv05u9raau3w0shay7msmr0dzs4m7sxxxxxx' is invalid, reason: Invalid checksum in npub1xxxxxxxxrhgtk4fgqdmpuqxv05u9raau3w0shay7msmr0dzs4m7sxxxxxx: expected "ym976l"`, 53 | ); 54 | }); 55 | 56 | Deno.test("nip19 public key performance", async (t) => { 57 | const key = PrivateKey.Generate().toPublicKey().hex; 58 | const count = 100000; 59 | await t.step(`${count}`, () => { 60 | for (let i = 0; i < count; i++) { 61 | PublicKey.FromHex(key); 62 | } 63 | }); 64 | }); 65 | 66 | Deno.test("nip19 private key", async (t) => { 67 | const key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 68 | const nsec = "nsec1424242424242424242424242424242424242424242424242424q3dgem8"; 69 | 70 | const pkey = PrivateKey.FromHex(key); 71 | if (pkey instanceof Error) fail(); 72 | assertEquals(key, pkey.hex); 73 | assertEquals(nsec, pkey.bech32); 74 | 75 | const pkey2 = PrivateKey.FromBech32(nsec); 76 | if (pkey2 instanceof Error) fail(); 77 | assertEquals(pkey.hex, pkey2.hex); 78 | assertEquals(nsec, pkey2.bech32); 79 | 80 | const pkey3 = PrivateKey.FromHex(""); 81 | assertIsError(pkey3); 82 | const pkey4 = PrivateKey.FromBech32(key); 83 | assertIsError(pkey4); 84 | 85 | await t.step("Invalid checksum", () => { 86 | const key = "nsec1alwevw7n7xxapp4g7c2v3l5qr7zkmxjrhlwqteh6rkh2527gm3qqgj3jh"; 87 | const pri = PrivateKey.FromBech32(key) as Error; 88 | assertEquals(pri instanceof Error, true); 89 | assertEquals( 90 | pri.message, 91 | `Invalid checksum in nsec1alwevw7n7xxapp4g7c2v3l5qr7zkmxjrhlwqteh6rkh2527gm3qqgj3jh: expected "29r5am"`, 92 | ); 93 | }); 94 | 95 | await t.step("private key from string", () => { 96 | const pri = PrivateKey.Generate(); 97 | const pri_1 = PrivateKey.FromString(pri.bech32) as PrivateKey; 98 | const pri_2 = PrivateKey.FromString(pri.hex) as PrivateKey; 99 | assertEquals(pri_1.hex, pri_2.hex); 100 | assertEquals(pri_1.bech32, pri_2.bech32); 101 | }); 102 | }); 103 | 104 | Deno.test("nip19 note", async () => { 105 | const note = "note16rqxdnalykdjm422plpc8056a2w9r5g8w6f9cy8ct9tfa5c493nqkp2ypm"; 106 | const hex = "d0c066cfbf259b2dd54a0fc383be9aea9c51d10776925c10f859569ed3152c66"; 107 | { 108 | const noteID = NoteID.FromBech32(note) as NoteID; 109 | assertEquals(noteID.hex, hex); 110 | assertEquals(noteID.bech32(), note); 111 | } 112 | { 113 | const noteID = NoteID.FromHex(hex) as NoteID; 114 | assertEquals(noteID.hex, hex); 115 | assertEquals(noteID.bech32(), note); 116 | } 117 | { 118 | const noteID1 = NoteID.FromString(hex) as NoteID; 119 | const noteID2 = NoteID.FromString(note) as NoteID; 120 | assertEquals(noteID1.hex, noteID2.hex); 121 | assertEquals(noteID1.hex, hex); 122 | assertEquals(noteID1.bech32(), noteID2.bech32()); 123 | assertEquals(noteID1.bech32(), note); 124 | } 125 | }); 126 | Deno.test("nip19 naddr", async () => { 127 | const identifier = "h5nqKSYouPcq1ZcUBbU8C"; 128 | const kind = NostrKind.Long_Form; 129 | const relays: string[] = []; 130 | const pubkeyhex = PrivateKey.Generate().toPublicKey(); 131 | const addressPointer: AddressPointer = { 132 | identifier: identifier, 133 | kind: kind, 134 | relays: relays, 135 | pubkey: pubkeyhex, 136 | }; 137 | 138 | const nostraddress = new NostrAddress(addressPointer); 139 | const naddr_encoded = nostraddress.encode(); 140 | if (naddr_encoded instanceof Error) { 141 | fail(naddr_encoded.message); 142 | } 143 | 144 | const naddr_decoded = NostrAddress.decode(naddr_encoded); 145 | if (naddr_decoded instanceof Error) { 146 | fail(naddr_decoded.message); 147 | } 148 | 149 | assertEquals(naddr_decoded.addr, addressPointer); 150 | }); 151 | 152 | Deno.test("nevent", async () => { 153 | const relays: string[] = [ 154 | "wss://yabu.me", 155 | ]; 156 | const pubkeyhex = PrivateKey.Generate().toPublicKey(); 157 | 158 | { 159 | const eventPointer: EventPointer = { 160 | id: "25524798c2182d1b20c87ba208aa5085a7ba34c9b54eb851977f7206591ab407", 161 | kind: NostrKind.META_DATA, // 0 162 | relays: relays, 163 | pubkey: pubkeyhex, 164 | }; 165 | 166 | const nostrevent = new Nevent(eventPointer); 167 | const nevent_encoded = nostrevent.encode(); 168 | const nevent_decoded = Nevent.decode(nevent_encoded); 169 | if (nevent_decoded instanceof Error) fail(nevent_decoded.message); 170 | 171 | assertEquals(nevent_decoded.pointer, eventPointer); 172 | } 173 | { 174 | const eventPointer: EventPointer = { 175 | id: "25524798c2182d1b20c87ba208aa5085a7ba34c9b54eb851977f7206591ab407", 176 | relays: relays, 177 | pubkey: pubkeyhex, 178 | }; 179 | 180 | const nostrevent = new Nevent(eventPointer); 181 | const nevent_encoded = nostrevent.encode(); 182 | const nevent_decoded = Nevent.decode(nevent_encoded); 183 | if (nevent_decoded instanceof Error) fail(nevent_decoded.message); 184 | 185 | assertEquals(nevent_decoded.pointer, eventPointer); 186 | } 187 | }); 188 | 189 | Deno.test("nip19 nprofile", async (t) => { 190 | await t.step("success case", () => { 191 | const pubkey = PrivateKey.Generate().toPublicKey(); 192 | const nProfile = new NostrProfile(pubkey, relays); 193 | 194 | const encoded_nProfile = nProfile.encode(); 195 | if (encoded_nProfile instanceof Error) { 196 | fail(encoded_nProfile.message); 197 | } 198 | 199 | const decoded_nProfile = NostrProfile.decode(encoded_nProfile); 200 | if (decoded_nProfile instanceof Error) { 201 | fail(decoded_nProfile.message); 202 | } 203 | 204 | assertEquals(decoded_nProfile.pubkey.hex, nProfile.pubkey.hex); 205 | assertEquals(decoded_nProfile.relays, nProfile.relays); 206 | }); 207 | 208 | await t.step("failure case", () => { 209 | const randomnProfile = "nprofilexxxxxxxx"; 210 | const decode_random = NostrProfile.decode(randomnProfile); 211 | if (decode_random instanceof Error) { 212 | assertEquals( 213 | decode_random.message, 214 | `failed to decode ${randomnProfile}, Letter "1" must be present between prefix and data only`, 215 | ); 216 | } else { 217 | fail(); 218 | } 219 | }); 220 | }); 221 | 222 | Deno.test("point is not on curve", () => { 223 | const hex = "ac68c5e86deed0cc0f5b36ddb7eda5d5a1c59f9cc773453a986141a02430de3a"; 224 | const err_message = 225 | "key 'ac68c5e86deed0cc0f5b36ddb7eda5d5a1c59f9cc773453a986141a02430de3a' is invalid, reason: Cannot find square root"; 226 | { 227 | const key1 = PublicKey.FromHex(hex) as Error; 228 | assertEquals(key1.message, err_message); 229 | 230 | const key2 = PublicKey.FromString(hex) as Error; 231 | assertEquals(key2.message, err_message); 232 | } 233 | { 234 | const key1 = PrivateKey.FromHex(hex) as PrivateKey; 235 | assertEquals(key1.hex, hex); 236 | 237 | const key2 = PrivateKey.FromString(hex) as PrivateKey; 238 | assertEquals(key2, key1); 239 | 240 | const pub1 = key1.toPublicKey(); 241 | const pub2 = PublicKey.FromString(pub1.bech32()) as PublicKey; 242 | const pub3 = PublicKey.FromString(pub1.hex) as PublicKey; 243 | assertEquals(pub2, pub3); 244 | assertEquals(pub1, pub2); 245 | } 246 | }); 247 | -------------------------------------------------------------------------------- /tests/relay-pool.test.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryAccountContext, type NostrEvent, NostrKind } from "../nostr.ts"; 2 | import { blowater, damus, nos, relays } from "./relay-list.test.ts"; 3 | import { SingleRelayConnection, SubscriptionAlreadyExist } from "../relay-single.ts"; 4 | import { AsyncWebSocket } from "../websocket.ts"; 5 | import { ConnectionPool } from "../relay-pool.ts"; 6 | import { prepareNostrEvent } from "../event.ts"; 7 | import { PrivateKey } from "../key.ts"; 8 | import * as csp from "@blowater/csp"; 9 | import { assertEquals, assertNotEquals, assertNotInstanceOf, fail } from "@std/assert"; 10 | 11 | Deno.test("ConnectionPool close gracefully 1", async () => { 12 | const pool = new ConnectionPool(); 13 | await pool.close(); // otherwise the coroutine in the constructor will run forever 14 | }); 15 | 16 | Deno.test("ConnectionPool close gracefully 2", async () => { 17 | // able to open & close 18 | const client = SingleRelayConnection.New(damus); 19 | if (client instanceof Error) { 20 | fail(client.message); 21 | } 22 | const pool = new ConnectionPool(); 23 | { 24 | const err = await pool.addRelay(client); 25 | assertNotInstanceOf(err, Error); 26 | await csp.sleep(10); 27 | } 28 | await pool.close(); 29 | }); 30 | 31 | Deno.test("ConnectionPool open multiple relays concurrently & close", async () => { 32 | const pool = new ConnectionPool(); 33 | const errs = await pool.addRelayURLs(relays); 34 | if (errs != undefined) { 35 | if (errs.length >= relays.length / 2) { // as long as 50%+ relays are available 36 | for (const err of errs) { 37 | console.error(err); 38 | } 39 | fail(); 40 | } 41 | } 42 | await pool.close(); 43 | }); 44 | 45 | Deno.test("ConnectionPool newSub & close", async () => { 46 | // able to open & close 47 | const client = SingleRelayConnection.New(damus); 48 | if (client instanceof Error) { 49 | fail(client.message); 50 | } 51 | const connectionPool = new ConnectionPool(); 52 | { 53 | const _relay = await connectionPool.addRelay(client); 54 | assertEquals(_relay, client); 55 | } 56 | const sub = await connectionPool.newSub("1", { kinds: [0], limit: 1 }); 57 | if (sub instanceof Error) { 58 | console.log(sub); 59 | fail(); 60 | } 61 | await connectionPool.close(); 62 | if (sub instanceof SubscriptionAlreadyExist) fail(sub.message); 63 | assertEquals( 64 | sub.chan.closed(), 65 | true, 66 | ); 67 | }); 68 | 69 | Deno.test("ConnectionPool: open,close,open again | no relay", async () => { 70 | const pool = new ConnectionPool(); 71 | { 72 | // open 73 | const subID = "1"; 74 | const sub = await pool.newSub(subID, { kinds: [0], limit: 1 }); 75 | if (sub instanceof Error) fail(sub.message); 76 | assertEquals(sub.chan.closed(), false); 77 | 78 | // close 79 | await pool.closeSub(subID); 80 | assertEquals(sub.chan.closed(), true); 81 | 82 | // open again 83 | const sub2 = await pool.newSub(subID, { kinds: [0], limit: 1 }); 84 | if (sub2 instanceof Error) fail(sub2.message); 85 | assertEquals(sub2.chan.closed(), false); 86 | } 87 | await pool.close(); 88 | }); 89 | 90 | Deno.test("ConnectionPool close subscription", async (t) => { 91 | await t.step("single relay", async () => { 92 | const pool = new ConnectionPool(); 93 | const err = await pool.addRelayURLs(relays); 94 | if (err instanceof Error) fail(err.message); 95 | { 96 | const subID = "x"; 97 | const sub = await pool.newSub(subID, { limit: 1 }); 98 | assertNotInstanceOf(sub, Error); 99 | await pool.closeSub(subID); 100 | assertEquals(sub.chan.closed(), true); 101 | } 102 | await pool.close(); 103 | }); 104 | }); 105 | 106 | Deno.test("ConnectionPool register the same relay twice", async () => { 107 | const pool = new ConnectionPool(); 108 | const client = SingleRelayConnection.New(damus); 109 | if (client instanceof Error) { 110 | fail(client.message); 111 | } 112 | 113 | { 114 | const _relay = await pool.addRelay(client); 115 | assertEquals(_relay, client); 116 | } 117 | 118 | const _relay = await pool.addRelay(client); 119 | if (_relay instanceof SingleRelayConnection) { 120 | assertEquals(_relay.url, client.url); 121 | } else { 122 | fail(_relay?.message); 123 | } 124 | 125 | await pool.close(); 126 | }); 127 | 128 | Deno.test("ConnectionPool able to subscribe before adding relays", async () => { 129 | const pool = new ConnectionPool(); 130 | 131 | const chan = await pool.newSub("1", { 132 | kinds: [NostrKind.DELETE], 133 | limit: 1, 134 | }); 135 | if (chan instanceof Error) { 136 | fail(chan.message); 137 | } 138 | 139 | const client = SingleRelayConnection.New(damus); 140 | if (client instanceof Error) { 141 | fail(client.message); 142 | } 143 | 144 | const _relay = await pool.addRelay(client); 145 | assertEquals(_relay, client); 146 | 147 | await pool.sendEvent( 148 | await prepareNostrEvent(InMemoryAccountContext.Generate(), { 149 | kind: NostrKind.DELETE, 150 | content: "", 151 | }) as NostrEvent, 152 | ); 153 | 154 | const msg = await chan.chan.pop(); 155 | if (msg === csp.closed) { 156 | fail(); 157 | } 158 | // don't care the value, just need to make sure that it's from the same relay 159 | assertEquals(msg.url.toString(), new URL(damus).toString()); 160 | await pool.close(); 161 | }); 162 | 163 | Deno.test("newSub 2 times & add relay url later", async (t) => { 164 | const pool = new ConnectionPool({ ws: AsyncWebSocket.New }); 165 | { 166 | const stream1 = await pool.newSub("sub1", { 167 | kinds: [NostrKind.META_DATA], 168 | limit: 1, 169 | }); 170 | if (stream1 instanceof Error) { 171 | fail(stream1.message); 172 | } 173 | const stream2 = await pool.newSub("sub2", { 174 | kinds: [NostrKind.Custom_App_Data], 175 | limit: 1, 176 | }); 177 | if (stream2 instanceof Error) { 178 | fail(stream2.message); 179 | } 180 | 181 | // add relay after creating subscriptions 182 | // should not create starvation for readers 183 | await pool.addRelayURL(nos); 184 | 185 | const res1 = await stream1.chan.pop(); 186 | const res2 = await stream1.chan.pop(); 187 | const res3 = await stream2.chan.pop(); 188 | const res4 = await stream2.chan.pop(); 189 | // as long as it does not block 190 | } 191 | await pool.close(); 192 | }); 193 | 194 | Deno.test("send & get event", async () => { 195 | const pool = new ConnectionPool(); 196 | const err = await pool.addRelayURLs(relays); 197 | if (err && err.length == relays.length) { // if all relays failed to connect 198 | console.log(err); 199 | fail(); 200 | } 201 | { 202 | const event = await prepareNostrEvent(InMemoryAccountContext.Generate(), { 203 | kind: NostrKind.CONTACTS, 204 | content: "", 205 | }) as NostrEvent; 206 | const err = await pool.sendEvent(event); 207 | if (err) fail(err.message); 208 | const e = await pool.getEvent(event.id); 209 | if (e instanceof Error) fail(e.message); 210 | assertEquals(e, event); 211 | } 212 | await pool.close(); 213 | }); 214 | 215 | Deno.test("URL handling", async (t) => { 216 | await t.step("wss://blowater.nostr1.com", async () => { 217 | const pool = new ConnectionPool(); 218 | pool.addRelayURL("wss://blowater.nostr1.com"); 219 | { 220 | const relay = pool.getRelay(blowater); 221 | // URL.toString contains ending / in the string 222 | const relay2 = pool.getRelay(new URL(blowater).toString()); 223 | const relay3 = pool.getRelay(new URL(blowater)); 224 | assertEquals(relay, relay2); 225 | assertEquals(relay, relay3); 226 | assertNotEquals(relay, undefined); 227 | } 228 | await pool.close(); 229 | }); 230 | await t.step("wss://blowater.nostr1.com/", async () => { 231 | const pool = new ConnectionPool(); 232 | pool.addRelayURL("wss://blowater.nostr1.com/"); 233 | { 234 | const relay = pool.getRelay(blowater); 235 | // URL.toString contains ending / in the string 236 | const relay2 = pool.getRelay(new URL(blowater).toString()); 237 | const relay3 = pool.getRelay(new URL(blowater)); 238 | assertEquals(relay, relay2); 239 | assertEquals(relay, relay3); 240 | assertNotEquals(relay, undefined); 241 | } 242 | await pool.close(); 243 | }); 244 | await t.step("with search params", async () => { 245 | const pool = new ConnectionPool(); 246 | pool.addRelayURL("wss://blowater.nostr1.com/?x=1"); 247 | { 248 | const relay = pool.getRelay(blowater); 249 | // URL.toString contains ending / in the string 250 | const relay2 = pool.getRelay(new URL(blowater).toString()); 251 | const relay3 = pool.getRelay(new URL(blowater)); 252 | assertEquals(relay, relay2); 253 | assertEquals(relay, relay3); 254 | assertNotEquals(relay, undefined); 255 | } 256 | await pool.close(); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /nip19.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from "./scure.ts"; 2 | import { utf8Decode, utf8Encode } from "./nip4.ts"; 3 | import { PublicKey } from "./key.ts"; 4 | import { NostrKind } from "./nostr.ts"; 5 | import { decodeHex, encodeHex } from "@std/encoding"; 6 | import { concatBytes } from "@noble/hashes/utils"; 7 | 8 | export class NoteID { 9 | static FromBech32(id: string): NoteID | Error { 10 | if (id.substring(0, 4) === "note") { 11 | try { 12 | return new NoteID(toHex(id)); 13 | } catch (e) { 14 | return e as Error; 15 | } 16 | } 17 | return new Error(`${id} is not valid`); 18 | } 19 | 20 | static FromHex(id: string) { 21 | return new NoteID(id); 22 | } 23 | 24 | static FromString(raw: string) { 25 | const key = this.FromBech32(raw); 26 | if (key instanceof Error) { 27 | return this.FromHex(raw); 28 | } 29 | return key; 30 | } 31 | 32 | private _bech32: string | undefined; 33 | private constructor(public readonly hex: string) {} 34 | 35 | bech32() { 36 | if (this._bech32) { 37 | return this._bech32; 38 | } 39 | this._bech32 = toBech32(this.hex, "note"); 40 | return this._bech32; 41 | } 42 | } 43 | 44 | function toBech32(hex: string, prefix: string) { 45 | const array = decodeHex(hex); 46 | const words = bech32.toWords(array); 47 | return bech32.encode(prefix, words, 1500); 48 | } 49 | 50 | function toHex(bech: string) { 51 | const code = bech32.decode(bech, 1500); 52 | const data = new Uint8Array(bech32.fromWords(code.words)); 53 | return encodeHex(data); 54 | } 55 | type TLV = { [t: number]: Uint8Array[] }; 56 | function encodeTLV(tlv: TLV): Uint8Array { 57 | const entries: Uint8Array[] = []; 58 | for (const [t, vs] of Object.entries(tlv)) { 59 | for (const v of vs) { 60 | const entry = new Uint8Array(v.length + 2); 61 | entry.set([parseInt(t)], 0); 62 | entry.set([v.length], 1); 63 | entry.set(v, 2); 64 | entries.push(entry); 65 | } 66 | } 67 | return concatBytes(...entries); 68 | } 69 | function parseTLV(data: Uint8Array): TLV | Error { 70 | const result: TLV = {}; 71 | let rest = data; 72 | while (rest.length > 0) { 73 | const t = rest[0]; 74 | const l = rest[1]; 75 | if (!l) { 76 | return new Error(`malformed TLV ${t}`); 77 | } 78 | const v = rest.slice(2, 2 + l); 79 | rest = rest.slice(2 + l); 80 | if (v.length < l) { 81 | return new Error(`not enough data to read on TLV ${t}`); 82 | } 83 | result[t] = result[t] || []; 84 | result[t].push(v); 85 | } 86 | return result; 87 | } 88 | export type AddressPointer = { 89 | identifier: string; 90 | pubkey: PublicKey; 91 | kind: NostrKind; 92 | relays?: string[]; 93 | }; 94 | 95 | // https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata 96 | export class NostrAddress { 97 | encode(): string | Error { 98 | const kind = new ArrayBuffer(4); 99 | new DataView(kind).setUint32(0, this.addr.kind, false); 100 | 101 | const data = encodeTLV({ 102 | 0: [utf8Encode(this.addr.identifier)], 103 | 1: (this.addr.relays || []).map((url) => utf8Encode(url)), 104 | 2: [decodeHex(this.addr.pubkey.hex)], 105 | 3: [new Uint8Array(kind)], 106 | }); 107 | 108 | const words = bech32.toWords(data); 109 | return bech32.encode("naddr", words, 1500); 110 | } 111 | static decode(naddr: string) { 112 | let words; 113 | try { 114 | const res = bech32.decode(naddr, 1500); 115 | words = res.words; 116 | } catch (e) { 117 | if (e instanceof Error == false) { 118 | throw e; // impossible 119 | } 120 | return new Error(`failed to decode ${naddr}, ${e.message}`); 121 | } 122 | 123 | const data = new Uint8Array(bech32.fromWords(words)); 124 | const tlv = parseTLV(data); 125 | if (tlv instanceof Error) return tlv; 126 | if (!tlv[0][0]) return new Error("missing TLV 0 for naddr"); 127 | if (!tlv[2][0]) return new Error("missing TLV 2 for naddr"); 128 | if (tlv[2][0].length !== 32) return new Error("TLV 2 should be 32 bytes"); 129 | if (!tlv[3][0]) return new Error("missing TLV 3 for naddr"); 130 | if (tlv[3][0].length !== 4) return new Error("TLV 3 should be 4 bytes"); 131 | const pubkey = PublicKey.FromHex(encodeHex(tlv[2][0])); 132 | if (pubkey instanceof Error) { 133 | return pubkey; 134 | } 135 | return new NostrAddress({ 136 | identifier: utf8Decode(tlv[0][0]), 137 | pubkey, 138 | kind: parseInt(encodeHex(tlv[3][0]), 16), 139 | relays: tlv[1] ? tlv[1].map((d) => utf8Decode(d)) : [], 140 | }); 141 | } 142 | public constructor(public readonly addr: AddressPointer) {} 143 | } 144 | 145 | // https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata 146 | export class NostrProfile { 147 | encode(): string | Error { 148 | const data = encodeTLV({ 149 | 0: [decodeHex(this.pubkey.hex)], 150 | 1: (this.relays || []).map((url) => utf8Encode(url)), 151 | }); 152 | const words = bech32.toWords(data); 153 | return bech32.encode("nprofile", words, 1500); 154 | } 155 | static decode(nprofile: string) { 156 | let words; 157 | try { 158 | const res = bech32.decode(nprofile, 1500); 159 | words = res.words; 160 | } catch (e) { 161 | if (e instanceof Error == false) { 162 | throw e; // impossible 163 | } 164 | return new Error(`failed to decode ${nprofile}, ${e.message}`); 165 | } 166 | 167 | const data = new Uint8Array(bech32.fromWords(words)); 168 | const tlv = parseTLV(data); 169 | if (tlv instanceof Error) { 170 | return tlv; 171 | } 172 | if (!tlv[0][0]) { 173 | return new Error("missing TLV 0 for nprofile"); 174 | } 175 | if (tlv[0][0].length !== 32) { 176 | return new Error("TLV 0 should be 32 bytes"); 177 | } 178 | const pubkey = PublicKey.FromHex(encodeHex(tlv[0][0])); 179 | if (pubkey instanceof Error) { 180 | return pubkey; 181 | } 182 | return new NostrProfile( 183 | pubkey, 184 | tlv[1] ? tlv[1].map((d) => utf8Decode(d)) : [], 185 | ); 186 | } 187 | public constructor( 188 | public readonly pubkey: PublicKey, 189 | public readonly relays?: string[], 190 | ) {} 191 | } 192 | 193 | export type EventPointer = { 194 | id: string; 195 | kind?: number; 196 | relays?: string[]; 197 | pubkey?: PublicKey; 198 | }; 199 | 200 | function integerToUint8Array(number: number) { 201 | // Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes). 202 | const uint8Array = new Uint8Array(4); 203 | 204 | // Use bitwise operations to extract the bytes. 205 | uint8Array[0] = (number >> 24) & 0xFF; // Most significant byte (MSB) 206 | uint8Array[1] = (number >> 16) & 0xFF; 207 | uint8Array[2] = (number >> 8) & 0xFF; 208 | uint8Array[3] = number & 0xFF; // Least significant byte (LSB) 209 | 210 | return uint8Array; 211 | } 212 | 213 | export class Nevent { 214 | encode(): string { 215 | let kindArray; 216 | if (this.pointer.kind != undefined) { 217 | kindArray = integerToUint8Array(this.pointer.kind); 218 | } 219 | 220 | const data = encodeTLV({ 221 | 0: [decodeHex(this.pointer.id)], 222 | 1: (this.pointer.relays || []).map((url) => utf8Encode(url)), 223 | 2: (this.pointer.pubkey ? [this.pointer.pubkey.hex] : []).map((url) => decodeHex(url)), 224 | 3: kindArray ? [new Uint8Array(kindArray)] : [], 225 | }); 226 | 227 | const words = bech32.toWords(data); 228 | return bech32.encode("nevent", words, 1500); 229 | } 230 | static decode(nevent: string) { 231 | let words; 232 | try { 233 | const res = bech32.decode(nevent, 1500); 234 | words = res.words; 235 | } catch (e) { 236 | if (e instanceof Error == false) { 237 | throw e; // impossible 238 | } 239 | return new Error(`failed to decode ${nevent}, ${e.message}`); 240 | } 241 | 242 | const data = new Uint8Array(bech32.fromWords(words)); 243 | const tlv = parseTLV(data); 244 | if (tlv instanceof Error) { 245 | return tlv; 246 | } 247 | if (!tlv[0][0]) { 248 | return new Error("missing TLV 0 for nevent"); 249 | } 250 | if (tlv[0][0].length !== 32) { 251 | return new Error("TLV 0 should be 32 bytes"); 252 | } 253 | if (tlv[2] && tlv[2][0].length !== 32) { 254 | return new Error("TLV 2 should be 32 bytes"); 255 | } 256 | if (tlv[3] && tlv[3][0].length !== 4) { 257 | return new Error("TLV 3 should be 4 bytes"); 258 | } 259 | let pubkey; 260 | if (tlv[2]) { 261 | pubkey = PublicKey.FromHex(encodeHex(tlv[2][0])); 262 | if (pubkey instanceof Error) { 263 | return pubkey; 264 | } 265 | } 266 | 267 | const pointer: EventPointer = { 268 | id: encodeHex(tlv[0][0]), 269 | relays: tlv[1] ? tlv[1].map((d) => utf8Decode(d)) : [], 270 | pubkey: pubkey, 271 | }; 272 | if (tlv[3]) { 273 | pointer.kind = parseInt(encodeHex(tlv[3][0]), 16); 274 | } 275 | return new Nevent(pointer); 276 | } 277 | 278 | public constructor(public readonly pointer: EventPointer) {} 279 | } 280 | -------------------------------------------------------------------------------- /relay-pool.ts: -------------------------------------------------------------------------------- 1 | import { chan, Channel, PutToClosedChannelError } from "@blowater/csp"; 2 | import { NoteID } from "./nip19.ts"; 3 | import { 4 | RelayDisconnectedByClient, 5 | SingleRelayConnection, 6 | SubscriptionAlreadyExist, 7 | } from "./relay-single.ts"; 8 | import { AsyncWebSocket } from "./websocket.ts"; 9 | 10 | import { ValueMap } from "@blowater/collections"; 11 | import { newURL } from "./_helper.ts"; 12 | import type { NostrEvent, NostrFilter, RelayResponse_REQ_Message, Signer } from "./nostr.ts"; 13 | import type { Closer, EventSender, SubscriptionCloser } from "./relay.interface.ts"; 14 | import type { Signer_V2 } from "./v2.ts"; 15 | 16 | export interface RelayAdder { 17 | addRelayURL(url: string): Promise; 18 | } 19 | 20 | export interface RelayRemover { 21 | removeRelay(url: string): Promise; 22 | } 23 | 24 | export interface RelayGetter { 25 | getRelay( 26 | url: string | URL, 27 | ): SingleRelayConnection | undefined | SingleRelayConnection | undefined | typeof url extends string 28 | ? TypeError 29 | : void; 30 | } 31 | 32 | export class ConnectionPoolClosed extends Error {} 33 | export class NoRelayRegistered extends Error { 34 | constructor() { 35 | super(); 36 | this.name = "NoRelayRegistered"; 37 | } 38 | } 39 | 40 | export class ConnectionPool 41 | implements SubscriptionCloser, EventSender, Closer, RelayAdder, RelayRemover, RelayGetter { 42 | private closed = false; 43 | private readonly connections = new ValueMap((url) => { 44 | return url.origin + url.pathname; 45 | }); 46 | private readonly subscriptionMap = new Map< 47 | string, 48 | { 49 | filters: NostrFilter[]; 50 | chan: Channel<{ res: RelayResponse_REQ_Message; url: URL }>; 51 | } 52 | >(); 53 | 54 | private readonly wsCreator: (url: string, log: boolean) => AsyncWebSocket | Error; 55 | 56 | constructor( 57 | private args?: { 58 | ws?: (url: string, log: boolean) => AsyncWebSocket | Error; 59 | signer?: Signer; 60 | signer_v2?: Signer_V2; 61 | }, 62 | public log?: boolean, 63 | ) { 64 | if (args?.ws == undefined) { 65 | this.wsCreator = AsyncWebSocket.New; 66 | } else { 67 | this.wsCreator = args.ws; 68 | } 69 | } 70 | 71 | getRelays() { 72 | return this.connections.values(); 73 | } 74 | 75 | getRelay(url: string | URL): SingleRelayConnection | undefined { 76 | if (typeof url == "string") { 77 | const theURL = newURL(url); 78 | if (theURL instanceof TypeError) { 79 | console.error(theURL); 80 | return undefined; 81 | } 82 | url = theURL; 83 | } 84 | return this.connections.get(url); 85 | } 86 | 87 | async addRelayURL(url: string | URL) { 88 | if (typeof url == "string") { 89 | if (!url.startsWith("ws://") && !url.startsWith("wss://")) { 90 | url = "wss://" + url; 91 | } 92 | } 93 | const theURL = newURL(url); 94 | if (theURL instanceof Error) { 95 | return theURL; 96 | } 97 | { 98 | const relay = this.connections.get(theURL); 99 | if (relay) { 100 | return relay; 101 | } 102 | } 103 | const client = SingleRelayConnection.New(url.toString(), { 104 | wsCreator: this.wsCreator, 105 | signer: this.args?.signer, 106 | signer_v2: this.args?.signer_v2, 107 | }) as SingleRelayConnection; 108 | const err = await this.addRelay(client); 109 | if (err instanceof Error) { 110 | return err; 111 | } 112 | return client; 113 | } 114 | 115 | async addRelayURLs(urls: string[]) { 116 | const ps = []; 117 | for (const url of urls) { 118 | ps.push(this.addRelayURL(url)); 119 | } 120 | const errs = await Promise.all(ps); 121 | const errs2 = new Array(); 122 | for (const err of errs) { 123 | if (err instanceof Error) { 124 | errs2.push(err); 125 | } 126 | } 127 | if (errs2.length === 0) { 128 | return undefined; 129 | } 130 | return errs2; 131 | } 132 | 133 | async addRelay(relay: SingleRelayConnection) { 134 | if (this.closed) { 135 | return new ConnectionPoolClosed("connection pool has been closed"); 136 | } 137 | if (relay.signer?.publicKey.hex != this.args?.signer?.publicKey.hex) { 138 | return new Error("relay has a different signer than the pool's"); 139 | } 140 | const _relay = this.connections.get(relay.url); 141 | if (_relay) { 142 | // should almost never happen because addRelay is not called that often 143 | // addRelayURL is called usually 144 | // close the new relay 145 | await relay.close(); 146 | return _relay; 147 | } 148 | 149 | this.connections.set(relay.url, relay); 150 | 151 | // for this newly added relay, do all the subs 152 | for (let [subID, { filters, chan }] of this.subscriptionMap.entries()) { 153 | let sub = await relay.newSub(subID, ...filters); 154 | if (sub instanceof Error) { 155 | return sub; 156 | } 157 | // pipe the channel 158 | (async () => { 159 | for await (let msg of sub.chan) { 160 | let err = await chan.put({ res: msg, url: relay.url }); 161 | if (err instanceof PutToClosedChannelError) { 162 | if (this.closed === true) { 163 | // we only expect the destination channel to be closed 164 | // if the ConnectionPool itself it closed 165 | } else { 166 | throw Error( 167 | "should not close the destination channel", 168 | ); 169 | } 170 | } 171 | } 172 | })(); 173 | } 174 | return relay; 175 | } 176 | 177 | async removeRelay(url: string | URL) { 178 | const theURL = newURL(url); 179 | if (theURL instanceof Error) { 180 | return; 181 | } 182 | const relay = this.connections.get(theURL); 183 | if (relay === undefined) { 184 | return; 185 | } 186 | const p = relay.close(); 187 | await p; 188 | this.connections.delete(theURL); 189 | } 190 | 191 | async newSub( 192 | subID: string, 193 | ...filters: NostrFilter[] 194 | ) { 195 | if (this.subscriptionMap.has(subID)) { 196 | return new SubscriptionAlreadyExist(subID, "relay pool"); 197 | } 198 | const results = chan<{ res: RelayResponse_REQ_Message; url: URL }>(); 199 | for (const conn of this.connections.values()) { 200 | (async (relay: SingleRelayConnection) => { 201 | const sub = await relay.newSub(subID, ...filters); 202 | if (sub instanceof Error) { 203 | console.error(sub); 204 | return; 205 | } 206 | for await (const msg of sub.chan) { 207 | await results.put({ res: msg, url: relay.url }); 208 | } 209 | })(conn); 210 | } 211 | const sub = { filters, chan: results }; 212 | this.subscriptionMap.set(subID, sub); 213 | return sub; 214 | } 215 | 216 | async getEvent(id: NoteID | string) { 217 | if (id instanceof NoteID) { 218 | id = id.hex; 219 | } 220 | const stream = await this.newSub(id, { 221 | "ids": [id], 222 | }); 223 | if (stream instanceof Error) { 224 | return stream; 225 | } 226 | const url_set = new Set(); 227 | for await (const msg of stream.chan) { 228 | if (msg.res.type == "EOSE") { 229 | url_set.add(msg.url); 230 | } else if (msg.res.type == "EVENT") { 231 | await this.closeSub(id); 232 | return msg.res.event; 233 | } 234 | if (url_set.size >= this.connections.size) { 235 | return undefined; 236 | } 237 | } 238 | } 239 | 240 | async sendEvent(nostrEvent: NostrEvent) { 241 | if (this.connections.size === 0) { 242 | return new NoRelayRegistered(); 243 | } 244 | const ps = []; 245 | for (let relay of this.connections.values()) { 246 | if (relay.isClosed() && !relay.isClosedByClient) { 247 | continue; 248 | } 249 | const p = relay.sendEvent(nostrEvent); 250 | ps.push(p); 251 | } 252 | 253 | for (const p of ps) { 254 | const err = await p; 255 | if (err instanceof RelayDisconnectedByClient) { 256 | console.log(err); 257 | } 258 | } 259 | return ""; 260 | } 261 | 262 | async closeSub(subID: string) { 263 | for (const relay of this.connections.values()) { 264 | await relay.closeSub(subID); 265 | } 266 | const subscription = this.subscriptionMap.get(subID); 267 | if (subscription && subscription.chan.closed() == false) { 268 | await subscription.chan.close(); 269 | } 270 | this.subscriptionMap.delete(subID); 271 | return undefined; 272 | } 273 | async close(): Promise { 274 | this.closed = true; 275 | for (const relay of this.connections.values()) { 276 | if (relay.isClosed()) { 277 | continue; 278 | } 279 | await relay.close(); 280 | } 281 | // this function should not be called in production 282 | // but we implement it for testing purpose 283 | for (const [subID, _] of this.subscriptionMap.entries()) { 284 | await this.closeSub(subID); 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /nostr.ts: -------------------------------------------------------------------------------- 1 | import { PrivateKey, PublicKey } from "./key.ts"; 2 | import { decrypt_with_shared_secret, encrypt, utf8Encode } from "./nip4.ts"; 3 | import * as nip44 from "./nip44.ts"; 4 | 5 | import { encodeHex } from "@std/encoding"; 6 | import { getSharedSecret } from "@noble/secp256k1"; 7 | import { schnorr } from "@noble/curves/secp256k1"; 8 | import { sha256 } from "@noble/hashes/sha256"; 9 | 10 | export enum NostrKind { 11 | META_DATA = 0, 12 | TEXT_NOTE = 1, 13 | RECOMMED_SERVER = 2, 14 | CONTACTS = 3, 15 | DIRECT_MESSAGE = 4, 16 | DIRECT_MESSAGE_V2 = 44, 17 | DELETE = 5, 18 | REACTION = 7, 19 | // https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists 20 | Mute_List = 10000, 21 | Pinned_Notes = 10001, 22 | Bookmarks = 10003, 23 | Interests = 10015, 24 | Encrypted_Custom_App_Data = 20231125, 25 | Custom_App_Data = 30078, // https://github.com/nostr-protocol/nips/blob/master/78.md 26 | Long_Form = 30023, // https://github.com/nostr-protocol/nips/blob/master/23.md 27 | Calendar_Date = 31922, // https://github.com/nostr-protocol/nips/blob/master/52.md#date-based-calendar-event 28 | Calendar_Time = 31923, // https://github.com/nostr-protocol/nips/blob/master/52.md#time-based-calendar-event 29 | HTTP_AUTH = 27235, // https://github.com/nostr-protocol/nips/blob/master/98.md 30 | } 31 | 32 | export interface NostrFilter { 33 | ids?: Array; 34 | authors?: Array; 35 | kinds?: Array; 36 | "#e"?: Array; 37 | "#p"?: Array; 38 | "#d"?: Array; // https://github.com/nostr-protocol/nips/blob/master/33.md 39 | "#a"?: string[]; // https://github.com/nostr-protocol/nips/blob/master/01.md#tags 40 | since?: number; 41 | until?: number; 42 | limit?: number; 43 | } 44 | 45 | export interface ProfileInfo { 46 | name?: string; 47 | picture?: string; 48 | about?: string; 49 | relays?: Array<{ url: string; read: boolean; write: boolean }>; 50 | following?: Array<{ publicKey: string; name: string }>; 51 | follower?: Array<{ publicKey: string; name: string }>; 52 | } 53 | 54 | //////////////////// 55 | // Relay Response // 56 | //////////////////// 57 | export type SubID = string; 58 | export type EventID = string; 59 | 60 | export type _RelayResponse = 61 | | _RelayResponse_REQ_Message 62 | | _RelayResponse_OK 63 | | _RelayResponse_Notice; 64 | 65 | export type _RelayResponse_REQ_Message = 66 | | _RelayResponse_Event 67 | | _RelayResponse_EOSE; 68 | 69 | export type _RelayResponse_Event = ["EVENT", SubID, NostrEvent]; 70 | export type _RelayResponse_EOSE = ["EOSE", SubID]; // https://github.com/nostr-protocol/nips/blob/master/15.md 71 | export type _RelayResponse_Notice = ["NOTICE", string]; 72 | export type _RelayResponse_OK = ["OK", EventID, boolean, string]; 73 | 74 | export type RelayResponse = RelayResponse_REQ_Message | RelayResponse_OK; 75 | export type RelayResponse_REQ_Message = RelayResponse_Event | RelayResponse_EOSE | RelayResponse_Notice; 76 | 77 | export type RelayResponse_Event = { 78 | type: "EVENT"; 79 | subID: SubID; 80 | event: NostrEvent; 81 | }; 82 | 83 | export type RelayResponse_EOSE = { 84 | type: "EOSE"; 85 | subID: SubID; 86 | }; 87 | 88 | export type RelayResponse_OK = { 89 | type: "OK"; 90 | eventID: EventID; 91 | ok: boolean; 92 | note: string; 93 | }; 94 | 95 | export type RelayResponse_Notice = { 96 | type: "NOTICE"; 97 | note: string; 98 | }; 99 | 100 | // Nostr Web Socket Message 101 | // https://github.com/nostr-protocol/nips/blob/master/01.md#from-client-to-relay-sending-events-and-creating-subscriptions 102 | export type ClientRequest_Message = 103 | | ClientRequest_Event 104 | | ClientRequest_REQ 105 | | ClientRequest_Close; 106 | export type ClientRequest_Event = ["EVENT", NostrEvent]; 107 | // potentially more filters, but I don't know how to represent in TS type 108 | export type ClientRequest_REQ = ["REQ", SubID, ...NostrFilter[]]; 109 | export type ClientRequest_Close = ["CLOSE", SubID]; 110 | 111 | export interface RequestFilter { 112 | "ids"?: string[]; 113 | "authors"?: string[]; 114 | "kinds"?: NostrKind[]; 115 | "#e"?: string[]; 116 | "#p"?: string[]; 117 | "since"?: number; 118 | "until"?: number; 119 | "limit"?: number; 120 | } 121 | 122 | // https://github.com/nostr-protocol/nips/blob/master/04.md 123 | export interface NostrEvent 124 | extends UnsignedNostrEvent { 125 | readonly id: EventID; 126 | readonly sig: string; 127 | } 128 | 129 | export interface UnsignedNostrEvent { 130 | readonly pubkey: string; 131 | readonly kind: Kind; 132 | readonly created_at: number; 133 | readonly tags: TagType[]; 134 | readonly content: string; 135 | } 136 | 137 | export type Tag = TagPubKey | TagEvent | TagIdentifier | TagHashtag | [string, ...string[]]; 138 | export type TagPubKey = ["p", string]; 139 | export type TagEvent = ["e", string]; 140 | export type TagIdentifier = ["d", string]; 141 | export type TagHashtag = ["t", string]; // https://github.com/nostr-protocol/nips/blob/master/24.md#tags 142 | 143 | export type Tags = { 144 | p: string[]; 145 | e: string[]; 146 | t: string[]; 147 | d?: string; 148 | client?: string; 149 | }; 150 | 151 | export function getTags(event: NostrEvent): Tags { 152 | const tags: Tags = { 153 | p: [], 154 | e: [], 155 | t: [], 156 | }; 157 | for (const tag of event.tags) { 158 | switch (tag[0]) { 159 | case "p": 160 | tags.p.push(tag[1]); 161 | break; 162 | case "e": 163 | tags.e.push(tag[1]); 164 | break; 165 | case "d": 166 | tags.d = tag[1]; 167 | break; 168 | case "client": 169 | tags.client = tag[1]; 170 | break; 171 | case "t": 172 | tags.t.push(tag[1]); 173 | break; 174 | } 175 | } 176 | return tags; 177 | } 178 | 179 | // https://github.com/nostr-protocol/nips/blob/master/07.md 180 | export type NostrAccountContext = Signer & Encrypter & Decrypter; 181 | export interface Encrypter { 182 | encrypt(pubkey: string, plaintext: string, algorithm: "nip44" | "nip4"): Promise; 183 | } 184 | export interface Decrypter { 185 | decrypt(pubkey: string, ciphertext: string, algorithm?: "nip44" | "nip4"): Promise; 186 | } 187 | 188 | /** 189 | * A Signer is just any object that 190 | * 1. contains a public key 191 | * 2. implements the signEvent function 192 | * 193 | * InMemoryAccountContext is the implementation this library provides 194 | * But in a web application, the program usually wants to implement one with NIP-7 195 | * 196 | * see examples [here](./tests/example.test.ts) 197 | */ 198 | export type Signer = { 199 | readonly publicKey: PublicKey; 200 | signEvent( 201 | event: UnsignedNostrEvent, 202 | ): Promise | Error>; 203 | }; 204 | 205 | export class DecryptionFailure extends Error { 206 | constructor( 207 | public event: NostrEvent, 208 | ) { 209 | super(`Failed to decrypt event ${event.id}`); 210 | } 211 | } 212 | 213 | export async function calculateId(event: UnsignedNostrEvent) { 214 | const commit = eventCommitment(event); 215 | const buf = utf8Encode(commit); 216 | return encodeHex(sha256(buf)); 217 | } 218 | 219 | function eventCommitment(event: UnsignedNostrEvent): string { 220 | const { pubkey, created_at, kind, tags, content } = event; 221 | return JSON.stringify([0, pubkey, created_at, kind, tags, content]); 222 | } 223 | 224 | export async function signId(id: string, privateKey: string) { 225 | return schnorr.sign(id, privateKey); 226 | } 227 | 228 | /** 229 | * see examples [here](./tests/example.test.ts) 230 | */ 231 | export class InMemoryAccountContext implements NostrAccountContext { 232 | static New(privateKey: PrivateKey) { 233 | return new InMemoryAccountContext(privateKey); 234 | } 235 | 236 | static FromString(prikey: string) { 237 | const key = PrivateKey.FromString(prikey); 238 | if (key instanceof Error) { 239 | return key; 240 | } 241 | return new InMemoryAccountContext(key); 242 | } 243 | 244 | static Generate() { 245 | return new InMemoryAccountContext(PrivateKey.Generate()); 246 | } 247 | 248 | readonly publicKey: PublicKey; 249 | 250 | private readonly sharedSecretsMap = new Map(); 251 | 252 | constructor( 253 | readonly privateKey: PrivateKey, 254 | ) { 255 | this.publicKey = privateKey.toPublicKey(); 256 | } 257 | 258 | async signEvent( 259 | event: UnsignedNostrEvent, 260 | ): Promise> { 261 | const id = await calculateId(event); 262 | const sig = encodeHex(await signId(id, this.privateKey.hex)); 263 | return { ...event, id, sig }; 264 | } 265 | 266 | async encrypt(pubkey: string, plaintext: string, algorithm: "nip44" | "nip4"): Promise { 267 | if (algorithm == "nip44") { 268 | const key = nip44.getConversationKey(this.privateKey.hex, pubkey); 269 | if (key instanceof Error) return key; 270 | return nip44.encrypt(plaintext, key); 271 | } else { 272 | return await encrypt(pubkey, plaintext, this.privateKey.hex); 273 | } 274 | } 275 | 276 | async decrypt( 277 | decryptionPublicKey: string, 278 | ciphertext: string, 279 | algorithm?: "nip44" | "nip4", 280 | ): Promise { 281 | if (algorithm != "nip4" && (algorithm == "nip44" || !ciphertext.includes("?iv"))) { 282 | const key = nip44.getConversationKey(this.privateKey.hex, decryptionPublicKey); 283 | if (key instanceof Error) return key; 284 | return nip44.decrypt(ciphertext, key); 285 | } else { 286 | let key = this.sharedSecretsMap.get(decryptionPublicKey); 287 | if (key == undefined) { 288 | try { 289 | key = getSharedSecret(this.privateKey.hex, "02" + decryptionPublicKey) as Uint8Array; 290 | } catch (e) { 291 | return e as Error; 292 | } 293 | this.sharedSecretsMap.set(decryptionPublicKey, key); 294 | } 295 | return decrypt_with_shared_secret(ciphertext, key); 296 | } 297 | } 298 | } 299 | 300 | export async function verifyEvent(event: NostrEvent) { 301 | try { 302 | return schnorr.verify(event.sig, await calculateId(event), event.pubkey); 303 | } catch { 304 | return false; 305 | } 306 | } 307 | 308 | export * from "./nip4.ts"; 309 | export * as nip44 from "./nip44.ts"; 310 | export * from "./nip06.ts"; 311 | export * from "./nip11.ts"; 312 | export * from "./nip19.ts"; 313 | export * from "./nip25.ts"; 314 | export * from "./nip96.ts"; 315 | export * from "./relay-single.ts"; 316 | export * from "./relay-pool.ts"; 317 | export * from "./websocket.ts"; 318 | export * from "./event.ts"; 319 | export * from "./key.ts"; 320 | export * from "./relay.interface.ts"; 321 | export * from "./_helper.ts"; 322 | -------------------------------------------------------------------------------- /tests/relay-single-test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertInstanceOf, fail } from "@std/assert"; 2 | import { prepareNostrEvent } from "../event.ts"; 3 | import { SingleRelayConnection, SubscriptionAlreadyExist } from "../relay-single.ts"; 4 | import * as csp from "@blowater/csp"; 5 | import { PrivateKey } from "../key.ts"; 6 | 7 | import { getSpaceMembers } from "../space-member.ts"; 8 | import { 9 | InMemoryAccountContext, 10 | type NostrEvent, 11 | NostrKind, 12 | type RelayResponse_Event, 13 | type Signer, 14 | } from "../nostr.ts"; 15 | import type { Signer_V2 } from "../v2.ts"; 16 | 17 | export const open_close = (urls: string[]) => async () => { 18 | for (let url of urls) { 19 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 20 | await client.close(); 21 | } 22 | }; 23 | 24 | export const newSub_close = (url: string) => async () => { 25 | // able to open & close 26 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 27 | const sub = await client.newSub("1", { kinds: [0], limit: 1 }); 28 | if (sub instanceof Error) fail(sub.message); 29 | 30 | await client.close(); 31 | if (sub instanceof SubscriptionAlreadyExist) { 32 | fail("unreachable"); 33 | } 34 | assertEquals(sub.chan.closed(), true); 35 | await client.close(); 36 | }; 37 | 38 | export const sub_exits = (url: string) => async () => { 39 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 40 | { 41 | // open 42 | const subID = "1"; 43 | const chan = await client.newSub(subID, { kinds: [0], limit: 1 }); 44 | if (chan instanceof Error) fail(chan.message); 45 | 46 | // close 47 | await client.closeSub(subID); 48 | assertEquals(chan.chan.closed(), true); 49 | 50 | // open again 51 | const sub2 = await client.newSub(subID, { kinds: [0], limit: 1 }); 52 | if (sub2 instanceof Error) fail(sub2.message); 53 | assertEquals(sub2.chan.closed(), false); 54 | } 55 | { 56 | const _ = await client.newSub("hi", { limit: 1 }); 57 | const sub = await client.newSub("hi", { limit: 1 }); 58 | assertInstanceOf(sub, SubscriptionAlreadyExist); 59 | } 60 | await client.close(); 61 | }; 62 | 63 | export const close_sub_keep_reading = (url: string) => async () => { 64 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 65 | { 66 | const subID = "1"; 67 | const sub = await client.newSub(subID, { limit: 1 }); 68 | if (sub instanceof Error) fail(sub.message); 69 | await client.closeSub(subID); 70 | assertEquals(sub.chan.closed(), true); 71 | } 72 | await client.close(); 73 | }; 74 | 75 | export const send_event = (url: string) => async () => { 76 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 77 | { 78 | const event = await prepareNostrEvent(InMemoryAccountContext.Generate(), { 79 | content: "", 80 | kind: NostrKind.TEXT_NOTE, 81 | }) as NostrEvent; 82 | const err = client.sendEvent(event); 83 | if (err instanceof Error) fail(err.message); 84 | } 85 | await client.close(); 86 | }; 87 | 88 | export const get_correct_kind = (url: string) => async () => { 89 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 90 | { 91 | const err = await relay.sendEvent( 92 | await prepareNostrEvent(InMemoryAccountContext.Generate(), { 93 | kind: NostrKind.Encrypted_Custom_App_Data, 94 | content: "test", 95 | }) as NostrEvent, 96 | ); 97 | if (err instanceof Error) fail(err.message); 98 | } 99 | { 100 | const stream = await relay.newSub("test", { limit: 1, kinds: [NostrKind.Encrypted_Custom_App_Data] }); 101 | if (stream instanceof Error) fail(stream.message); 102 | 103 | const msg = await stream.chan.pop(); 104 | if (msg == csp.closed) { 105 | fail(); 106 | } 107 | if (msg.type != "EVENT") { 108 | fail(`msg.type is ${msg.type}`); 109 | } 110 | assertEquals(msg.subID, "test"); 111 | assertEquals(msg.event.kind, NostrKind.Encrypted_Custom_App_Data); 112 | } 113 | await relay.close(); 114 | }; 115 | 116 | export const newSub_multiple_filters = (url: string) => async () => { 117 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 118 | const event_1 = await prepareNostrEvent(InMemoryAccountContext.Generate(), { 119 | kind: NostrKind.TEXT_NOTE, 120 | content: "test1", 121 | }) as NostrEvent; 122 | const event_2 = await prepareNostrEvent(InMemoryAccountContext.Generate(), { 123 | kind: NostrKind.Long_Form, 124 | content: "test2", 125 | }) as NostrEvent; 126 | { 127 | const err1 = await relay.sendEvent(event_1 as NostrEvent); 128 | if (err1 instanceof Error) fail(err1.message); 129 | const err2 = await relay.sendEvent(event_2 as NostrEvent); 130 | if (err2 instanceof Error) fail(err2.message); 131 | } 132 | 133 | const stream = await relay.newSub( 134 | "multiple filters", 135 | { 136 | ids: [event_1.id], 137 | limit: 1, 138 | }, 139 | { 140 | authors: [event_2.pubkey], 141 | limit: 1, 142 | }, 143 | ); 144 | if (stream instanceof Error) fail(stream.message); 145 | 146 | const msg1 = await stream.chan.pop() as RelayResponse_Event; 147 | const msg2 = await stream.chan.pop() as RelayResponse_Event; 148 | 149 | assertEquals(event_1, msg1.event); 150 | assertEquals(event_2, msg2.event); 151 | await relay.close(); 152 | }; 153 | 154 | // maximum number of events relays SHOULD return in the initial query 155 | export const limit = (url: string) => async () => { 156 | const ctx = InMemoryAccountContext.Generate(); 157 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 158 | { 159 | const err = await relay.sendEvent( 160 | await prepareNostrEvent(ctx, { 161 | kind: NostrKind.TEXT_NOTE, 162 | content: "1", 163 | }) as NostrEvent, 164 | ); 165 | if (err instanceof Error) fail(err.message); 166 | const err2 = await relay.sendEvent( 167 | await prepareNostrEvent(ctx, { 168 | kind: NostrKind.TEXT_NOTE, 169 | content: "2", 170 | }) as NostrEvent, 171 | ); 172 | if (err2 instanceof Error) fail(err2.message); 173 | const err3 = await relay.sendEvent( 174 | await prepareNostrEvent(ctx, { 175 | kind: NostrKind.TEXT_NOTE, 176 | content: "3", 177 | }) as NostrEvent, 178 | ); 179 | if (err3 instanceof Error) fail(err3.message); 180 | const err4 = await relay.sendEvent( 181 | await prepareNostrEvent(ctx, { 182 | kind: NostrKind.TEXT_NOTE, 183 | content: "4", 184 | }) as NostrEvent, 185 | ); 186 | if (err4 instanceof Error) fail(err4.message); 187 | 188 | const subID = "limit"; 189 | const sub = await relay.newSub(subID, { kinds: [NostrKind.TEXT_NOTE], limit: 3 }); 190 | if (sub instanceof Error) fail(sub.message); 191 | 192 | let i = 0; 193 | for await (const msg of sub.chan) { 194 | console.log("msg", msg); 195 | if (msg.type == "EOSE") { 196 | break; 197 | } 198 | i++; 199 | } 200 | assertEquals(i, 3); 201 | } 202 | await relay.close(); 203 | }; 204 | 205 | export const no_event = (url: string) => async () => { 206 | const ctx = InMemoryAccountContext.Generate(); 207 | const relay = SingleRelayConnection.New(url) as SingleRelayConnection; 208 | { 209 | const subID = "NoEvent"; 210 | const sub = await relay.newSub(subID, { 211 | "authors": [ctx.publicKey.hex], 212 | "kinds": [NostrKind.CONTACTS], 213 | }); 214 | if (sub instanceof Error) fail(sub.message); 215 | 216 | for await (const msg of sub.chan) { 217 | assertEquals(msg, { type: "EOSE", subID: "NoEvent" }); 218 | break; 219 | } 220 | } 221 | await relay.close(); 222 | }; 223 | 224 | export const two_clients_communicate = (url: string) => async () => { 225 | const ctx = InMemoryAccountContext.Generate(); 226 | const relay1 = SingleRelayConnection.New(url) as SingleRelayConnection; 227 | const relay2 = SingleRelayConnection.New(url) as SingleRelayConnection; 228 | { 229 | const sub = await relay1.newSub("relay1", { 230 | authors: [ctx.publicKey.hex], 231 | }); 232 | if (sub instanceof Error) fail(sub.message); 233 | 234 | const err = await relay2.sendEvent( 235 | await prepareNostrEvent(ctx, { 236 | content: "test", 237 | kind: NostrKind.TEXT_NOTE, 238 | }) as NostrEvent, 239 | ); 240 | if (err instanceof Error) fail(err.message); 241 | 242 | for await (const msg of sub.chan) { 243 | if (msg.type == "EVENT") { 244 | assertEquals(msg.event.content, "test"); 245 | assertEquals(msg.event.pubkey, ctx.publicKey.hex); 246 | break; 247 | } 248 | } 249 | } 250 | await relay1.close(); 251 | await relay2.close(); 252 | }; 253 | 254 | export const get_event_by_id = (url: string) => async () => { 255 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 256 | const ctx = InMemoryAccountContext.Generate(); 257 | { 258 | const event_1 = await client.getEvent(PrivateKey.Generate().hex); 259 | assertEquals(event_1, undefined); 260 | 261 | const event = await prepareNostrEvent(ctx, { 262 | content: "get_event_by_id", 263 | kind: NostrKind.TEXT_NOTE, 264 | }) as NostrEvent; 265 | const err = await client.sendEvent(event); 266 | if (err instanceof Error) fail(err.message); 267 | 268 | const event_2 = await client.getEvent(event.id); 269 | if (event_2 instanceof Error) fail(event_2.message); 270 | 271 | assertEquals(event, event_2); 272 | } 273 | await client.close(); 274 | }; 275 | 276 | export const get_replaceable_event = (url: string) => async () => { 277 | const client = SingleRelayConnection.New(url) as SingleRelayConnection; 278 | const ctx = InMemoryAccountContext.Generate(); 279 | 280 | const created_at = Date.now() / 1000; 281 | const event1 = await prepareNostrEvent(ctx, { 282 | content: "1", 283 | kind: NostrKind.META_DATA, 284 | created_at: created_at, 285 | }) as NostrEvent; 286 | { 287 | const err = await client.sendEvent(event1); 288 | if (err instanceof Error) fail(err.message); 289 | } 290 | 291 | const event2 = await prepareNostrEvent(ctx, { 292 | content: "2", 293 | kind: NostrKind.META_DATA, 294 | created_at: created_at + 100, 295 | }) as NostrEvent; 296 | { 297 | const err = await client.sendEvent(event2); 298 | if (err instanceof Error) fail(err.message); 299 | } 300 | 301 | const event_got = await client.getReplaceableEvent(ctx.publicKey, NostrKind.META_DATA); 302 | assertEquals(event_got, event2); 303 | await client.close(); 304 | }; 305 | 306 | export const get_space_members = (url: URL) => async () => { 307 | const members = await getSpaceMembers(url); 308 | if (members instanceof Error) fail(members.message); 309 | }; 310 | 311 | export const add_space_member = (url: string, args: { 312 | signer: Signer; 313 | signer_v2: Signer_V2; 314 | }) => 315 | async () => { 316 | const client = SingleRelayConnection.New(url, args) as SingleRelayConnection; 317 | { 318 | const new_member = PrivateKey.Generate().toPublicKey(); 319 | const added = await client.unstable.addSpaceMember(new_member); 320 | if (added instanceof Error) fail(added.message); 321 | assertEquals(added.status, 200); 322 | assertEquals(await added.text(), ""); 323 | } 324 | await client.close(); 325 | }; 326 | -------------------------------------------------------------------------------- /deno.test.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@blowater/collections@^0.0.0-rc1": "0.0.0-rc3", 5 | "jsr:@blowater/csp@1.0.0": "1.0.0", 6 | "jsr:@noble/secp256k1@2.1.0": "2.1.0", 7 | "jsr:@std/assert@0.226.0": "0.226.0", 8 | "jsr:@std/datetime@0.224.1": "0.224.1", 9 | "jsr:@std/encoding@0.224.3": "0.224.3", 10 | "jsr:@std/internal@1": "1.0.1", 11 | "npm:@noble/ciphers@0.4.1": "0.4.1", 12 | "npm:@noble/curves@1.4.0": "1.4.0", 13 | "npm:@noble/hashes@1.4.0": "1.4.0", 14 | "npm:@scure/bip32@1.3.2": "1.3.2", 15 | "npm:@scure/bip39@1.2.1": "1.2.1", 16 | "npm:json-stable-stringify@1.1.1": "1.1.1", 17 | "npm:zod@3.23.8": "3.23.8" 18 | }, 19 | "jsr": { 20 | "@blowater/collections@0.0.0-rc3": { 21 | "integrity": "54cdc175266e16d50aaf780885b6e6eb3009f99c66641ff9b0c6c60f5b6e629d" 22 | }, 23 | "@blowater/csp@1.0.0": { 24 | "integrity": "415d4e8bf1656e4a508997fb8e725e9372e1e39b2b714afa76e86efd733f8c4c" 25 | }, 26 | "@noble/secp256k1@2.1.0": { 27 | "integrity": "b5843dfb90c67026ec5d2771cc2c809155fa5954ff0300b23de5d4a1f4eec758" 28 | }, 29 | "@std/assert@0.226.0": { 30 | "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3", 31 | "dependencies": [ 32 | "jsr:@std/internal" 33 | ] 34 | }, 35 | "@std/datetime@0.224.1": { 36 | "integrity": "3b4d5062333a3fe2e8f94b343e05e8c34ec8b09e2e8311fe0e879a6773075830" 37 | }, 38 | "@std/encoding@0.224.3": { 39 | "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" 40 | }, 41 | "@std/internal@1.0.1": { 42 | "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" 43 | } 44 | }, 45 | "npm": { 46 | "@noble/ciphers@0.4.1": { 47 | "integrity": "sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==" 48 | }, 49 | "@noble/curves@1.2.0": { 50 | "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", 51 | "dependencies": [ 52 | "@noble/hashes@1.3.2" 53 | ] 54 | }, 55 | "@noble/curves@1.4.0": { 56 | "integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==", 57 | "dependencies": [ 58 | "@noble/hashes@1.4.0" 59 | ] 60 | }, 61 | "@noble/hashes@1.3.2": { 62 | "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" 63 | }, 64 | "@noble/hashes@1.3.3": { 65 | "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==" 66 | }, 67 | "@noble/hashes@1.4.0": { 68 | "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==" 69 | }, 70 | "@scure/base@1.1.7": { 71 | "integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g==" 72 | }, 73 | "@scure/bip32@1.3.2": { 74 | "integrity": "sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==", 75 | "dependencies": [ 76 | "@noble/curves@1.2.0", 77 | "@noble/hashes@1.3.3", 78 | "@scure/base" 79 | ] 80 | }, 81 | "@scure/bip39@1.2.1": { 82 | "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", 83 | "dependencies": [ 84 | "@noble/hashes@1.3.3", 85 | "@scure/base" 86 | ] 87 | }, 88 | "call-bind@1.0.7": { 89 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 90 | "dependencies": [ 91 | "es-define-property", 92 | "es-errors", 93 | "function-bind", 94 | "get-intrinsic", 95 | "set-function-length" 96 | ] 97 | }, 98 | "define-data-property@1.1.4": { 99 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 100 | "dependencies": [ 101 | "es-define-property", 102 | "es-errors", 103 | "gopd" 104 | ] 105 | }, 106 | "es-define-property@1.0.0": { 107 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 108 | "dependencies": [ 109 | "get-intrinsic" 110 | ] 111 | }, 112 | "es-errors@1.3.0": { 113 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 114 | }, 115 | "function-bind@1.1.2": { 116 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 117 | }, 118 | "get-intrinsic@1.2.4": { 119 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 120 | "dependencies": [ 121 | "es-errors", 122 | "function-bind", 123 | "has-proto", 124 | "has-symbols", 125 | "hasown" 126 | ] 127 | }, 128 | "gopd@1.0.1": { 129 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 130 | "dependencies": [ 131 | "get-intrinsic" 132 | ] 133 | }, 134 | "has-property-descriptors@1.0.2": { 135 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 136 | "dependencies": [ 137 | "es-define-property" 138 | ] 139 | }, 140 | "has-proto@1.0.3": { 141 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" 142 | }, 143 | "has-symbols@1.0.3": { 144 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" 145 | }, 146 | "hasown@2.0.2": { 147 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 148 | "dependencies": [ 149 | "function-bind" 150 | ] 151 | }, 152 | "isarray@2.0.5": { 153 | "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" 154 | }, 155 | "json-stable-stringify@1.1.1": { 156 | "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", 157 | "dependencies": [ 158 | "call-bind", 159 | "isarray", 160 | "jsonify", 161 | "object-keys" 162 | ] 163 | }, 164 | "jsonify@0.0.1": { 165 | "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==" 166 | }, 167 | "object-keys@1.1.1": { 168 | "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" 169 | }, 170 | "set-function-length@1.2.2": { 171 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 172 | "dependencies": [ 173 | "define-data-property", 174 | "es-errors", 175 | "function-bind", 176 | "get-intrinsic", 177 | "gopd", 178 | "has-property-descriptors" 179 | ] 180 | }, 181 | "zod@3.23.8": { 182 | "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" 183 | } 184 | }, 185 | "remote": { 186 | "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", 187 | "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", 188 | "https://deno.land/std@0.224.0/http/cookie.ts": "a377fa60175ba5f61dd4b8a70b34f2bbfbc70782dfd5faf36d314c42e4306006", 189 | "https://deno.land/x/graphql_tag@0.1.2/deps.ts": "5696461c8bb42db7c83486db452e125f7cfdc62a2c628bb470a4447d934b90b3", 190 | "https://deno.land/x/graphql_tag@0.1.2/mod.ts": "57fd56de5f7cbc66e23ce896cc8e99521d286e89969d83e09d960642b0a9d652", 191 | "https://deno.land/x/sqlite@v3.8/build/sqlite.js": "72f63689fffcb9bb5ae10b1e8f7db09ea845cdf713e0e3a9693d8416a28f92a6", 192 | "https://deno.land/x/sqlite@v3.8/build/vfs.js": "08533cc78fb29b9d9bd62f6bb93e5ef333407013fed185776808f11223ba0e70", 193 | "https://deno.land/x/sqlite@v3.8/mod.ts": "e09fc79d8065fe222578114b109b1fd60077bff1bb75448532077f784f4d6a83", 194 | "https://deno.land/x/sqlite@v3.8/src/constants.ts": "90f3be047ec0a89bcb5d6fc30db121685fc82cb00b1c476124ff47a4b0472aa9", 195 | "https://deno.land/x/sqlite@v3.8/src/db.ts": "7d3251021756fa80f382c3952217c7446c5c8c1642b63511da0938fe33562663", 196 | "https://deno.land/x/sqlite@v3.8/src/error.ts": "f7a15cb00d7c3797da1aefee3cf86d23e0ae92e73f0ba3165496c3816ab9503a", 197 | "https://deno.land/x/sqlite@v3.8/src/function.ts": "e4c83b8ec64bf88bafad2407376b0c6a3b54e777593c70336fb40d43a79865f2", 198 | "https://deno.land/x/sqlite@v3.8/src/query.ts": "d58abda928f6582d77bad685ecf551b1be8a15e8e38403e293ec38522e030cad", 199 | "https://deno.land/x/sqlite@v3.8/src/wasm.ts": "e79d0baa6e42423257fb3c7cc98091c54399254867e0f34a09b5bdef37bd9487", 200 | "https://esm.sh/graphql@16.6.0/language/ast#=": "3370c36acfd7dc994d7f2242361dbc538b7b239f0cb41603f97a5d67273f5255", 201 | "https://esm.sh/graphql@16.6.0/language/parser#=": "38b1158134aed40573b9885cc5f3b020fe8b60c75aa8b32420388c577615b97a", 202 | "https://esm.sh/graphql@16.8.1": "50c951dc0b67f3f01e70d1511ca68ebb067ef99d3590d6a7afb36819c84595f9", 203 | "https://esm.sh/preact-render-to-string@6.4.1": "502d356922296aa8b105dbbf7de0e43d41146966e7af4c485053d64627029c98", 204 | "https://esm.sh/stable/preact@10.20.0/denonext/preact.mjs": "323ac0dab4ede066d3ef67ae2f51029ed1bd5f81f5a07982924a00aee3c54ada", 205 | "https://esm.sh/stable/preact@10.20.2/denonext/preact.mjs": "f418bc70c24b785703afb9d4dea8cdc1e315e43c8df620a0c52fd27ad9bd70eb", 206 | "https://esm.sh/v135/graphql@16.6.0/denonext/language/ast.js": "1c8cc697db4ec96d354496fa69a2d6552ec5801a6c3bdbe5505f369d872d594c", 207 | "https://esm.sh/v135/graphql@16.6.0/denonext/language/parser.js": "82063e5ee234089909652e04ccd9f0e1c0670c04ac6db896099a638ae759b182", 208 | "https://esm.sh/v135/graphql@16.8.1/denonext/graphql.mjs": "585b84022623b931e27a7a8134cd24ec50b33ea12fd18b43254527628a0fddac", 209 | "https://esm.sh/v135/preact-render-to-string@6.4.1/denonext/preact-render-to-string.mjs": "0226110b9ae616d3143c7ea00b822faa1cdd96588188fc3f28e2bf8ead94ba4f", 210 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/channel.ts": "be3b4e157ea3dce0f7b7a73f65450e6f6c24eb1d6828635fc63e8b4c945256c9", 211 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/graphql-schema.ts": "22a155582a45e0c3228e32ea538f938ee959967806d1505b37b315a0249e1e73", 212 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/main.ts": "32b9449bf5308daf38cdb4771ff975668a54ddf61c234eb06f3a0a5acbee4473", 213 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/mod.ts": "2ba3aadf6448e08c69495c618a860646789e96bc33287408bf30c1b480cf8fba", 214 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/resolvers/event.ts": "04cb313d54d517f552fc84e8ad7407237e6498ce48089ce8c0d47de5735f6b43", 215 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/resolvers/event_deletion.ts": "440053969a792351fc2570741b47bcb52635a73ec1012d60af0635b490f5d2f9", 216 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/resolvers/nip11.ts": "0476ce9f77226b04f453fdfbd5db6eeca0e3aa5cd5e998ee0768828a8ca90187", 217 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/resolvers/policy.ts": "5ee0acb5a98bcb3aacb861a23c752ee641c70124110303b14b5c7dcf7b6b752c", 218 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/resolvers/root.ts": "e6b7cd1e3551dbc15b37f924a150b06b05dfe48e827c5b1554d738dec93a7be9", 219 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/routes/_404.tsx": "d38a1b385d27ecbb5e783a6251d3e499da5861bedae851fe05eaa4750b38fc62", 220 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/routes/landing.tsx": "18ff33a1ce5f2538bed83201a1dd48fda607c9dc34b70b3099be13e7cec076eb", 221 | "https://raw.githubusercontent.com/BlowaterNostr/relayed/main/ws.ts": "eb4d1977b833fb0b58a54252c6534acc20a0718a6e5027ac5f251e2b0aa7a2ce" 222 | }, 223 | "workspace": { 224 | "dependencies": [ 225 | "jsr:@blowater/collections@^0.0.0-rc1", 226 | "jsr:@blowater/csp@1.0.0", 227 | "jsr:@noble/secp256k1@2.1.0", 228 | "jsr:@std/assert@0.226.0", 229 | "jsr:@std/datetime@0.224.1", 230 | "jsr:@std/encoding@0.224.3", 231 | "npm:@noble/ciphers@0.4.1", 232 | "npm:@noble/curves@1.4.0", 233 | "npm:@noble/hashes@1.4.0", 234 | "npm:@scure/bip32@1.3.2", 235 | "npm:@scure/bip39@1.2.1", 236 | "npm:zod@3.23.8" 237 | ] 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /scure.ts: -------------------------------------------------------------------------------- 1 | /*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */ 2 | 3 | // Utilities 4 | /** 5 | * @__NO_SIDE_EFFECTS__ 6 | */ 7 | export function assertNumber(n: number) { 8 | if (!Number.isSafeInteger(n)) throw new Error(`Wrong integer: ${n}`); 9 | } 10 | export interface Coder { 11 | encode(from: F): T; 12 | decode(to: T): F; 13 | } 14 | 15 | export interface BytesCoder extends Coder { 16 | encode: (data: Uint8Array) => string; 17 | decode: (str: string) => Uint8Array; 18 | } 19 | 20 | function isBytes(a: unknown): a is Uint8Array { 21 | return ( 22 | a instanceof Uint8Array || 23 | (a != null && typeof a === "object" && a.constructor.name === "Uint8Array") 24 | ); 25 | } 26 | 27 | // TODO: some recusive type inference so it would check correct order of input/output inside rest? 28 | // like , , 29 | type Chain = [Coder, ...Coder[]]; 30 | // Extract info from Coder type 31 | type Input = F extends Coder ? T : never; 32 | type Output = F extends Coder ? T : never; 33 | // Generic function for arrays 34 | type First = T extends [infer U, ...any[]] ? U : never; 35 | type Last = T extends [...any[], infer U] ? U : never; 36 | type Tail = T extends [any, ...infer U] ? U : never; 37 | 38 | type AsChain> = { 39 | // C[K] = Coder, Input> 40 | [K in keyof C]: Coder, Input>; 41 | }; 42 | 43 | /** 44 | * @__NO_SIDE_EFFECTS__ 45 | */ 46 | function chain>(...args: T): Coder>, Output>> { 47 | const id = (a: any) => a; 48 | // Wrap call in closure so JIT can inline calls 49 | const wrap = (a: any, b: any) => (c: any) => a(b(c)); 50 | // Construct chain of args[-1].encode(args[-2].encode([...])) 51 | const encode = args.map((x) => x.encode).reduceRight(wrap, id); 52 | // Construct chain of args[0].decode(args[1].decode(...)) 53 | const decode = args.map((x) => x.decode).reduce(wrap, id); 54 | return { encode, decode }; 55 | } 56 | 57 | type Alphabet = string[] | string; 58 | 59 | /** 60 | * Encodes integer radix representation to array of strings using alphabet and back 61 | * @__NO_SIDE_EFFECTS__ 62 | */ 63 | function alphabet(alphabet: Alphabet): Coder { 64 | return { 65 | encode: (digits: number[]) => { 66 | if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== "number")) { 67 | throw new Error("alphabet.encode input should be an array of numbers"); 68 | } 69 | return digits.map((i) => { 70 | assertNumber(i); 71 | if (i < 0 || i >= alphabet.length) { 72 | throw new Error(`Digit index outside alphabet: ${i} (alphabet: ${alphabet.length})`); 73 | } 74 | return alphabet[i]!; 75 | }); 76 | }, 77 | decode: (input: string[]) => { 78 | if (!Array.isArray(input) || (input.length && typeof input[0] !== "string")) { 79 | throw new Error("alphabet.decode input should be array of strings"); 80 | } 81 | return input.map((letter) => { 82 | if (typeof letter !== "string") { 83 | throw new Error(`alphabet.decode: not string element=${letter}`); 84 | } 85 | const index = alphabet.indexOf(letter); 86 | if (index === -1) throw new Error(`Unknown letter: "${letter}". Allowed: ${alphabet}`); 87 | return index; 88 | }); 89 | }, 90 | }; 91 | } 92 | 93 | /** 94 | * @__NO_SIDE_EFFECTS__ 95 | */ 96 | function join(separator = ""): Coder { 97 | if (typeof separator !== "string") throw new Error("join separator should be string"); 98 | return { 99 | encode: (from) => { 100 | if (!Array.isArray(from) || (from.length && typeof from[0] !== "string")) { 101 | throw new Error("join.encode input should be array of strings"); 102 | } 103 | for (let i of from) { 104 | if (typeof i !== "string") throw new Error(`join.encode: non-string input=${i}`); 105 | } 106 | return from.join(separator); 107 | }, 108 | decode: (to) => { 109 | if (typeof to !== "string") throw new Error("join.decode input should be string"); 110 | return to.split(separator); 111 | }, 112 | }; 113 | } 114 | 115 | /** 116 | * Pad strings array so it has integer number of bits 117 | * @__NO_SIDE_EFFECTS__ 118 | */ 119 | function padding(bits: number, chr = "="): Coder { 120 | assertNumber(bits); 121 | if (typeof chr !== "string") throw new Error("padding chr should be string"); 122 | return { 123 | encode(data: string[]): string[] { 124 | if (!Array.isArray(data) || (data.length && typeof data[0] !== "string")) { 125 | throw new Error("padding.encode input should be array of strings"); 126 | } 127 | for (let i of data) { 128 | if (typeof i !== "string") throw new Error(`padding.encode: non-string input=${i}`); 129 | } 130 | while ((data.length * bits) % 8) data.push(chr); 131 | return data; 132 | }, 133 | decode(input: string[]): string[] { 134 | if (!Array.isArray(input) || (input.length && typeof input[0] !== "string")) { 135 | throw new Error("padding.encode input should be array of strings"); 136 | } 137 | for (let i of input) { 138 | if (typeof i !== "string") throw new Error(`padding.decode: non-string input=${i}`); 139 | } 140 | let end = input.length; 141 | if ((end * bits) % 8) { 142 | throw new Error("Invalid padding: string should have whole number of bytes"); 143 | } 144 | for (; end > 0 && input[end - 1] === chr; end--) { 145 | if (!(((end - 1) * bits) % 8)) { 146 | throw new Error("Invalid padding: string has too much padding"); 147 | } 148 | } 149 | return input.slice(0, end); 150 | }, 151 | }; 152 | } 153 | 154 | /** 155 | * @__NO_SIDE_EFFECTS__ 156 | */ 157 | function normalize(fn: (val: T) => T): Coder { 158 | if (typeof fn !== "function") throw new Error("normalize fn should be function"); 159 | return { encode: (from: T) => from, decode: (to: T) => fn(to) }; 160 | } 161 | 162 | /** 163 | * Slow: O(n^2) time complexity 164 | * @__NO_SIDE_EFFECTS__ 165 | */ 166 | function convertRadix(data: number[], from: number, to: number) { 167 | // base 1 is impossible 168 | if (from < 2) throw new Error(`convertRadix: wrong from=${from}, base cannot be less than 2`); 169 | if (to < 2) throw new Error(`convertRadix: wrong to=${to}, base cannot be less than 2`); 170 | if (!Array.isArray(data)) throw new Error("convertRadix: data should be array"); 171 | if (!data.length) return []; 172 | let pos = 0; 173 | const res = []; 174 | const digits = Array.from(data); 175 | digits.forEach((d) => { 176 | assertNumber(d); 177 | if (d < 0 || d >= from) throw new Error(`Wrong integer: ${d}`); 178 | }); 179 | while (true) { 180 | let carry = 0; 181 | let done = true; 182 | for (let i = pos; i < digits.length; i++) { 183 | const digit = digits[i]!; 184 | const digitBase = from * carry + digit; 185 | if ( 186 | !Number.isSafeInteger(digitBase) || 187 | (from * carry) / from !== carry || 188 | digitBase - digit !== from * carry 189 | ) { 190 | throw new Error("convertRadix: carry overflow"); 191 | } 192 | carry = digitBase % to; 193 | const rounded = Math.floor(digitBase / to); 194 | digits[i] = rounded; 195 | if (!Number.isSafeInteger(rounded) || rounded * to + carry !== digitBase) { 196 | throw new Error("convertRadix: carry overflow"); 197 | } 198 | if (!done) continue; 199 | else if (!rounded) pos = i; 200 | else done = false; 201 | } 202 | res.push(carry); 203 | if (done) break; 204 | } 205 | for (let i = 0; i < data.length - 1 && data[i] === 0; i++) res.push(0); 206 | return res.reverse(); 207 | } 208 | 209 | const gcd = /* @__NO_SIDE_EFFECTS__ */ (a: number, b: number): number => (!b ? a : gcd(b, a % b)); 210 | const radix2carry = /*@__NO_SIDE_EFFECTS__ */ (from: number, to: number) => from + (to - gcd(from, to)); 211 | /** 212 | * Implemented with numbers, because BigInt is 5x slower 213 | * @__NO_SIDE_EFFECTS__ 214 | */ 215 | function convertRadix2(data: number[], from: number, to: number, padding: boolean): number[] { 216 | if (!Array.isArray(data)) throw new Error("convertRadix2: data should be array"); 217 | if (from <= 0 || from > 32) throw new Error(`convertRadix2: wrong from=${from}`); 218 | if (to <= 0 || to > 32) throw new Error(`convertRadix2: wrong to=${to}`); 219 | if (radix2carry(from, to) > 32) { 220 | throw new Error( 221 | `convertRadix2: carry overflow from=${from} to=${to} carryBits=${radix2carry(from, to)}`, 222 | ); 223 | } 224 | let carry = 0; 225 | let pos = 0; // bitwise position in current element 226 | const mask = 2 ** to - 1; 227 | const res: number[] = []; 228 | for (const n of data) { 229 | assertNumber(n); 230 | if (n >= 2 ** from) throw new Error(`convertRadix2: invalid data word=${n} from=${from}`); 231 | carry = (carry << from) | n; 232 | if (pos + from > 32) throw new Error(`convertRadix2: carry overflow pos=${pos} from=${from}`); 233 | pos += from; 234 | for (; pos >= to; pos -= to) res.push(((carry >> (pos - to)) & mask) >>> 0); 235 | carry &= 2 ** pos - 1; // clean carry, otherwise it will cause overflow 236 | } 237 | carry = (carry << (to - pos)) & mask; 238 | if (!padding && pos >= from) throw new Error("Excess padding"); 239 | if (!padding && carry) throw new Error(`Non-zero padding: ${carry}`); 240 | if (padding && pos > 0) res.push(carry >>> 0); 241 | return res; 242 | } 243 | 244 | /** 245 | * @__NO_SIDE_EFFECTS__ 246 | */ 247 | function radix(num: number): Coder { 248 | assertNumber(num); 249 | return { 250 | encode: (bytes: Uint8Array) => { 251 | if (!isBytes(bytes)) throw new Error("radix.encode input should be Uint8Array"); 252 | return convertRadix(Array.from(bytes), 2 ** 8, num); 253 | }, 254 | decode: (digits: number[]) => { 255 | if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== "number")) { 256 | throw new Error("radix.decode input should be array of numbers"); 257 | } 258 | return Uint8Array.from(convertRadix(digits, num, 2 ** 8)); 259 | }, 260 | }; 261 | } 262 | 263 | /** 264 | * If both bases are power of same number (like `2**8 <-> 2**64`), 265 | * there is a linear algorithm. For now we have implementation for power-of-two bases only. 266 | * @__NO_SIDE_EFFECTS__ 267 | */ 268 | function radix2(bits: number, revPadding = false): Coder { 269 | assertNumber(bits); 270 | if (bits <= 0 || bits > 32) throw new Error("radix2: bits should be in (0..32]"); 271 | if (radix2carry(8, bits) > 32 || radix2carry(bits, 8) > 32) { 272 | throw new Error("radix2: carry overflow"); 273 | } 274 | return { 275 | encode: (bytes: Uint8Array) => { 276 | if (!isBytes(bytes)) throw new Error("radix2.encode input should be Uint8Array"); 277 | return convertRadix2(Array.from(bytes), 8, bits, !revPadding); 278 | }, 279 | decode: (digits: number[]) => { 280 | if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== "number")) { 281 | throw new Error("radix2.decode input should be array of numbers"); 282 | } 283 | return Uint8Array.from(convertRadix2(digits, bits, 8, revPadding)); 284 | }, 285 | }; 286 | } 287 | 288 | type ArgumentTypes = F extends (...args: infer A) => any ? A : never; 289 | /** 290 | * @__NO_SIDE_EFFECTS__ 291 | */ 292 | function unsafeWrapper any>(fn: T) { 293 | if (typeof fn !== "function") throw new Error("unsafeWrapper fn should be function"); 294 | return function (...args: ArgumentTypes): ReturnType | void { 295 | try { 296 | return fn.apply(null, args); 297 | } catch (e) {} 298 | }; 299 | } 300 | 301 | /** 302 | * @__NO_SIDE_EFFECTS__ 303 | */ 304 | function checksum( 305 | len: number, 306 | fn: (data: Uint8Array) => Uint8Array, 307 | ): Coder { 308 | assertNumber(len); 309 | if (typeof fn !== "function") throw new Error("checksum fn should be function"); 310 | return { 311 | encode(data: Uint8Array) { 312 | if (!isBytes(data)) throw new Error("checksum.encode: input should be Uint8Array"); 313 | const checksum = fn(data).slice(0, len); 314 | const res = new Uint8Array(data.length + len); 315 | res.set(data); 316 | res.set(checksum, data.length); 317 | return res; 318 | }, 319 | decode(data: Uint8Array) { 320 | if (!isBytes(data)) throw new Error("checksum.decode: input should be Uint8Array"); 321 | const payload = data.slice(0, -len); 322 | const newChecksum = fn(payload).slice(0, len); 323 | const oldChecksum = data.slice(-len); 324 | for (let i = 0; i < len; i++) { 325 | if (newChecksum[i] !== oldChecksum[i]) throw new Error("Invalid checksum"); 326 | } 327 | return payload; 328 | }, 329 | }; 330 | } 331 | 332 | // prettier-ignore 333 | export const utils = { 334 | alphabet, 335 | chain, 336 | checksum, 337 | convertRadix, 338 | convertRadix2, 339 | radix, 340 | radix2, 341 | join, 342 | padding, 343 | }; 344 | 345 | // RFC 4648 aka RFC 3548 346 | // --------------------- 347 | export const base16: BytesCoder = /* @__PURE__ */ chain( 348 | radix2(4), 349 | alphabet("0123456789ABCDEF"), 350 | join(""), 351 | ); 352 | export const base32: BytesCoder = /* @__PURE__ */ chain( 353 | radix2(5), 354 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), 355 | padding(5), 356 | join(""), 357 | ); 358 | export const base32nopad: BytesCoder = /* @__PURE__ */ chain( 359 | radix2(5), 360 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), 361 | join(""), 362 | ); 363 | export const base32hex: BytesCoder = /* @__PURE__ */ chain( 364 | radix2(5), 365 | alphabet("0123456789ABCDEFGHIJKLMNOPQRSTUV"), 366 | padding(5), 367 | join(""), 368 | ); 369 | export const base32hexnopad: BytesCoder = /* @__PURE__ */ chain( 370 | radix2(5), 371 | alphabet("0123456789ABCDEFGHIJKLMNOPQRSTUV"), 372 | join(""), 373 | ); 374 | export const base32crockford: BytesCoder = /* @__PURE__ */ chain( 375 | radix2(5), 376 | alphabet("0123456789ABCDEFGHJKMNPQRSTVWXYZ"), 377 | join(""), 378 | normalize((s: string) => s.toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1")), 379 | ); 380 | export const base64: BytesCoder = /* @__PURE__ */ chain( 381 | radix2(6), 382 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), 383 | padding(6), 384 | join(""), 385 | ); 386 | export const base64nopad: BytesCoder = /* @__PURE__ */ chain( 387 | radix2(6), 388 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), 389 | join(""), 390 | ); 391 | export const base64url: BytesCoder = /* @__PURE__ */ chain( 392 | radix2(6), 393 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), 394 | padding(6), 395 | join(""), 396 | ); 397 | export const base64urlnopad: BytesCoder = /* @__PURE__ */ chain( 398 | radix2(6), 399 | alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), 400 | join(""), 401 | ); 402 | 403 | // base58 code 404 | // ----------- 405 | const genBase58 = (abc: string) => chain(radix(58), alphabet(abc), join("")); 406 | 407 | export const base58: BytesCoder = /* @__PURE__ */ genBase58( 408 | "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", 409 | ); 410 | export const base58flickr: BytesCoder = /* @__PURE__ */ genBase58( 411 | "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ", 412 | ); 413 | export const base58xrp: BytesCoder = /* @__PURE__ */ genBase58( 414 | "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz", 415 | ); 416 | 417 | // xmr ver is done in 8-byte blocks (which equals 11 chars in decoding). Last (non-full) block padded with '1' to size in XMR_BLOCK_LEN. 418 | // Block encoding significantly reduces quadratic complexity of base58. 419 | 420 | // Data len (index) -> encoded block len 421 | const XMR_BLOCK_LEN = [0, 2, 3, 5, 6, 7, 9, 10, 11]; 422 | export const base58xmr: BytesCoder = { 423 | encode(data: Uint8Array) { 424 | let res = ""; 425 | for (let i = 0; i < data.length; i += 8) { 426 | const block = data.subarray(i, i + 8); 427 | res += base58.encode(block).padStart(XMR_BLOCK_LEN[block.length]!, "1"); 428 | } 429 | return res; 430 | }, 431 | decode(str: string) { 432 | let res: number[] = []; 433 | for (let i = 0; i < str.length; i += 11) { 434 | const slice = str.slice(i, i + 11); 435 | const blockLen = XMR_BLOCK_LEN.indexOf(slice.length); 436 | const block = base58.decode(slice); 437 | for (let j = 0; j < block.length - blockLen; j++) { 438 | if (block[j] !== 0) throw new Error("base58xmr: wrong padding"); 439 | } 440 | res = res.concat(Array.from(block.slice(block.length - blockLen))); 441 | } 442 | return Uint8Array.from(res); 443 | }, 444 | }; 445 | 446 | export const createBase58check = (sha256: (data: Uint8Array) => Uint8Array): BytesCoder => 447 | chain( 448 | checksum(4, (data) => sha256(sha256(data))), 449 | base58, 450 | ); 451 | // legacy export, bad name 452 | export const base58check = createBase58check; 453 | 454 | // Bech32 code 455 | // ----------- 456 | export interface Bech32Decoded { 457 | prefix: Prefix; 458 | words: number[]; 459 | } 460 | export interface Bech32DecodedWithArray { 461 | prefix: Prefix; 462 | words: number[]; 463 | bytes: Uint8Array; 464 | } 465 | 466 | const BECH_ALPHABET: Coder = /* @__PURE__ */ chain( 467 | alphabet("qpzry9x8gf2tvdw0s3jn54khce6mua7l"), 468 | join(""), 469 | ); 470 | 471 | const POLYMOD_GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; 472 | /** 473 | * @__NO_SIDE_EFFECTS__ 474 | */ 475 | function bech32Polymod(pre: number): number { 476 | const b = pre >> 25; 477 | let chk = (pre & 0x1ffffff) << 5; 478 | for (let i = 0; i < POLYMOD_GENERATORS.length; i++) { 479 | if (((b >> i) & 1) === 1) chk ^= POLYMOD_GENERATORS[i]!; 480 | } 481 | return chk; 482 | } 483 | 484 | /** 485 | * @__NO_SIDE_EFFECTS__ 486 | */ 487 | function bechChecksum(prefix: string, words: number[], encodingConst = 1): string { 488 | const len = prefix.length; 489 | let chk = 1; 490 | for (let i = 0; i < len; i++) { 491 | const c = prefix.charCodeAt(i); 492 | if (c < 33 || c > 126) throw new Error(`Invalid prefix (${prefix})`); 493 | chk = bech32Polymod(chk) ^ (c >> 5); 494 | } 495 | chk = bech32Polymod(chk); 496 | for (let i = 0; i < len; i++) chk = bech32Polymod(chk) ^ (prefix.charCodeAt(i) & 0x1f); 497 | for (let v of words) chk = bech32Polymod(chk) ^ v; 498 | for (let i = 0; i < 6; i++) chk = bech32Polymod(chk); 499 | chk ^= encodingConst; 500 | return BECH_ALPHABET.encode(convertRadix2([chk % 2 ** 30], 30, 5, false)); 501 | } 502 | 503 | /** 504 | * @__NO_SIDE_EFFECTS__ 505 | */ 506 | function genBech32(encoding: "bech32" | "bech32m") { 507 | const ENCODING_CONST = encoding === "bech32" ? 1 : 0x2bc830a3; 508 | const _words = radix2(5); 509 | const fromWords = _words.decode; 510 | const toWords = _words.encode; 511 | const fromWordsUnsafe = unsafeWrapper(fromWords); 512 | 513 | function encode( 514 | prefix: Prefix, 515 | words: number[] | Uint8Array, 516 | limit: number | false = 90, 517 | ): `${Lowercase}1${string}` { 518 | if (typeof prefix !== "string") { 519 | throw new Error(`bech32.encode prefix should be string, not ${typeof prefix}`); 520 | } 521 | if (!Array.isArray(words) || (words.length && typeof words[0] !== "number")) { 522 | throw new Error(`bech32.encode words should be array of numbers, not ${typeof words}`); 523 | } 524 | if (prefix.length === 0) throw new TypeError(`Invalid prefix length ${prefix.length}`); 525 | const actualLength = prefix.length + 7 + words.length; 526 | if (limit !== false && actualLength > limit) { 527 | throw new TypeError(`Length ${actualLength} exceeds limit ${limit}`); 528 | } 529 | const lowered = prefix.toLowerCase(); 530 | const sum = bechChecksum(lowered, words, ENCODING_CONST); 531 | return `${lowered}1${BECH_ALPHABET.encode(words)}${sum}` as `${Lowercase}1${string}`; 532 | } 533 | 534 | function decode( 535 | str: `${Prefix}1${string}`, 536 | limit?: number | false, 537 | ): Bech32Decoded; 538 | function decode(str: string, limit?: number | false): Bech32Decoded; 539 | function decode(str: string, limit: number | false = 90): Bech32Decoded { 540 | if (typeof str !== "string") { 541 | throw new Error(`bech32.decode input should be string, not ${typeof str}`); 542 | } 543 | if (str.length < 8 || (limit !== false && str.length > limit)) { 544 | throw new TypeError(`Wrong string length: ${str.length} (${str}). Expected (8..${limit})`); 545 | } 546 | // don't allow mixed case 547 | const lowered = str.toLowerCase(); 548 | if (str !== lowered && str !== str.toUpperCase()) { 549 | throw new Error(`String must be lowercase or uppercase`); 550 | } 551 | const sepIndex = lowered.lastIndexOf("1"); 552 | if (sepIndex === 0 || sepIndex === -1) { 553 | throw new Error(`Letter "1" must be present between prefix and data only`); 554 | } 555 | const prefix = lowered.slice(0, sepIndex); 556 | const data = lowered.slice(sepIndex + 1); 557 | if (data.length < 6) throw new Error("Data must be at least 6 characters long"); 558 | const words = BECH_ALPHABET.decode(data).slice(0, -6); 559 | const sum = bechChecksum(prefix, words, ENCODING_CONST); 560 | if (!data.endsWith(sum)) throw new Error(`Invalid checksum in ${str}: expected "${sum}"`); 561 | return { prefix, words }; 562 | } 563 | 564 | const decodeUnsafe = unsafeWrapper(decode); 565 | 566 | function decodeToBytes(str: string): Bech32DecodedWithArray { 567 | const { prefix, words } = decode(str, false); 568 | return { prefix, words, bytes: fromWords(words) }; 569 | } 570 | 571 | return { encode, decode, decodeToBytes, decodeUnsafe, fromWords, fromWordsUnsafe, toWords }; 572 | } 573 | 574 | export const bech32 = /* @__PURE__ */ genBech32("bech32"); 575 | export const bech32m = /* @__PURE__ */ genBech32("bech32m"); 576 | 577 | declare const TextEncoder: any; 578 | declare const TextDecoder: any; 579 | 580 | export const utf8: BytesCoder = { 581 | encode: (data) => new TextDecoder().decode(data), 582 | decode: (str) => new TextEncoder().encode(str), 583 | }; 584 | 585 | export const hex: BytesCoder = /* @__PURE__ */ chain( 586 | radix2(4), 587 | alphabet("0123456789abcdef"), 588 | join(""), 589 | normalize((s: string) => { 590 | if (typeof s !== "string" || s.length % 2) { 591 | throw new TypeError(`hex.decode: expected string, got ${typeof s} with length ${s.length}`); 592 | } 593 | return s.toLowerCase(); 594 | }), 595 | ); 596 | 597 | // prettier-ignore 598 | const CODERS = { 599 | utf8, 600 | hex, 601 | base16, 602 | base32, 603 | base64, 604 | base64url, 605 | base58, 606 | base58xmr, 607 | }; 608 | type CoderType = keyof typeof CODERS; 609 | const coderTypeError = 610 | "Invalid encoding type. Available types: utf8, hex, base16, base32, base64, base64url, base58, base58xmr"; 611 | 612 | export const bytesToString = (type: CoderType, bytes: Uint8Array): string => { 613 | if (typeof type !== "string" || !CODERS.hasOwnProperty(type)) throw new TypeError(coderTypeError); 614 | if (!isBytes(bytes)) throw new TypeError("bytesToString() expects Uint8Array"); 615 | return CODERS[type].encode(bytes); 616 | }; 617 | export const str = bytesToString; // as in python, but for bytes only 618 | 619 | export const stringToBytes = (type: CoderType, str: string): Uint8Array => { 620 | if (!CODERS.hasOwnProperty(type)) throw new TypeError(coderTypeError); 621 | if (typeof str !== "string") throw new TypeError("stringToBytes() expects string"); 622 | return CODERS[type].decode(str); 623 | }; 624 | export const bytes = stringToBytes; 625 | -------------------------------------------------------------------------------- /relay-single.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "@blowater/csp"; 2 | import { newURL, parseJSON, RESTRequestFailed } from "./_helper.ts"; 3 | import { prepareNostrEvent } from "./event.ts"; 4 | import { PublicKey } from "./key.ts"; 5 | import { getRelayInformation, type RelayInformation } from "./nip11.ts"; 6 | import { NoteID } from "./nip19.ts"; 7 | import { 8 | type _RelayResponse, 9 | type ClientRequest_REQ, 10 | type NostrEvent, 11 | type NostrFilter, 12 | NostrKind, 13 | type RelayResponse_REQ_Message, 14 | type Signer, 15 | } from "./nostr.ts"; 16 | import type { Closer, EventSender, Subscriber, SubscriptionCloser } from "./relay.interface.ts"; 17 | import { 18 | AsyncWebSocket, 19 | CloseTwice, 20 | type WebSocketClosedEvent, 21 | type WebSocketError, 22 | type WebSocketReadyState, 23 | } from "./websocket.ts"; 24 | import * as csp from "@blowater/csp"; 25 | import { getSpaceMembers, prepareSpaceMember } from "./space-member.ts"; 26 | import { assertEquals } from "@std/assert"; 27 | import type { Event_V2, Signer_V2, SpaceMember } from "./v2.ts"; 28 | 29 | export class WebSocketClosed extends Error { 30 | constructor( 31 | public url: string | URL, 32 | public state: WebSocketReadyState, 33 | public reason?: WebSocketClosedEvent, 34 | ) { 35 | super(`${url} is in state ${state}, code ${reason?.code}`); 36 | this.name = WebSocketClosed.name; 37 | } 38 | } 39 | 40 | export class RelayDisconnectedByClient extends Error { 41 | constructor() { 42 | super(); 43 | this.name = RelayDisconnectedByClient.name; 44 | } 45 | } 46 | 47 | export class FailedToLookupAddress extends Error {} 48 | 49 | export type NextMessageType = { 50 | type: "messsage"; 51 | data: string; 52 | } | { 53 | type: "WebSocketClosed"; 54 | error: WebSocketClosed; 55 | } | { 56 | type: "RelayDisconnectedByClient"; 57 | error: RelayDisconnectedByClient; 58 | } | { 59 | type: "FailedToLookupAddress"; 60 | error: string; 61 | } | { 62 | type: "OtherError"; 63 | error: WebSocketError; 64 | } | { 65 | type: "open"; 66 | } | { 67 | type: "closed"; 68 | event: WebSocketClosedEvent; 69 | }; 70 | 71 | export type BidirectionalNetwork = { 72 | status(): WebSocketReadyState; 73 | untilOpen(): Promise; 74 | nextMessage(): Promise< 75 | NextMessageType 76 | >; 77 | send: ( 78 | str: string | ArrayBufferLike | Blob | ArrayBufferView, 79 | ) => Promise; 80 | close: ( 81 | code?: number, 82 | reason?: string, 83 | force?: boolean, 84 | ) => Promise; 85 | }; 86 | 87 | export class SubscriptionAlreadyExist extends Error { 88 | constructor(public subID: string, public url: string) { 89 | super(`subscription '${subID}' already exists for ${url}`); 90 | } 91 | } 92 | 93 | export type SubscriptionStream = { 94 | filters: NostrFilter[]; 95 | chan: csp.Channel; 96 | }; 97 | 98 | /** 99 | * [examples](./tests/example.test.ts) 100 | */ 101 | export class SingleRelayConnection implements Subscriber, SubscriptionCloser, EventSender, Closer { 102 | private _isClosedByClient = false; 103 | isClosedByClient() { 104 | return this._isClosedByClient; 105 | } 106 | 107 | private subscriptionMap = new Map< 108 | string, 109 | SubscriptionStream 110 | >(); 111 | readonly send_promise_resolvers = new Map< 112 | string, 113 | (res: { ok: boolean; message: string }) => void 114 | >(); 115 | private error: AuthError | RelayDisconnectedByClient | undefined; // todo: check this error in public APIs 116 | private ws: BidirectionalNetwork | undefined; 117 | 118 | status(): WebSocketReadyState { 119 | if (this.ws == undefined) { 120 | return "Closed"; 121 | } 122 | return this.ws.status(); 123 | } 124 | 125 | private constructor( 126 | readonly url: URL, 127 | readonly wsCreator: (url: string, log: boolean) => BidirectionalNetwork | Error, 128 | public log: boolean, 129 | readonly signer?: Signer, 130 | readonly signer_v2?: Signer_V2, 131 | ) { 132 | (async () => { 133 | const ws = await this.connect(); 134 | if (ws instanceof Error) { 135 | this.error = ws; 136 | return ws; 137 | } 138 | this.ws = ws; 139 | for (;;) { 140 | const messsage = await this.nextMessage(this.ws); 141 | if (messsage.type == "RelayDisconnectedByClient") { 142 | this.error = messsage.error; 143 | // exit the coroutine 144 | return messsage.error; 145 | } else if ( 146 | messsage.type == "WebSocketClosed" || 147 | messsage.type == "FailedToLookupAddress" || 148 | messsage.type == "OtherError" || messsage.type == "closed" 149 | ) { 150 | if (messsage.type != "closed") { 151 | if (messsage.error instanceof Error) { 152 | this.error = messsage.error; 153 | } else if (typeof messsage.error == "string") { 154 | this.error = new Error(messsage.error); 155 | } else { 156 | console.error(messsage); 157 | this.error = new Error(messsage.error.error); 158 | } 159 | } 160 | if (messsage.type == "closed") { 161 | // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4 162 | // https://www.iana.org/assignments/websocket/websocket.xml#close-code-number 163 | if (messsage.event.code == 3000) { 164 | // close all sub channels 165 | for (const stream of this.subscriptionMap) { 166 | const e = await this.closeSub(stream[0]); 167 | if (e instanceof Error) { 168 | console.error(e); 169 | } 170 | } 171 | const err = new AuthError(messsage.event.reason); 172 | // resolve all send_promise_resolvers to false 173 | for (const [_, resolver] of this.send_promise_resolvers) { 174 | resolver({ 175 | ok: false, 176 | message: err.message, 177 | }); 178 | } 179 | return err; 180 | } 181 | } 182 | if (this._isClosedByClient == false) { 183 | console.log("connection error", messsage); 184 | const err = await this.connect(); 185 | if (err instanceof RelayDisconnectedByClient) { 186 | return err; 187 | } 188 | if (err instanceof Error) { 189 | console.error(err); 190 | this.error = err; 191 | } 192 | } 193 | continue; 194 | } else if (messsage.type == "open") { 195 | if (this.log) { 196 | console.log(`relay connection ${this.url} is openned`); 197 | } 198 | // the websocket is just openned 199 | // send all the subscriptions to the relay 200 | for (const [subID, data] of this.subscriptionMap.entries()) { 201 | if (this.ws == undefined) { 202 | console.error("impossible state"); 203 | break; 204 | } 205 | const err = await sendSubscription(this.ws, subID, ...data.filters); 206 | if (err instanceof Error) { 207 | console.error(err); 208 | } 209 | } 210 | } else { 211 | const relayResponse = parseJSON<_RelayResponse>(messsage.data); 212 | if (relayResponse instanceof Error) { 213 | console.error(relayResponse); 214 | continue; 215 | } 216 | 217 | if ( 218 | relayResponse[0] === "EVENT" || 219 | relayResponse[0] === "EOSE" 220 | ) { 221 | const subID = relayResponse[1]; 222 | const subscription = this.subscriptionMap.get( 223 | subID, 224 | ); 225 | if (subscription === undefined) { 226 | // the subscription has been closed locally before receiving remote messages 227 | // or the relay sends to the wrong connection 228 | continue; 229 | } 230 | const chan = subscription.chan; 231 | if (!chan.closed()) { 232 | if (relayResponse[0] === "EOSE") { 233 | chan.put({ 234 | type: relayResponse[0], 235 | subID: relayResponse[1], 236 | }); 237 | } else { 238 | chan.put({ 239 | type: relayResponse[0], 240 | subID: relayResponse[1], 241 | event: relayResponse[2], 242 | }); 243 | } 244 | } 245 | } else if (relayResponse[0] == "OK") { 246 | const resolver = this.send_promise_resolvers.get(relayResponse[1]); 247 | if (resolver) { 248 | const ok = relayResponse[2]; 249 | const message = relayResponse[3]; 250 | resolver({ ok, message }); 251 | } 252 | } else { 253 | for (const sub of this.subscriptionMap.values()) { 254 | sub.chan.put({ 255 | type: "NOTICE", 256 | note: relayResponse[1], 257 | }); 258 | } 259 | console.log(url, relayResponse); // NOTICE, OK and other non-standard response types 260 | } 261 | } 262 | } 263 | })().then((res) => { 264 | if (res instanceof RelayDisconnectedByClient) { 265 | if (this.log) { 266 | console.log(res); 267 | } 268 | return; 269 | } 270 | if (res instanceof Error) { 271 | this.error = res; 272 | } else { 273 | console.error(res); 274 | } 275 | }); 276 | } 277 | 278 | public static New( 279 | urlString: string, 280 | args?: { 281 | wsCreator?: (url: string, log: boolean) => BidirectionalNetwork | Error; 282 | connect?: boolean; 283 | log?: boolean; 284 | signer?: Signer; // used for authentication 285 | signer_v2?: Signer_V2; // used for sign event v2 286 | }, 287 | ): SingleRelayConnection | TypeError { 288 | if (args == undefined) { 289 | args = {}; 290 | } 291 | try { 292 | if (!urlString.startsWith("wss://") && !urlString.startsWith("ws://")) { 293 | urlString = "wss://" + urlString; 294 | } 295 | if (args.wsCreator == undefined) { 296 | args.wsCreator = AsyncWebSocket.New; 297 | } 298 | const url = newURL(urlString); 299 | if (url instanceof TypeError) { 300 | return url; 301 | } 302 | return new SingleRelayConnection( 303 | url, 304 | args.wsCreator, 305 | args.log || false, 306 | args.signer, 307 | args.signer_v2, 308 | ); 309 | } catch (e) { 310 | if (e instanceof Error) { 311 | return e; 312 | } else { 313 | throw e; // impossible 314 | } 315 | } 316 | } 317 | 318 | async newSub(subID: string, ...filters: NostrFilter[]) { 319 | if (this.error instanceof AuthError) { 320 | return this.error; 321 | } 322 | if (this.log) { 323 | console.log(`${this.url} registers subscription ${subID}`, ...filters); 324 | } 325 | 326 | const subscription = this.subscriptionMap.get(subID); 327 | if (subscription !== undefined) { 328 | return new SubscriptionAlreadyExist(subID, this.url.toString()); 329 | } 330 | 331 | if (this.ws != undefined) { 332 | const err = await sendSubscription(this.ws, subID, ...filters); 333 | if (err instanceof Error) { 334 | console.error(err); 335 | } 336 | } 337 | 338 | const chan = csp.chan(); 339 | this.subscriptionMap.set(subID, { filters, chan }); 340 | return { filters, chan }; 341 | } 342 | 343 | async sendEvent(event: NostrEvent) { 344 | if (this.ws == undefined) { 345 | return new WebSocketClosed(this.url.toString(), this.status()); 346 | } 347 | if (this.error) { 348 | return this.error; 349 | } 350 | const err = await this.ws.send(JSON.stringify([ 351 | "EVENT", 352 | event, 353 | ])); 354 | if (err instanceof Error) { 355 | return err; 356 | } 357 | 358 | const res = await new Promise<{ ok: boolean; message: string }>( 359 | (resolve) => { 360 | this.send_promise_resolvers.set(event.id, resolve); 361 | }, 362 | ); 363 | if (!res.ok) { 364 | return new RelayRejectedEvent(res.message, event); 365 | } 366 | return res.message; 367 | } 368 | 369 | async getEvent(id: NoteID | string) { 370 | if (this.error) { 371 | return this.error; 372 | } 373 | if (id instanceof NoteID) { 374 | id = id.hex; 375 | } 376 | 377 | const err = await this.closeSub(id); 378 | if (err instanceof Error) return err; 379 | 380 | const events = await this.newSub(id, { ids: [id] }); 381 | if (events instanceof Error) { 382 | return events; 383 | } 384 | for await (const msg of events.chan) { 385 | const err = await this.closeSub(id); 386 | if (err instanceof Error) return err; 387 | 388 | if (msg.type == "EVENT") { 389 | return msg.event; 390 | } else if (msg.type == "NOTICE") { 391 | // todo: give a concrete type 392 | return new Error(msg.note); 393 | } else if (msg.type == "EOSE") { 394 | return; 395 | } 396 | } 397 | } 398 | 399 | async getReplaceableEvent(pubkey: PublicKey, kind: NostrKind) { 400 | const subID = `${pubkey.bech32()}:${kind}`; 401 | const err = await this.closeSub(subID); 402 | if (err instanceof Error) return err; 403 | 404 | const events = await this.newSub(subID, { 405 | authors: [pubkey.hex], 406 | kinds: [kind], 407 | limit: 1, 408 | }); 409 | if (events instanceof Error) { 410 | return events; 411 | } 412 | for await (const msg of events.chan) { 413 | const err = await this.closeSub(subID); 414 | if (err instanceof Error) return err; 415 | 416 | if (msg.type == "EVENT") { 417 | return msg.event; 418 | } else if (msg.type == "NOTICE") { 419 | return new Error(msg.note); 420 | } else if (msg.type == "EOSE") { 421 | return; 422 | } 423 | } 424 | } 425 | 426 | async closeSub(subID: string) { 427 | let err; 428 | if (this.ws != undefined) { 429 | err = await this.ws.send(JSON.stringify([ 430 | "CLOSE", 431 | subID, // multiplex marker / channel 432 | ])); 433 | } 434 | 435 | const subscription = this.subscriptionMap.get(subID); 436 | if (subscription === undefined) { 437 | return; 438 | } 439 | 440 | try { 441 | await subscription.chan.close(); 442 | } catch (e) { 443 | if (!(e instanceof csp.CloseChannelTwiceError)) { 444 | throw e; 445 | } 446 | } 447 | this.subscriptionMap.delete(subID); 448 | return err; 449 | } 450 | 451 | close = async (force?: boolean) => { 452 | this._isClosedByClient = true; 453 | for (const [subID, { chan }] of this.subscriptionMap.entries()) { 454 | if (chan.closed()) { 455 | continue; 456 | } 457 | await this.closeSub(subID); 458 | } 459 | if (this.ws) { 460 | await this.ws.close(undefined, undefined, force ? true : false); 461 | } 462 | // the WebSocket constructor is async underneath but since it's too old, 463 | // it does not have an awaitable interface so that exiting the program may cause 464 | // unresolved event underneath 465 | // this is a quick & dirty way for me to address it 466 | // old browser API sucks 467 | await csp.sleep(1); 468 | if (this.log) { 469 | console.log(`relay ${this.url} closed, status: ${this.status()}`); 470 | } 471 | }; 472 | 473 | [Symbol.asyncDispose] = () => { 474 | return this.close(); 475 | }; 476 | 477 | isClosed(): boolean { 478 | if (this.ws == undefined) { 479 | return true; 480 | } 481 | return this.ws.status() == "Closed" || this.ws.status() == "Closing"; 482 | } 483 | 484 | private async connect() { 485 | if (this.error instanceof Error) { 486 | return this.error; 487 | } 488 | let ws: BidirectionalNetwork | Error | undefined; 489 | for (;;) { 490 | if (this.log) { 491 | console.log(`(re)connecting ${this.url}`); 492 | } 493 | if (this.isClosedByClient()) { 494 | return new RelayDisconnectedByClient(); 495 | } 496 | if (this.ws) { 497 | const status = this.ws.status(); 498 | if (status == "Connecting" || status == "Open") { 499 | return this.ws; 500 | } 501 | } 502 | 503 | if (this.signer) { 504 | this.url.searchParams.set( 505 | "auth", 506 | btoa(JSON.stringify( 507 | await prepareNostrEvent(this.signer, { 508 | kind: NostrKind.HTTP_AUTH, 509 | content: "", 510 | }), 511 | )), 512 | ); 513 | } 514 | ws = this.wsCreator(this.url.toString(), this.log); 515 | if (ws instanceof Error) { 516 | console.error(ws.name, ws.message, ws.cause); 517 | if (ws.name == "SecurityError") { 518 | return ws; 519 | } 520 | continue; 521 | } 522 | break; 523 | } 524 | this.ws = ws; 525 | return this.ws; 526 | } 527 | 528 | private async nextMessage(ws: BidirectionalNetwork): Promise { 529 | if (this.isClosedByClient()) { 530 | return { 531 | type: "RelayDisconnectedByClient", 532 | error: new RelayDisconnectedByClient(), 533 | }; 534 | } 535 | const message = await ws.nextMessage(); 536 | return message; 537 | } 538 | 539 | unstable = { 540 | /** 541 | * before we have relay info as events, 542 | * let's pull it periodically to have an async iterable API 543 | */ 544 | getRelayInformationStream: () => { 545 | const chan = csp.chan(); 546 | (async () => { 547 | let spaceInformation: RelayInformation | Error | undefined; 548 | for (;;) { 549 | if (chan.closed()) return; 550 | const info = await this.unstable.getSpaceInformation(); 551 | if (info instanceof Error || !deepEqual(spaceInformation, info)) { 552 | spaceInformation = info; 553 | const err = await chan.put(info); 554 | if (err instanceof Error) { 555 | // the channel is closed by outside, stop the stream 556 | return; 557 | } 558 | } 559 | await sleep(3000); // every 3 sec 560 | } 561 | })(); 562 | return chan; 563 | }, 564 | postEventV2: async (event: Event_V2): Promise => { 565 | const httpURL = new URL(this.url); 566 | httpURL.protocol = httpURL.protocol == "wss:" ? "https" : "http"; 567 | try { 568 | return await fetch(httpURL, { method: "POST", body: JSON.stringify(event) }); 569 | } catch (e) { 570 | return e as Error; 571 | } 572 | }, 573 | /** 574 | * v2 API, unstable 575 | * add a public key to this relay as its member 576 | */ 577 | addSpaceMember: async (member: PublicKey | string): Promise => { 578 | if (!this.signer_v2) { 579 | return new SignerV2NotExist(); 580 | } 581 | const spaceMemberEvent = await prepareSpaceMember(this.signer_v2, member); 582 | if (spaceMemberEvent instanceof Error) { 583 | return spaceMemberEvent; 584 | } 585 | return await this.unstable.postEventV2(spaceMemberEvent); 586 | }, 587 | /** 588 | * v2 API, unstable 589 | * a stream of space members 590 | */ 591 | getSpaceMembersStream: () => { 592 | const chan = csp.chan< 593 | RESTRequestFailed | TypeError | SyntaxError | Error | SpaceMember[] 594 | >(); 595 | (async () => { 596 | let spaceMembers: 597 | | SpaceMember[] 598 | | RESTRequestFailed 599 | | TypeError 600 | | SyntaxError 601 | | Error 602 | | undefined; 603 | for (;;) { 604 | if (chan.closed()) return; 605 | const members = await getSpaceMembers(this.url); 606 | if (members instanceof Error) { 607 | if (members instanceof RESTRequestFailed) { 608 | if (members.res.status == 404) { 609 | await chan.put(members); 610 | await chan.close(); 611 | } else { 612 | await chan.put(members); 613 | } 614 | } else { 615 | await chan.put(members); 616 | } 617 | } else if (!deepEqual(spaceMembers, members)) { 618 | spaceMembers = members; 619 | const err = await chan.put(members); 620 | if (err instanceof Error) { 621 | // the channel is closed by outside, stop the stream 622 | return; 623 | } 624 | } 625 | await sleep(3000); // every 3 sec 626 | } 627 | })(); 628 | return chan; 629 | }, 630 | getSpaceInformation: () => { 631 | return getRelayInformation(this.url); 632 | }, 633 | }; 634 | } 635 | 636 | async function sendSubscription(ws: BidirectionalNetwork, subID: string, ...filters: NostrFilter[]) { 637 | const req: ClientRequest_REQ = ["REQ", subID, ...filters]; 638 | const err = await ws.send(JSON.stringify(req)); 639 | if (err) { 640 | return err; 641 | } 642 | } 643 | 644 | export class RelayRejectedEvent extends Error { 645 | constructor(msg: string, public readonly event: NostrEvent) { 646 | super(`${event.id}: ${msg}`); 647 | this.name = RelayRejectedEvent.name; 648 | } 649 | } 650 | 651 | export class AuthError extends Error { 652 | constructor(msg: string) { 653 | super(msg); 654 | this.name = AuthError.name; 655 | } 656 | } 657 | 658 | export class SignerV2NotExist extends Error { 659 | constructor() { 660 | super(`Signer V2 does not exist`); 661 | this.name = SignerV2NotExist.name; 662 | } 663 | } 664 | 665 | // deno-lint-ignore no-explicit-any 666 | function deepEqual(a: any, b: any) { 667 | try { 668 | assertEquals(a, b); 669 | return true; 670 | } catch { 671 | return false; 672 | } 673 | } 674 | --------------------------------------------------------------------------------