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:
commit
077236969a
8 changed files with 366 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
172
packages/wallet/src/wallet-connection-manager.test.ts
Normal file
172
packages/wallet/src/wallet-connection-manager.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue