Add additional default relay

For improved reliability, this adds an additional relay as a redundancy.
This commit is contained in:
Dagur Valberg Johannsson 2026-04-14 08:40:53 +02:00
parent f9da868513
commit ef56fb198c
No known key found for this signature in database
GPG key ID: FD701804AEE88107
17 changed files with 195 additions and 32 deletions

View file

@ -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`

View file

@ -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

View file

@ -19,7 +19,7 @@ wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
|-----------|----------|------|---------|
| `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) |

View file

@ -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.

View file

@ -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,

View file

@ -21,6 +21,7 @@ export {
DEFAULT_RELAY_HOSTNAME,
DEFAULT_RELAY_PORT,
DEFAULT_RELAY_PROTOCOL,
DEFAULT_RELAY_URLS,
} from "./key-exchange.js";
export type {
KeyExchangeCredentials,

View file

@ -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);
});
});
});

View file

@ -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;

View file

@ -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)) {

View file

@ -22,7 +22,7 @@ program
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.cauldron.quest:443",
"wss://relay.riften.net:443",
)
.option(
"-k, --private-key <hex>",
@ -47,7 +47,7 @@ program
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.cauldron.quest:443",
"wss://relay.riften.net:443",
)
.requiredOption("-u, --uri <uri>", "wiz:// URI from dapp")
.option(

View file

@ -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;

View file

@ -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<ConnectionHandles> {
const adapter = makeTestAdapter(seed);

View file

@ -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" },

View file

@ -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<void> {
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);
});

View file

@ -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

View file

@ -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;

View file

@ -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;