Merge branch 'redundant-relay' into 'master'
Add additional default relay See merge request riftenlabs/lib/wizardconnect!21
This commit is contained in:
commit
fbac14cd08
18 changed files with 415 additions and 32 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export {
|
|||
DEFAULT_RELAY_HOSTNAME,
|
||||
DEFAULT_RELAY_PORT,
|
||||
DEFAULT_RELAY_PROTOCOL,
|
||||
DEFAULT_RELAY_URLS,
|
||||
} from "./key-exchange.js";
|
||||
export type {
|
||||
KeyExchangeCredentials,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
220
packages/core/src/relay-client.test.ts
Normal file
220
packages/core/src/relay-client.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
117
packages/wallet/src/integration/multi-relay.test.ts
Normal file
117
packages/wallet/src/integration/multi-relay.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue