From ef56fb198c4845a507c5dd5d225848d4c68e9618 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Tue, 14 Apr 2026 08:40:53 +0200 Subject: [PATCH 1/2] Add additional default relay For improved reliability, this adds an additional relay as a redundancy. --- CLAUDE.md | 2 +- README.md | 2 +- docs/connection-uri.md | 2 +- docs/transport.md | 27 +++- packages/core/src/dapp-relay.ts | 8 +- packages/core/src/index.ts | 1 + packages/core/src/key-exchange.test.ts | 26 +++- packages/core/src/key-exchange.ts | 8 +- packages/core/src/wallet-relay.ts | 17 ++- packages/test-cli/src/cli.ts | 4 +- .../wallet/src/integration/disconnect.test.ts | 2 +- packages/wallet/src/integration/helpers.ts | 3 +- .../src/integration/integration.test.ts | 2 +- .../src/integration/multi-relay.test.ts | 117 ++++++++++++++++++ .../src/integration/reconnection.test.ts | 2 +- .../src/integration/sign-cancel.test.ts | 2 +- .../src/integration/sign-request.test.ts | 2 +- 17 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 packages/wallet/src/integration/multi-relay.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 138fbc8..91033cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This codebase communicates over a live relay with timing-sensitive handshakes an - Should cover all edge cases in pure logic (state managers, message builders, URI encoding, etc.) **Integration tests** (`npm run test:integration` in a package): -- Hit the real relay at `wss://relay.cauldron.quest:443` +- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443` - Test the full protocol handshake end-to-end - Located in `src/__tests__/*.integration.test.ts` - Run with generous timeouts (60s per test) via `vitest.integration.config.ts` diff --git a/README.md b/README.md index 90ea010..58d6157 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ const dapp = new DappConnectionManager("My Dapp", "https://example.com/icon.png" const relay = initiateDappRelay( (payload) => dapp.updateConnection(payload.client, payload.status), - { explicitRelayUrls: ["wss://relay.cauldron.quest:443"] }, + { explicitRelayUrls: ["wss://relay.riften.net:443"] }, ); // Display relay.uri as a QR code for the wallet to scan diff --git a/docs/connection-uri.md b/docs/connection-uri.md index 3194f4e..cdf4866 100644 --- a/docs/connection-uri.md +++ b/docs/connection-uri.md @@ -19,7 +19,7 @@ wiz://:?p=&s=&pr= |-----------|----------|------|---------| | `p` | bech32-padded | 32 bytes | Dapp's Nostr public key (x-only secp256k1) | | `s` | bech32-padded | 8 bytes | Shared secret for key exchange verification | -| `hostname` | URL authority | — | Relay hostname (default: `relay.cauldron.quest`) | +| `hostname` | URL authority | — | Relay hostname (default: `relay.riften.net`) | | `port` | URL authority | — | Relay port (default: `443`) | | `pr` | query param | — | `ws` or `wss` (default: `wss`, omitted when default) | diff --git a/docs/transport.md b/docs/transport.md index db7fa73..0e0ab63 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -36,7 +36,7 @@ Application message handler ```typescript new RelayClient({ - explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"] + explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.riften.net:443"] signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity) pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange) logNetworkActivity?: boolean; // default true @@ -199,12 +199,27 @@ the same sender. NDK handles all three layers in `giftWrap()` / `giftUnwrap()`. -## Default relay +## Default relays ``` -wss://relay.cauldron.quest:443 +wss://relay.riften.net:443 (primary) +wss://relay.cauldron.quest:443 (secondary) ``` -This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other -standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always -use the same relay without out-of-band coordination. +Both relays are used by default on both dapp and wallet sides for redundancy. Since Nostr +relays do not federate (they don't forward events to each other), connecting to multiple relays +ensures messages are delivered even if one relay is temporarily unavailable. + +The connection URI encodes only the primary relay; the secondary is added programmatically +by the library. When a custom relay is specified (via URI or `explicitRelayUrls`), only that +relay is used — default relays are not auto-added. + +Nothing in the protocol prevents using any other standard Nostr relay. + +### Duplicate message handling + +When subscribed to multiple relays, the same event may arrive from more than one relay. +nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks seen event IDs +in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since +`pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving +side's pool delivers it exactly once. diff --git a/packages/core/src/dapp-relay.ts b/packages/core/src/dapp-relay.ts index 3408ff8..1f1634f 100644 --- a/packages/core/src/dapp-relay.ts +++ b/packages/core/src/dapp-relay.ts @@ -14,9 +14,7 @@ import { generateKeyExchangeCredentials, encodeKeyExchangeURI, KeyExchangeCredentials, - DEFAULT_RELAY_HOSTNAME, - DEFAULT_RELAY_PORT, - DEFAULT_RELAY_PROTOCOL, + DEFAULT_RELAY_URLS, } from "./key-exchange.js"; import { hexToBin, binToHex } from "@bitauth/libauth"; import { EventEmitter } from "eventemitter3"; @@ -171,9 +169,7 @@ export function initiateDappRelay( const relayUrls = options.explicitRelayUrls && options.explicitRelayUrls.length > 0 ? options.explicitRelayUrls - : [ - `${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`, - ]; + : [...DEFAULT_RELAY_URLS]; const cleanup = initiateRelay( wrappedCallback, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 407532c..0260ced 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export { DEFAULT_RELAY_HOSTNAME, DEFAULT_RELAY_PORT, DEFAULT_RELAY_PROTOCOL, + DEFAULT_RELAY_URLS, } from "./key-exchange.js"; export type { KeyExchangeCredentials, diff --git a/packages/core/src/key-exchange.test.ts b/packages/core/src/key-exchange.test.ts index 2f6d362..020da63 100644 --- a/packages/core/src/key-exchange.test.ts +++ b/packages/core/src/key-exchange.test.ts @@ -7,6 +7,8 @@ import { encodeKeyExchangeURI, decodeKeyExchangeURI, generateKeyExchangeCredentials, + DEFAULT_RELAY_URLS, + DEFAULT_RELAY_HOSTNAME, } from "./key-exchange.js"; describe("key-exchange", () => { @@ -200,7 +202,7 @@ describe("key-exchange", () => { }); it("should throw error for wrong scheme", () => { - const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def"; + const invalidUri = "wrong://relay.riften.net?p=abc&s=def"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid URI scheme", @@ -225,7 +227,7 @@ describe("key-exchange", () => { }); it("should throw error for missing parameters", () => { - const invalidUri = "wiz://relay.cauldron.quest?p=abc"; + const invalidUri = "wiz://relay.riften.net?p=abc"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid URI format", @@ -235,7 +237,7 @@ describe("key-exchange", () => { it("should throw error for invalid bech32 encoding", () => { // Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l) // This will fail at bech32 decoding - const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars"; + const invalidUri = "wiz://relay.riften.net?p=invalid&s=chars"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid bech32 encoding", @@ -250,7 +252,7 @@ describe("key-exchange", () => { "b".repeat(16), ); const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || ""; - const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`; + const invalidUri = `wiz://relay.riften.net?p=${shortBech32}&s=${secretPart}`; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); }); @@ -262,7 +264,7 @@ describe("key-exchange", () => { ); const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || ""; const shortBech32 = "q"; - const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`; + const invalidUri = `wiz://relay.riften.net?p=${publicKeyPart}&s=${shortBech32}`; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); }); @@ -371,4 +373,18 @@ describe("key-exchange", () => { expect(decoded.secret).toBe(secret); }); }); + + describe("DEFAULT_RELAY_URLS", () => { + it("has relay.riften.net as primary (first entry)", () => { + expect(DEFAULT_RELAY_URLS[0]).toBe("wss://relay.riften.net:443"); + }); + + it("contains relay.cauldron.quest as secondary", () => { + expect(DEFAULT_RELAY_URLS).toContain("wss://relay.cauldron.quest:443"); + }); + + it("primary URL matches DEFAULT_RELAY_HOSTNAME", () => { + expect(DEFAULT_RELAY_URLS[0]).toContain(DEFAULT_RELAY_HOSTNAME); + }); + }); }); diff --git a/packages/core/src/key-exchange.ts b/packages/core/src/key-exchange.ts index 630d0f6..a3c5388 100644 --- a/packages/core/src/key-exchange.ts +++ b/packages/core/src/key-exchange.ts @@ -11,10 +11,16 @@ import { } from "@bitauth/libauth"; import { deriveNostrPublicKey } from "./utilnostr.js"; -export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest"; +export const DEFAULT_RELAY_HOSTNAME = "relay.riften.net"; export const DEFAULT_RELAY_PORT = 443; export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss"; +/** All default relay URLs, primary first. */ +export const DEFAULT_RELAY_URLS: readonly string[] = [ + "wss://relay.riften.net:443", + "wss://relay.cauldron.quest:443", +]; + export interface KeyExchangeCredentials { privateKey: string; publicKey: string; diff --git a/packages/core/src/wallet-relay.ts b/packages/core/src/wallet-relay.ts index 2b26664..9762b5b 100644 --- a/packages/core/src/wallet-relay.ts +++ b/packages/core/src/wallet-relay.ts @@ -8,7 +8,7 @@ import { RelayStatusCallback, initiateRelay, } from "./relay-handler.js"; -import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js"; +import { decodeKeyExchangeURI, DEFAULT_RELAY_URLS } from "./key-exchange.js"; import { hexToBin } from "@bitauth/libauth"; import { deriveNostrPublicKeyBytes } from "./utilnostr.js"; @@ -48,7 +48,7 @@ export function initiateWalletRelay( const hostname = decoded.hostname; const protocol = decoded.protocol; const port = decoded.port; - const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`; + const relayUrl = `${protocol}://${hostname}:${port}`; const walletPublicKeyNostr = deriveNostrPublicKeyBytes( options.walletPrivateKey, @@ -73,6 +73,19 @@ export function initiateWalletRelay( }; const relayUrls: string[] = [relayUrl]; + + // Add remaining default relays for redundancy when the URI points to a + // default relay. Skip when using a custom/private relay — the user chose + // that relay deliberately and may not want traffic on public relays. + const isDefaultRelay = DEFAULT_RELAY_URLS.includes(relayUrl); + if (isDefaultRelay) { + for (const defaultUrl of DEFAULT_RELAY_URLS) { + if (!relayUrls.includes(defaultUrl)) { + relayUrls.push(defaultUrl); + } + } + } + if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) { for (const explicitUrl of options.explicitRelayUrls) { if (!relayUrls.includes(explicitUrl)) { diff --git a/packages/test-cli/src/cli.ts b/packages/test-cli/src/cli.ts index a96ce17..399fc03 100644 --- a/packages/test-cli/src/cli.ts +++ b/packages/test-cli/src/cli.ts @@ -22,7 +22,7 @@ program .option( "-r, --relay ", "Nostr relay WebSocket URL", - "wss://relay.cauldron.quest:443", + "wss://relay.riften.net:443", ) .option( "-k, --private-key ", @@ -47,7 +47,7 @@ program .option( "-r, --relay ", "Nostr relay WebSocket URL", - "wss://relay.cauldron.quest:443", + "wss://relay.riften.net:443", ) .requiredOption("-u, --uri ", "wiz:// URI from dapp") .option( diff --git a/packages/wallet/src/integration/disconnect.test.ts b/packages/wallet/src/integration/disconnect.test.ts index ce4f76b..9d0d525 100644 --- a/packages/wallet/src/integration/disconnect.test.ts +++ b/packages/wallet/src/integration/disconnect.test.ts @@ -25,7 +25,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet"; import { makeTestAdapter, waitFor } from "./helpers.js"; const TEST_RELAY_URL = - process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443"; describe("WalletConnectionManager — disconnect", () => { let dappCleanup: () => void; diff --git a/packages/wallet/src/integration/helpers.ts b/packages/wallet/src/integration/helpers.ts index 762dc26..86dd717 100644 --- a/packages/wallet/src/integration/helpers.ts +++ b/packages/wallet/src/integration/helpers.ts @@ -139,8 +139,7 @@ export interface ConnectionHandles { * 3. wallet_ready with paths */ export async function setupConnection( - relayUrl: string = process.env.TEST_RELAY_URL ?? - "wss://relay.cauldron.quest:443", + relayUrl: string = process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443", seed?: Uint8Array, ): Promise { const adapter = makeTestAdapter(seed); diff --git a/packages/wallet/src/integration/integration.test.ts b/packages/wallet/src/integration/integration.test.ts index 0104e45..2547b0e 100644 --- a/packages/wallet/src/integration/integration.test.ts +++ b/packages/wallet/src/integration/integration.test.ts @@ -17,7 +17,7 @@ import { DerivationPath } from "@wizardconnect/wallet"; import type { Hdwalletv1Session } from "@wizardconnect/core"; const TEST_RELAY_URL = - process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443"; const PATHS = [ { childIndex: 0, name: "receive" }, diff --git a/packages/wallet/src/integration/multi-relay.test.ts b/packages/wallet/src/integration/multi-relay.test.ts new file mode 100644 index 0000000..7b990fe --- /dev/null +++ b/packages/wallet/src/integration/multi-relay.test.ts @@ -0,0 +1,117 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Multi-relay tests — verifies that when both dapp and wallet connect to + * both default relays, the handshake completes and messages are not duplicated. + * + * nostr-tools SimplePool deduplicates events by ID across relays in + * subscribeMany, so onevent fires at most once per event. This test exercises + * that path over the wire with real relay connections. + */ + +import { describe, it, expect, afterAll } from "vitest"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + DEFAULT_RELAY_URLS, + type RelayUpdatePayload, + type RelayClient, + type DappReadyMessage, + type WalletReadyMessage, + type ProtocolMessage, +} from "@wizardconnect/core"; +import { WalletConnectionManager } from "@wizardconnect/wallet"; +import { makeTestAdapter, waitFor } from "./helpers.js"; + +const RELAY_URLS = [...DEFAULT_RELAY_URLS]; + +describe("Multi-relay redundancy", () => { + let dappCleanup: (() => void) | null = null; + let walletManager: WalletConnectionManager | null = null; + + afterAll(() => { + dappCleanup?.(); + walletManager?.disconnectAll(); + }); + + it("handshake succeeds with both default relays and wallet_ready arrives once", async () => { + const adapter = makeTestAdapter(); + + // ---- Dapp side: use both relays ---- + + const walletReadyMessages: WalletReadyMessage[] = []; + let dappClient: RelayClient | null = null; + let keyExchanged = false; + + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: RELAY_URLS }, + ); + dappCleanup = dappRelay.cleanup; + + async function sendDappReady(wd: boolean): Promise { + const msg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: wd, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(msg); + } + + dappRelay.events.on("keyexchangecomplete", async () => { + keyExchanged = true; + await new Promise((r) => setTimeout(r, 50)); + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + dappClient!.on("message", (message: ProtocolMessage) => { + if (message.action === RelayMsgAction.WalletReady) { + const msg = message as WalletReadyMessage; + walletReadyMessages.push(msg); + if (!msg.dapp_discovered) { + sendDappReady(true).catch(() => {}); + } + } + }); + + await sendDappReady(false); + }); + + // ---- Wallet side: connect via URI (auto-adds second default relay) ---- + + walletManager = new WalletConnectionManager(adapter); + walletManager.connect(dappRelay.uri); + + // ---- Wait for key exchange ---- + + await waitFor(() => keyExchanged, { + timeoutMs: 20000, + what: "key exchange", + }); + + // ---- Wait for wallet_ready with paths ---- + + await waitFor( + () => + walletReadyMessages.length > 0 && + (walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length > + 0, + { timeoutMs: 20000, what: "wallet_ready with paths" }, + ); + + // ---- Verify single delivery ---- + + // Give extra time for any duplicate to arrive + await new Promise((r) => setTimeout(r, 3000)); + + // wallet_ready should arrive exactly once (not duplicated across relays) + expect(walletReadyMessages.length).toBe(1); + }, 60_000); +}); diff --git a/packages/wallet/src/integration/reconnection.test.ts b/packages/wallet/src/integration/reconnection.test.ts index 0033ae2..048c46f 100644 --- a/packages/wallet/src/integration/reconnection.test.ts +++ b/packages/wallet/src/integration/reconnection.test.ts @@ -24,7 +24,7 @@ import { makeTestAdapter, waitFor } from "./helpers.js"; import { generateRandomBytes } from "@bitauth/libauth"; const TEST_RELAY_URL = - process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443"; describe("WalletConnectionManager — reconnection", () => { // Shared dapp that stays alive across both wallet connections diff --git a/packages/wallet/src/integration/sign-cancel.test.ts b/packages/wallet/src/integration/sign-cancel.test.ts index 7c773c4..a1566f2 100644 --- a/packages/wallet/src/integration/sign-cancel.test.ts +++ b/packages/wallet/src/integration/sign-cancel.test.ts @@ -23,7 +23,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet"; import { makeTestAdapter, waitFor } from "./helpers.js"; const TEST_RELAY_URL = - process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443"; describe("WalletConnectionManager — sign_cancel", () => { let dappCleanup: () => void; diff --git a/packages/wallet/src/integration/sign-request.test.ts b/packages/wallet/src/integration/sign-request.test.ts index c79d400..06c2cd4 100644 --- a/packages/wallet/src/integration/sign-request.test.ts +++ b/packages/wallet/src/integration/sign-request.test.ts @@ -26,7 +26,7 @@ import { import { makeTestAdapter, waitFor } from "./helpers.js"; const TEST_RELAY_URL = - process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443"; describe("WalletConnectionManager — sign_transaction_request with inputPaths", () => { let dappCleanup: () => void; From c8e0a2d7cc872d874aea62eaefd06a5dc8abfd95 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Tue, 14 Apr 2026 08:42:14 +0200 Subject: [PATCH 2/2] Tests for `22601be4` (tcp zombie) --- packages/core/src/relay-client.test.ts | 220 +++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/core/src/relay-client.test.ts diff --git a/packages/core/src/relay-client.test.ts b/packages/core/src/relay-client.test.ts new file mode 100644 index 0000000..df346c7 --- /dev/null +++ b/packages/core/src/relay-client.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, afterEach, type Mock } from "vitest"; +import { generateRandomBytes, secp256k1 } from "@bitauth/libauth"; + +// Mock nostr-tools and isomorphic-ws before importing RelayClient +vi.mock("nostr-tools/nip59", () => ({ + wrapEvent: vi.fn(() => ({ kind: 1059, content: "wrapped" })), + unwrapEvent: vi.fn((_event: any, _key: any) => ({ + kind: 14, + content: '{"action":"dapp_ready","time":9999999999}', + pubkey: "aa".repeat(32), + })), +})); + +vi.mock("nostr-tools/pool", () => ({ + SimplePool: vi.fn(), + useWebSocketImplementation: vi.fn(), +})); + +vi.mock("isomorphic-ws", () => ({ default: vi.fn() })); + +import { RelayClient } from "./relay-client.js"; +import type { SimplePool, SubCloser } from "nostr-tools/pool"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePrivateKey(): Uint8Array { + let key: Uint8Array; + do { + key = generateRandomBytes(32); + } while (typeof secp256k1.derivePublicKeyCompressed(key) === "string"); + return key; +} + +interface MockPoolHandles { + pool: SimplePool; + triggerEose: () => void; + triggerClose: (reasons: string[]) => void; + publishMock: Mock; +} + +function makeMockPool(): MockPoolHandles { + let onEose: (() => void) | null = null; + let onClose: ((reasons: string[]) => void) | null = null; + + const closeFn = vi.fn(); + const publishMock = vi.fn(() => [Promise.resolve("")]); + + const pool = { + subscribeMany: vi.fn( + ( + _urls: string[], + _filter: any, + callbacks: { + onevent: (event: any) => void; + oneose: () => void; + onclose: (reasons: string[]) => void; + }, + ) => { + onEose = callbacks.oneose; + onClose = callbacks.onclose; + return { close: closeFn } as SubCloser; + }, + ), + publish: publishMock, + close: vi.fn(), + } as unknown as SimplePool; + + return { + pool, + triggerEose: () => onEose?.(), + triggerClose: (reasons: string[]) => onClose?.(reasons), + publishMock, + }; +} + +function makeClient(pool: SimplePool) { + const privateKey = makePrivateKey(); + const pairedKey = makePrivateKey(); + return new RelayClient( + { + explicitRelayUrls: ["wss://test.relay:443"], + signerPrivateKey: privateKey, + pairedPublicKey: pairedKey, + logNetworkActivity: false, + }, + pool, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RelayClient — publish failure triggers disconnect", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits disconnect on publish failure", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); // Promise.any wraps in AggregateError + + expect(disconnects).toHaveLength(1); + }); + + it("still throws the error to the caller", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + client.on("disconnect", () => {}); // prevent unhandled + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("relay down"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); + }); + + it("does not double-emit disconnect from publish failure + onclose race", async () => { + const { pool, triggerEose, triggerClose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); + + // Subscription onclose also fires (race condition) + triggerClose(["relay gone"]); + expect(disconnects).toHaveLength(1); + }); + + it("reconnect resets the disconnect guard so future failures emit again", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + // First publish failure + publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 1"))]); + await client + .relay({ action: "dapp_ready" as any, time: 1 }) + .catch(() => {}); + expect(disconnects).toHaveLength(1); + + // Reconnect + await client.connect(); + triggerEose(); + + // Second publish failure — should emit again + publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 2"))]); + await client + .relay({ action: "dapp_ready" as any, time: 2 }) + .catch(() => {}); + expect(disconnects).toHaveLength(2); + }); +}); + +describe("RelayClient — isConnected", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false before connect", () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + expect(client.isConnected()).toBe(false); + }); + + it("returns true after connect", async () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + await client.connect(); + expect(client.isConnected()).toBe(true); + }); + + it("returns false after disconnect", async () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + await client.connect(); + await client.disconnect(); + expect(client.isConnected()).toBe(false); + }); +});