Persistent relay connection with ping/pong keepalive

This commit is contained in:
Jakob Notland 2026-04-28 11:20:08 +02:00
parent 896871c0d8
commit f3df4c3ae3
16 changed files with 798 additions and 50 deletions

View file

@ -60,8 +60,7 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
| File | Update when… |
|------|-------------|
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
| `docs/extensions.md` | hdwalletv1 protocol-level extensions, `WalletAdapter` extension hooks, known protocol extensions, or custom message conventions change |
| `docs/transport.md` (transport extensions section) | Transport-level extensions (`chunk`, future: compression), base-level `extensions` field on `dapp_ready`/`wallet_ready`, reassembly semantics |
| `docs/extensions.md` | Extension system, `WalletAdapter` extension hooks, known extensions, or custom message conventions change |
| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes |
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |

25
package-lock.json generated
View file

@ -32,6 +32,7 @@
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
@ -1199,6 +1200,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@ -1538,6 +1540,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1846,6 +1849,7 @@
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@ -2702,9 +2706,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@ -3092,6 +3096,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3147,6 +3152,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -3370,6 +3376,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -3401,7 +3408,7 @@
},
"packages/core": {
"name": "@wizardconnect/core",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2",
@ -3418,7 +3425,7 @@
},
"packages/dapp": {
"name": "@wizardconnect/dapp",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
@ -3430,7 +3437,7 @@
},
"packages/react": {
"name": "@wizardconnect/react",
"version": "0.2.0",
"version": "0.1.0",
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
@ -3456,6 +3463,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3476,6 +3484,7 @@
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -3502,7 +3511,7 @@
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.2.0",
"version": "0.1.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3522,7 +3531,7 @@
},
"packages/wallet": {
"name": "@wizardconnect/wallet",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/core",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"description": "Transport and protocol primitives for WizardConnect",
"repository": {

View file

@ -101,3 +101,11 @@ export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
typeof (msg as DisconnectMessage).reason === "string"
);
}
export interface PingMessage extends ProtocolMessage {
action: RelayMsgAction.Ping;
}
export interface PongMessage extends ProtocolMessage {
action: RelayMsgAction.Pong;
}

View file

@ -29,6 +29,10 @@ export enum RelayMsgAction {
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
Chunk = "chunk",
/// Keepalive ping sent by the dapp every ~30 s to prevent relay silence timeouts.
Ping = "ping",
/// Keepalive pong sent by the wallet in response to each ping.
Pong = "pong",
}
export interface ProtocolMessage {

View file

@ -42,6 +42,12 @@ export interface RelayClientConfig {
}
export class RelayClient extends EventEmitter {
// Keyed by "walletPubkeyHex:dappPubkeyHex". Persists the high-water mark
// across RelayClient instance teardowns within the same JS session so that
// reconnects after an explicit disconnect()+connect() (e.g. Paytaca's 65s
// keepalive watchdog) still filter relay-replayed messages from the prior session.
private static readonly sessionTimestamps = new Map<string, number>();
private pool: SimplePool;
private sharedPool: boolean;
private pairedPubkeyHex: string;
@ -83,6 +89,12 @@ export class RelayClient extends EventEmitter {
}
>();
private get sessionKey(): string | null {
return this.pairedPubkeyHex
? `${this.myPubkeyHex}:${this.pairedPubkeyHex}`
: null;
}
constructor(config: RelayClientConfig, pool?: SimplePool) {
super();
this.config = {
@ -119,6 +131,13 @@ export class RelayClient extends EventEmitter {
} else {
this.pairedPubkeyHex = "";
}
// Restore persisted high-water mark for this wallet+dapp pair (survives
// instance recreation within the same JS session).
const saved = this.sessionKey
? RelayClient.sessionTimestamps.get(this.sessionKey)
: undefined;
if (saved) this.lastProcessedTimestamp = saved;
}
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
@ -128,6 +147,13 @@ export class RelayClient extends EventEmitter {
? pairedPublicKey.slice(1)
: pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
// Now that we have the full key, restore any persisted timestamp.
const saved = RelayClient.sessionTimestamps.get(this.sessionKey!);
if (saved && saved > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = saved;
}
this.emit("paired");
}
@ -228,6 +254,9 @@ export class RelayClient extends EventEmitter {
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
this.messageQueue.setNotReady();
this.reassembler.stop();
@ -412,6 +441,16 @@ export class RelayClient extends EventEmitter {
return;
}
// Advance the high-water mark so relay replays are filtered on reconnect.
// Also persist to the static session cache so a fresh RelayClient instance
// for the same wallet+dapp pair inherits this mark.
if (payload.time > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = payload.time;
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
}
// wallet_ready carries the key exchange data (public_key + secret) so it
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
const isKeyExchangeMessage = payload.action === RelayMsgAction.WalletReady;

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/dapp",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"description": "Dapp-side integration helpers for WizardConnect",
"repository": {

View file

@ -5,10 +5,15 @@
import { describe, it, expect, vi } from "vitest";
import { encodeHdPublicKey } from "@bitauth/libauth";
import type { PathXpub } from "@wizardconnect/core";
import { RelayMsgAction, PROTOCOL_NAME } from "@wizardconnect/core";
import {
RelayMsgAction,
PROTOCOL_NAME,
DisconnectReason,
} from "@wizardconnect/core";
import type {
WalletReadyMessage,
SignTransactionRequest,
DisconnectMessage,
ProtocolMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
@ -147,8 +152,8 @@ describe("DappConnectionManager", () => {
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
isKeyExchangeComplete: () => true,
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
@ -235,4 +240,417 @@ describe("DappConnectionManager", () => {
expect(signMsgs).toHaveLength(0);
});
});
describe("keepalive reconnect grace timer", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeDisconnectMsg(): DisconnectMessage {
return {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("suppresses disconnect when wallet_ready arrives within the grace window", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
// Wallet keepalive watchdog fires UserDisconnect
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(5_000);
expect(disconnectEvents).toHaveLength(0);
// Wallet reconnects and sends wallet_ready before the window expires
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(26_000); // past where the original 30s timer would have fired
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("fires disconnect after the grace window if wallet does not reconnect", () => {
vi.useFakeTimers();
try {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: Array<{
reason: DisconnectReason;
message: string | undefined;
}> = [];
mgr.on("disconnect", (reason, message) =>
disconnectEvents.push({ reason, message }),
);
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(7_999);
expect(disconnectEvents).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(disconnectEvents).toHaveLength(1);
expect(disconnectEvents[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
} finally {
vi.useRealTimers();
}
});
});
describe("ping interval", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends a Ping after 10s following wallet_ready", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(9_999);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("stops sending pings once the grace timer fires on disconnect", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
client.emit("message", {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
});
// Grace timer fires at 30s and stops the ping interval
vi.advanceTimersByTime(30_000);
expect(disconnectEvents).toHaveLength(1);
// No further pings after the interval was stopped
client.relayed.length = 0;
vi.advanceTimersByTime(30_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("resets the ping interval when wallet reconnects", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
// First wallet_ready — ping interval starts (would fire at t=10s)
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(5_000); // t=5s, no ping yet
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
// Wallet reconnects — second wallet_ready resets the interval
client.emit("message", makeWalletReadyMsg(xpub));
client.relayed.length = 0;
// Old interval would have fired at t=15s (10s from now); new fires at t=25s
vi.advanceTimersByTime(9_000); // t=14s — still no ping
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1_000); // t=15s — new interval fires
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("enters reconnecting state after 75s with no pong or wallet_ready (does not disconnect)", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const reconnectingEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("reconnecting", () => reconnectingEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Still alive just before the threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(0);
// Next interval check pushes past 75s — liveness timeout fires reconnecting, not disconnect
vi.advanceTimersByTime(5_001); // advances to t=80s (next 10s interval)
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("recovers to connected when wallet_ready arrives after liveness timeout", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const walletReadyEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("walletready", () => walletReadyEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Liveness timeout fires at t=80s
vi.advanceTimersByTime(80_001);
// Wallet wakes up and sends wallet_ready — dapp should resume connected
client.emit("message", makeWalletReadyMsg(xpub));
expect(walletReadyEvents).toHaveLength(2); // initial + recovery
expect(disconnectEvents).toHaveLength(0);
// Ping interval restarts after recovery — next ping at t=90s
client.relayed.length = 0;
vi.advanceTimersByTime(10_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("does not disconnect when wallet_ready resets the liveness timer", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Simulate wallet_ready (keepalive reconnect) at t=60s, resetting the timer
vi.advanceTimersByTime(60_000);
client.emit("message", makeWalletReadyMsg(xpub));
// 74s after the reset — still below 75s threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
describe("dapp_ready ordering on reconnect", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: false,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends dapp_ready before re-sending pending sign requests", async () => {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const xpub = makeTestXpub();
mgr.updateConnection(client as any, { status: "connected" });
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
const signPromise = mgr.sendSignRequest(request);
client.relayed.length = 0;
// wallet_ready with dapp_discovered: false — dapp must await dapp_ready
// before re-sending pending sign requests
client.emit("message", makeWalletReadyMsg(xpub));
await new Promise((r) => setTimeout(r, 50));
const actions = client.relayed.map((m) => m.action);
const dappReadyIdx = actions.indexOf(RelayMsgAction.DappReady);
const signReqIdx = actions.indexOf(RelayMsgAction.SignTransactionRequest);
expect(dappReadyIdx).toBeGreaterThan(-1);
expect(signReqIdx).toBeGreaterThan(-1);
expect(dappReadyIdx).toBeLessThan(signReqIdx);
client.emit("message", {
action: RelayMsgAction.SignTransactionResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
signedTransaction: "signed",
});
await signPromise;
});
});
});

View file

@ -16,6 +16,7 @@ import {
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
PingMessage,
ProtocolMessage,
PROTOCOL_NAME,
childIndexOfPathName,
@ -51,6 +52,8 @@ export interface DappConnectionManagerEvents {
messagereceived: [msg: ProtocolMessage];
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
disconnect: [reason: DisconnectReason, message: string | undefined];
/** Fired when the relay connection drops and auto-reconnect begins. */
reconnecting: [];
}
/**
@ -79,6 +82,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
private walletDiscovered = false;
private disconnectGraceTimer: ReturnType<typeof setTimeout> | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
private lastPongTime: number = 0;
private sessionPaths: PathXpub[] = [];
private pendingSignatureRequests = new Map<
number,
@ -200,6 +207,14 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
if (status.status === "connected" && this.conn) {
this.onConnected();
} else if (
status.status === "reconnecting" ||
status.status === "disconnected"
) {
this.stopPingInterval();
if (status.status === "reconnecting") {
this.emit("reconnecting");
}
}
}
@ -386,18 +401,62 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private onConnected(): void {
(async () => {
// Wait until key exchange is complete before sending dapp_ready
const deadline = Date.now() + 30_000;
while (this.conn && !this.conn.isKeyExchangeComplete()) {
if (Date.now() >= deadline) {
console.error("[wizardconnect/dapp] Key exchange timed out");
return;
}
await new Promise((r) => setTimeout(r, 100));
}
if (this.conn) {
await this.pushDappReady();
}
if (!this.conn) return;
await this.pushDappReady();
})().catch((e) =>
console.error("[wizardconnect/dapp] Error in onConnected:", e),
);
}
private startPingInterval(): void {
this.stopPingInterval();
// Treat connection as live at the moment the session is established.
this.lastPongTime = Date.now();
this.pingInterval = setInterval(() => {
if (!this.conn || !this.walletDiscovered) return;
// If the wallet hasn't responded (pong or wallet_ready) within 75s,
// it silently dropped the session — fire disconnect.
// 75s is safely above Paytaca's 65s keepalive interval so wallet_ready
// resets the timer before it expires. Once wallets respond to Ping with
// Pong this threshold can be lowered.
if (Date.now() - this.lastPongTime > 75_000) {
this.stopPingInterval();
this.emit("reconnecting");
return;
}
const ping: PingMessage = {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
};
this.conn.relay(ping).catch(() => {});
}, 10_000);
}
private stopPingInterval(): void {
if (this.pingInterval !== null) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
destroy(): void {
this.stopPingInterval();
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
}
private async pushDappReady(): Promise<void> {
if (!this.conn) return;
@ -425,7 +484,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagereceived", msg);
switch (msg.action) {
case RelayMsgAction.WalletReady:
this.handleWalletReady(msg as WalletReadyMessage);
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
);
break;
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
@ -433,6 +494,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(msg as DisconnectMessage);
break;
case RelayMsgAction.Pong:
this.lastPongTime = Date.now();
break;
case RelayMsgAction.DappReady:
// Not expected on dapp side — silently ignore
break;
@ -442,10 +506,30 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
private handleRemoteDisconnect(msg: DisconnectMessage): void {
this.emit("disconnect", msg.reason, msg.message);
// Give the wallet a short window to reconnect before propagating the
// disconnect. Wallets that use a keepalive watchdog send UserDisconnect
// and immediately reconnect; without this grace period the dapp would
// tear down the session before the wallet_ready arrives.
// 8s covers worst-case relay reconnect (WebSocket + 5s EOSE timeout + latency)
// while being fast enough that a real explicit disconnect is felt promptly.
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
}
this.disconnectGraceTimer = setTimeout(() => {
this.disconnectGraceTimer = null;
this.stopPingInterval();
this.emit("disconnect", msg.reason, msg.message);
}, 8_000);
}
private handleWalletReady(msg: WalletReadyMessage): void {
private async handleWalletReady(msg: WalletReadyMessage): Promise<void> {
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
// Treat wallet_ready as a liveness signal — resets the pong stale timer.
// This covers keepalive reconnects where the wallet reconnects instead of ponging.
this.lastPongTime = Date.now();
this.walletDiscovered = true;
this.walletName = msg.wallet_name;
this.walletIcon = msg.wallet_icon;
@ -463,6 +547,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
time: Math.floor(Date.now() / 1000),
};
this.conn?.relay(disconnectMsg).catch(() => {});
this.stopPingInterval();
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
return;
}
@ -505,14 +590,32 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
}
// Send dapp_ready first so the wallet's session state is confirmed before
// any sign requests arrive — await ensures correct ordering on the wire.
if (!msg.dapp_discovered) {
this.pushDappReady().catch((e) =>
await this.pushDappReady().catch((e) =>
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
);
}
this.emit("walletready", msg);
// Re-send any pending sign requests with a fresh timestamp so the wallet
// doesn't filter them as already-processed (the wallet timestamps messages
// at receive time and ignores anything older than its last disconnect).
if (this.pendingSignatureRequests.size > 0) {
const now = Math.floor(Date.now() / 1000);
for (const [, entry] of this.pendingSignatureRequests) {
const refreshed = { ...entry.request, time: now };
this.conn!.relay(refreshed)
.then(() => this.emit("messagesent", refreshed))
.catch((err) => {
this.pendingSignatureRequests.delete(entry.request.sequence);
entry.reject(err instanceof Error ? err : new Error(String(err)));
});
}
}
// Auto-persist wallet identity and xpub paths to session storage
if (this.sessionOptions) {
const sessionUpdate: Partial<StoredSession> = {
@ -527,18 +630,8 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
);
}
// Re-send any pending sign requests the wallet may have missed
// (e.g. wallet app wasn't open when the request was first sent).
if (this.pendingSignatureRequests.size > 0 && this.conn) {
for (const [, entry] of this.pendingSignatureRequests) {
this.conn.relay(entry.request).catch((err) => {
console.error(
"[wizardconnect/dapp] Failed to re-send pending sign request:",
err,
);
});
}
}
// Start keepalive pings now that the session is fully established.
this.startPingInterval();
}
private handleSignTransactionResponse(

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/react",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"description": "React components and hooks for WizardConnect dapp integration",
"repository": {

View file

@ -72,6 +72,10 @@ export function useWizardConnect(
setState("connected");
});
mgr.on("reconnecting", () => {
setState("reconnecting");
});
mgr.on("disconnect", () => {
setState("disconnected");
setWalletName(null);
@ -159,6 +163,7 @@ export function useWizardConnect(
// Cleanup on unmount
useEffect(() => {
return () => {
managerRef.current?.destroy();
relayRef.current?.cleanup();
};
}, []);

View file

@ -81,6 +81,7 @@ export type WizardConnectState =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export interface UseWizardConnectOptions {

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/test-cli",
"version": "0.2.0",
"version": "0.2.1",
"description": "CLI for testing WizardConnect protocol",
"type": "module",
"private": true,

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/wallet",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"description": "Wallet-side integration helpers for WizardConnect",
"repository": {

View file

@ -99,6 +99,7 @@ describe("WalletConnectionManager — sign request dedup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
@ -124,7 +125,7 @@ describe("WalletConnectionManager — sign request dedup", () => {
expect(emitted).toEqual([42]);
});
it("allows the same sequence after response clears it", async () => {
it("keeps dedup guard active after sign response to block relay re-delivery", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
@ -134,19 +135,18 @@ describe("WalletConnectionManager — sign request dedup", () => {
const request = makeSignRequest(42);
// First delivery
client.emit("message", request);
expect(emitted).toEqual([42]);
// Wallet responds — clears the dedup guard
await mgr.sendSignResponse(connId, 42, "signed_hex");
// Same sequence arrives again (hypothetical re-send)
// Nostr relay re-delivers the stored sign request after reconnect —
// must NOT prompt the user a second time
client.emit("message", request);
expect(emitted).toEqual([42, 42]);
expect(emitted).toEqual([42]);
});
it("clears dedup guard on sign cancel", async () => {
it("keeps dedup guard active after sign cancel to block relay re-delivery", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
@ -165,8 +165,103 @@ describe("WalletConnectionManager — sign request dedup", () => {
time: Math.floor(Date.now() / 1000),
});
// Re-sent after cancel — should be accepted
// Relay re-delivers the old sign request — must still be filtered
client.emit("message", request);
expect(emitted).toEqual([42, 42]);
expect(emitted).toEqual([42]);
});
});
describe("WalletConnectionManager — ping → pong", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("responds to a Ping message with a Pong", () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
client.relay.mockClear();
client.emit("message", {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
});
const pongCalls = client.relay.mock.calls.filter(
([msg]) => msg.action === RelayMsgAction.Pong,
);
expect(pongCalls).toHaveLength(1);
});
});
describe("WalletConnectionManager — per-connection signSequence cleanup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("blocks relay-replayed COMPLETED sequence on reconnect to same URI", async () => {
// A sequence that was fully signed must be blocked on relay replay even
// after an explicit disconnect()+connect() (e.g. Paytaca 65s watchdog).
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Complete the request — this is what persists seq 42 to the URI cache
await mgr.sendSignResponse(connId1, 42, "signed_hex");
mgr.disconnect(connId1);
// Relay replays seq=42 on the new connection — must be blocked
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]); // still just [42]
// A genuinely new sequence from the dapp is accepted
secondClient.emit("message", makeSignRequest(44));
expect(emitted).toEqual([42, 44]);
});
it("allows dapp to resend a PENDING sequence after watchdog disconnect", () => {
// If the Paytaca watchdog fires while the user is mid-signing, the dapp
// must be able to resend the request on the new connection. Pending sequences
// must NOT be stored in the URI cache until a response is sent.
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Watchdog fires BEFORE user signs — seq 42 is still pending, no response sent
mgr.disconnect(connId1);
// Dapp reconnects and resends the same pending request — must be accepted
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42, 42]); // resend accepted, user prompted again
});
});

View file

@ -18,6 +18,8 @@ import {
WalletReadyMessage,
DisconnectMessage,
DisconnectReason,
PingMessage,
PongMessage,
PathXpub,
Hdwalletv1Session,
PROTOCOL_NAME,
@ -57,6 +59,8 @@ interface ActiveConnection {
/// Prevents duplicate wallet_ready messages within a single connection cycle.
/// Reset to false on each new connect/reconnect; set to true after sending.
walletReadySentThisCycle: boolean;
/// Sign request sequences received on this connection, for cleanup on disconnect.
signSequences: Set<number>;
notificationQueue: ProtocolMessage[];
notificationProcessor: ReturnType<typeof setInterval> | null;
/// Key exchange data embedded in wallet_ready
@ -89,6 +93,11 @@ export type WalletConnectionManagerEvents = {
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
private connections: Map<string, ActiveConnection> = new Map();
private activeSignSequences = new Set<number>();
// Persists sign request sequences seen per URI across doDisconnect()+connect()
// within the same JS session. The relay replays stored sign requests after
// explicit reconnect; without this, clearing activeSignSequences in doDisconnect
// would let replayed sequences bypass the dedup guard.
private static readonly uriSignSequences = new Map<string, Set<number>>();
private adapter: WalletAdapter;
constructor(adapter: WalletAdapter) {
@ -122,6 +131,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
connectedAt: Date.now(),
dappDiscovered: false,
walletReadySentThisCycle: false,
signSequences: new Set(),
notificationQueue: [],
notificationProcessor: null,
walletPublicKeyHex: "",
@ -130,6 +140,16 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
this.connections.set(id, conn);
// Restore any sign sequences seen on this URI during this JS session so
// relay-replayed requests are blocked even after a full disconnect+reconnect.
const savedSeqs = WalletConnectionManager.uriSignSequences.get(uri);
if (savedSeqs) {
for (const seq of savedSeqs) {
this.activeSignSequences.add(seq);
conn.signSequences.add(seq);
}
}
const statusCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
@ -206,6 +226,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn.client.relay(disconnectMsg).catch(() => {});
}
for (const seq of conn.signSequences) {
this.activeSignSequences.delete(seq);
}
clearInterval(conn.notificationProcessor ?? undefined);
conn.cleanup();
this.connections.delete(connectionId);
@ -244,7 +267,17 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
throw new Error(`Connection ${connectionId} not found or not connected`);
}
this.activeSignSequences.delete(sequence);
// Intentionally keep sequence in activeSignSequences and conn.signSequences.
// Nostr relays replay stored events on reconnect; removing the guard here
// would let a re-delivered sign_transaction_request pass dedup and prompt
// the user a second time for an already-completed request.
// doDisconnect() is the sole cleanup point for these sets.
//
// Now that the request is complete, persist to the URI cache so a fresh
// connection created after a watchdog disconnect also blocks relay replays.
// Pending sequences are NOT stored until completion so the dapp can resend
// them if the watchdog fires while the user is still signing.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
@ -269,7 +302,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
return; // Already disconnected, nothing to do
}
this.activeSignSequences.delete(sequence);
// Same reasoning as sendSignResponse: keep in dedup guard until doDisconnect.
// Persist completed sequence to URI cache so relay replays are blocked on reconnect.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
@ -282,6 +317,27 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
await conn.client.relay(response);
}
// --- Private helpers ---
/**
* Mark a sign sequence as completed in the URI-scoped cache.
* Called after a response is sent or the dapp cancels the request so that
* relay replays of the original sign_transaction_request are blocked on the
* next reconnect. Pending sequences are intentionally NOT stored here the
* dapp must be able to resend them if the Paytaca watchdog fires mid-signing.
*/
private persistCompletedSequence(
conn: ActiveConnection,
sequence: number,
): void {
let seqSet = WalletConnectionManager.uriSignSequences.get(conn.uri);
if (!seqSet) {
seqSet = new Set();
WalletConnectionManager.uriSignSequences.set(conn.uri, seqSet);
}
seqSet.add(sequence);
}
// --- Private connection lifecycle ---
/** Immediately attempt to flush the notification queue (fire-and-forget). */
@ -310,7 +366,12 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
// Wait for key exchange, then send wallet_ready
(async () => {
const deadline = Date.now() + 30_000;
while (conn.client && !conn.client.isKeyExchangeComplete()) {
if (Date.now() >= deadline) {
console.error("[wizardconnect/wallet] Key exchange timed out");
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (conn.client) {
@ -346,6 +407,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
case RelayMsgAction.SignCancel:
this.handleSignCancel(conn, message as SignCancelMessage);
break;
case RelayMsgAction.Ping:
this.handlePing(conn, message as PingMessage);
break;
default:
this.emit("message", conn.id, message);
}
@ -363,10 +427,22 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignCancelMessage,
): void {
this.activeSignSequences.delete(msg.sequence);
// Keep in dedup guard: the relay may still re-deliver the original
// sign_transaction_request after the cancel. doDisconnect() cleans up.
// Persist to URI cache so the cancelled sequence is blocked on reconnect too.
this.persistCompletedSequence(conn, msg.sequence);
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
}
private handlePing(conn: ActiveConnection, _msg: PingMessage): void {
if (!conn.client) return;
const pong: PongMessage = {
action: RelayMsgAction.Pong,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(pong).catch(() => {});
}
private async handleDappReady(
conn: ActiveConnection,
msg: DappReadyMessage,
@ -418,10 +494,10 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
...(this.adapter.getAdditionalPaths?.() ?? []),
];
const extensions = this.adapter.getExtensions?.();
const adapterExtensions = this.adapter.getExtensions?.();
const hdwv1Session: Hdwalletv1Session = {
paths,
...(extensions ? { extensions } : {}),
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
};
const msg: WalletReadyMessage = {
@ -456,6 +532,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
return;
}
this.activeSignSequences.add(msg.sequence);
conn.signSequences.add(msg.sequence);
// Emit to host app for queuing/approval
const pendingRequest: PendingSignRequest = {