Merge branch 'cauldron-191-cauldron-and-wizard-connect-thinks-i-am-connected-but-im-not' into 'master'
Draft: Active reconnect. See merge request riftenlabs/lib/wizardconnect!25
This commit is contained in:
commit
9e5a5f610a
11 changed files with 790 additions and 44 deletions
|
|
@ -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
25
package-lock.json
generated
|
|
@ -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": "*",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 10 s to prevent relay silence timeouts.
|
||||
Ping = "ping",
|
||||
/// Keepalive pong sent by the wallet in response to each ping.
|
||||
Pong = "pong",
|
||||
}
|
||||
|
||||
export interface ProtocolMessage {
|
||||
|
|
|
|||
|
|
@ -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() 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,17 +402,59 @@ 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 — trigger reconnect.
|
||||
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 +482,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 +492,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 +504,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 +545,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 +588,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 +628,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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export type WizardConnectState =
|
|||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
|
||||
export interface UseWizardConnectOptions {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 a keepalive reconnect 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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue