Merge branch 'resend-on-ready' into 'master'

Re-send signature request on ready

See merge request riftenlabs/lib/wizardconnect!18
This commit is contained in:
Dagur Valberg Johannsson 2026-04-03 11:38:12 +00:00
commit 077236969a
8 changed files with 366 additions and 9 deletions

View file

@ -218,6 +218,12 @@ it calls `onConnected()` which waits for key exchange and then sends a fresh `da
`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager),
so the correct `wallet_discovered` value is sent on each reconnect.
When `wallet_ready` is received and there are pending (unresponded) sign requests, the manager
automatically re-sends them. This handles the case where the user triggers a signature in the
dapp before the wallet app is open — the relay's time filter would otherwise discard the
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
valid across reconnects.
## Using initiateDappRelay without DappConnectionManager
If you need lower-level control (e.g., in the test-cli), you can work directly with the

View file

@ -306,6 +306,18 @@ interface SignTransactionResponse {
The wallet either returns the signed transaction hex or an error string. The dapp rejects the
pending Promise associated with the `sequence` in the error case.
### Re-delivery on reconnect
If the wallet is not connected (or reconnects) while a `sign_transaction_request` is in flight,
the dapp automatically re-sends all pending requests when it receives `wallet_ready`. This
handles the common case where the user triggers a transaction in the dapp and then opens the
wallet app several seconds later.
The wallet deduplicates incoming requests by `sequence` number — if it has already emitted
`pendingSignRequest` for a given sequence and has not yet responded, the duplicate is silently
dropped. The dedup guard is cleared when the wallet sends a response (`sign_transaction_response`)
or receives a `sign_cancel`.
### sign_cancel
```typescript

View file

@ -172,6 +172,12 @@ application is responsible for:
The wallet library does not auto-sign or auto-reject anything.
**Deduplication:** The dapp re-sends pending sign requests when the wallet reconnects (see
[protocol docs](protocol.md#re-delivery-on-reconnect)). To prevent duplicate approval dialogs,
`WalletConnectionManager` tracks in-flight sequences and silently drops requests whose `sequence`
has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` /
`sendSignError`) or a `sign_cancel` is received.
**SIGHASH enforcement:** The wallet **MUST** sign every input with
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol

12
package-lock.json generated
View file

@ -1633,9 +1633,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2239,9 +2239,9 @@
}
},
"node_modules/happy-dom": {
"version": "20.8.4",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
"version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -2,9 +2,15 @@
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect } from "vitest";
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 type {
WalletReadyMessage,
SignTransactionRequest,
ProtocolMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
// Build a valid xpub string from a deterministic test node.
@ -105,4 +111,127 @@ describe("DappConnectionManager", () => {
expect(mgr.hasPath(1)).toBe(false); // change = 1
});
});
describe("re-sends pending sign requests on wallet_ready", () => {
function makeTestXpubForSession(): string {
const node = {
chainCode: new Uint8Array(32).fill(0xbb),
publicKey: Uint8Array.from(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.match(/.{2}/g)!
.map((h) => parseInt(h, 16)),
),
childIndex: 0,
depth: 3,
parentFingerprint: new Uint8Array(4),
};
const result = encodeHdPublicKey({ node, network: "mainnet" });
if (typeof result === "string") throw new Error(result);
return result.hdPublicKey;
}
/** Minimal mock of RelayClient — just enough to drive DappConnectionManager. */
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);
}),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReady(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("re-relays pending sign requests when wallet_ready arrives", async () => {
const mgr = new DappConnectionManager();
const client = makeMockClient();
const xpub = makeTestXpubForSession();
// Attach manager to mock client
mgr.updateConnection(client as any, { status: "connected" });
// Send a sign request (wallet is "connected" but hasn't sent wallet_ready yet)
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
const signPromise = mgr.sendSignRequest(request);
// The initial relay call
expect(client.relayed).toHaveLength(2); // dapp_ready + sign request
// Now simulate wallet_ready arriving (wallet just opened)
client.relayed.length = 0;
client.emit("message", makeWalletReady(xpub));
// Wait a tick for the async re-send
await new Promise((r) => setTimeout(r, 10));
// The sign request should have been re-sent
const resent = client.relayed.filter(
(m) => m.action === RelayMsgAction.SignTransactionRequest,
);
expect(resent).toHaveLength(1);
expect(resent[0].sequence).toBe(request.sequence);
// Clean up: resolve the pending promise so it doesn't leak
client.emit("message", {
action: RelayMsgAction.SignTransactionResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
transaction: "signed",
});
await signPromise;
});
it("does not re-send if there are no pending requests", async () => {
const mgr = new DappConnectionManager();
const client = makeMockClient();
const xpub = makeTestXpubForSession();
mgr.updateConnection(client as any, { status: "connected" });
client.relayed.length = 0;
client.emit("message", makeWalletReady(xpub));
await new Promise((r) => setTimeout(r, 10));
// Only dapp_ready should have been sent (reactive), no sign requests
const signMsgs = client.relayed.filter(
(m) => m.action === RelayMsgAction.SignTransactionRequest,
);
expect(signMsgs).toHaveLength(0);
});
});
});

View file

@ -65,6 +65,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private pendingSignatureRequests = new Map<
number,
{
request: SignTransactionRequest;
resolve: (r: SignTransactionResponse) => void;
reject: (e: Error) => void;
}
@ -129,7 +130,11 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
return new Promise<SignTransactionResponse>((resolve, reject) => {
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
this.pendingSignatureRequests.set(request.sequence, {
request,
resolve,
reject,
});
this.conn!.relay(request)
.then(() => {
@ -346,6 +351,19 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
this.emit("walletready", msg);
// 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,
);
});
}
}
}
private handleSignTransactionResponse(

View file

@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "eventemitter3";
import {
RelayMsgAction,
RelayStatus,
type SignTransactionRequest,
type RelayStatusCallback,
type RelayUpdatePayload,
} from "@wizardconnect/core";
import type { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
// --- Mock initiateWalletRelay so we can drive the connection from tests ------
let capturedCallback: RelayStatusCallback | null = null;
/** Minimal RelayClient mock — EventEmitter + relay spy. */
function makeMockClient() {
const emitter = new EventEmitter();
return {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
relay: vi.fn(async () => {}),
isKeyExchangeComplete: () => true,
setPairedPublicKey: vi.fn(),
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
type MockClient = ReturnType<typeof makeMockClient>;
let mockClient: MockClient;
vi.mock("@wizardconnect/core", async () => {
const actual = await vi.importActual<typeof import("@wizardconnect/core")>(
"@wizardconnect/core",
);
return {
...actual,
initiateWalletRelay: (cb: RelayStatusCallback) => {
capturedCallback = cb;
return {
client: mockClient,
dappPublicKey: new Uint8Array(32),
walletPublicKey: new Uint8Array(33).fill(0x02),
secret: "aa".repeat(16),
cleanup: vi.fn(),
};
},
};
});
// Import after mock so the module picks up the mock
const { WalletConnectionManager } =
await import("./wallet-connection-manager.js");
// --- Helpers -----------------------------------------------------------------
function makeAdapter(): WalletAdapter {
return {
walletName: "Test Wallet",
walletIcon: "",
getRelayPrivateKey: () => new Uint8Array(32).fill(0x01),
getPublicKey: () => new Uint8Array(33).fill(0x02),
getXpub: (_path: DerivationPath) =>
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
signTransaction: vi.fn(),
};
}
function makeSignRequest(sequence: number): SignTransactionRequest {
return {
action: RelayMsgAction.SignTransactionRequest,
sequence,
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
}
/** Simulate connection: push status callback + emit messages via mock client. */
function simulateConnection(): MockClient {
if (!capturedCallback) throw new Error("connect() not called yet");
const payload: RelayUpdatePayload = {
client: mockClient as any,
status: RelayStatus.connected(),
};
capturedCallback(payload);
return mockClient;
}
// --- Tests -------------------------------------------------------------------
describe("WalletConnectionManager — sign request dedup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("does not emit duplicate pendingSignRequest for the same sequence", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const request = makeSignRequest(42);
// First delivery — wallet sees the sign request
client.emit("message", request);
// Second delivery — dapp re-sent after reconnect
client.emit("message", request);
await new Promise((r) => setTimeout(r, 10));
expect(emitted).toEqual([42]);
});
it("allows the same sequence after response clears it", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
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)
client.emit("message", request);
expect(emitted).toEqual([42, 42]);
});
it("clears dedup guard on sign cancel", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const request = makeSignRequest(42);
client.emit("message", request);
expect(emitted).toEqual([42]);
// Dapp cancels
client.emit("message", {
action: RelayMsgAction.SignCancel,
sequence: 42,
time: Math.floor(Date.now() / 1000),
});
// Re-sent after cancel — should be accepted
client.emit("message", request);
expect(emitted).toEqual([42, 42]);
});
});

View file

@ -6,6 +6,7 @@ import { EventEmitter } from "eventemitter3";
import {
RelayClient,
RelayStatus,
ProtocolMessage,
RelayUpdatePayload,
RelayStatusCallback,
initiateWalletRelay,
@ -19,7 +20,6 @@ import {
DisconnectReason,
PathXpub,
Hdwalletv1Session,
ProtocolMessage,
PROTOCOL_NAME,
binToHex,
} from "@wizardconnect/core";
@ -86,6 +86,7 @@ export type WalletConnectionManagerEvents = {
*/
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
private connections: Map<string, ActiveConnection> = new Map();
private activeSignSequences = new Set<number>();
private adapter: WalletAdapter;
constructor(adapter: WalletAdapter) {
@ -241,6 +242,8 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
throw new Error(`Connection ${connectionId} not found or not connected`);
}
this.activeSignSequences.delete(sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
@ -264,6 +267,8 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
return; // Already disconnected, nothing to do
}
this.activeSignSequences.delete(sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
@ -353,6 +358,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignCancelMessage,
): void {
this.activeSignSequences.delete(msg.sequence);
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
}
@ -426,6 +432,14 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignTransactionRequest,
): void {
// Deduplicate: the dapp re-sends pending requests after reconnect, so the
// wallet may receive the same sequence twice while it's still awaiting
// user approval.
if (this.activeSignSequences.has(msg.sequence)) {
return;
}
this.activeSignSequences.add(msg.sequence);
// Emit to host app for queuing/approval
const pendingRequest: PendingSignRequest = {
connectionId: conn.id,