diff --git a/docs/dapp.md b/docs/dapp.md index 3774e5d..e6de351 100644 --- a/docs/dapp.md +++ b/docs/dapp.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index c862594..4d984e3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/docs/wallet.md b/docs/wallet.md index 725b806..e4f9e3e 100644 --- a/docs/wallet.md +++ b/docs/wallet.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 66b974e..8e42532 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/packages/dapp/src/dapp-connection-manager.test.ts b/packages/dapp/src/dapp-connection-manager.test.ts index a09d147..8a9ad4f 100644 --- a/packages/dapp/src/dapp-connection-manager.test.ts +++ b/packages/dapp/src/dapp-connection-manager.test.ts @@ -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 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); + }); + }); }); diff --git a/packages/dapp/src/dapp-connection-manager.ts b/packages/dapp/src/dapp-connection-manager.ts index 4cace07..63ff066 100644 --- a/packages/dapp/src/dapp-connection-manager.ts +++ b/packages/dapp/src/dapp-connection-manager.ts @@ -65,6 +65,7 @@ export class DappConnectionManager extends EventEmitter void; reject: (e: Error) => void; } @@ -129,7 +130,11 @@ export class DappConnectionManager extends EventEmitter((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 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( diff --git a/packages/wallet/src/wallet-connection-manager.test.ts b/packages/wallet/src/wallet-connection-manager.test.ts new file mode 100644 index 0000000..d52154e --- /dev/null +++ b/packages/wallet/src/wallet-connection-manager.test.ts @@ -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; + +let mockClient: MockClient; + +vi.mock("@wizardconnect/core", async () => { + const actual = await vi.importActual( + "@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]); + }); +}); diff --git a/packages/wallet/src/wallet-connection-manager.ts b/packages/wallet/src/wallet-connection-manager.ts index 6c741d2..357cced 100644 --- a/packages/wallet/src/wallet-connection-manager.ts +++ b/packages/wallet/src/wallet-connection-manager.ts @@ -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 { private connections: Map = new Map(); + private activeSignSequences = new Set(); private adapter: WalletAdapter; constructor(adapter: WalletAdapter) { @@ -241,6 +242,8 @@ export class WalletConnectionManager extends EventEmitter