diff --git a/docs/dapp.md b/docs/dapp.md index c7244c0..d656a05 100644 --- a/docs/dapp.md +++ b/docs/dapp.md @@ -133,6 +133,7 @@ const request: SignTransactionRequest = { userPrompt: "Confirm swap", broadcast: true, }, + inputPaths: [["receive", 0], ["defi", 5]], // one per sourceOutput }; try { diff --git a/docs/protocol.md b/docs/protocol.md index 0cad3ac..3e1818f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -229,6 +229,7 @@ interface SignTransactionRequest { action: "sign_transaction_request"; transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces sequence: number; + inputPaths: [PathName, number][]; // [pathName, addressIndex] per sourceOutput time: number; } ``` @@ -241,6 +242,12 @@ to match responses to requests. (for signing), version, locktime, and an optional `userPrompt` string shown to the user in the wallet UI. +`inputPaths` is a parallel array to `transaction.sourceOutputs` (and to `transaction.transaction.inputs`). +Each entry is a `[PathName, number]` tuple identifying the HD derivation path name and address index +that the dapp used to derive the locking script for that input. This allows the wallet to sign each +input without scanning or guessing which key was used. The array must have the same length as +`sourceOutputs`. + ### sign_transaction_response ```typescript diff --git a/package-lock.json b/package-lock.json index 4b255a3..fca0f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2418,9 +2418,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -4100,7 +4100,7 @@ }, "packages/core": { "name": "@wizardconnect/core", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@bch-wc2/interfaces": "^0.0.8", "@bitauth/libauth": "^3.1.0-next.2", @@ -4117,7 +4117,7 @@ }, "packages/dapp": { "name": "@wizardconnect/dapp", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@wizardconnect/core": "*", "eventemitter3": "^5.0.1" @@ -4149,7 +4149,7 @@ }, "packages/wallet": { "name": "@wizardconnect/wallet", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@bitauth/libauth": "^3.1.0-next.2", "@wizardconnect/core": "*", diff --git a/packages/core/src/protocols/hdwalletv1.test.ts b/packages/core/src/protocols/hdwalletv1.test.ts new file mode 100644 index 0000000..210f6b0 --- /dev/null +++ b/packages/core/src/protocols/hdwalletv1.test.ts @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Whiterun LLC, +// 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 { isSignTransactionRequest, RelayMsgAction } from "./hdwalletv1.js"; + +describe("isSignTransactionRequest", () => { + const valid = { + action: RelayMsgAction.SignTransactionRequest, + transaction: { transaction: {}, sourceOutputs: [] }, + sequence: 1, + inputPaths: [ + ["receive", 0], + ["change", 3], + ], + time: 1000, + }; + + it("accepts valid message with inputPaths", () => { + expect(isSignTransactionRequest(valid)).toBe(true); + }); + + it("accepts empty inputPaths", () => { + expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true); + }); + + it("rejects missing inputPaths", () => { + const noInputPaths = { ...valid } as Record; + delete noInputPaths.inputPaths; + expect(isSignTransactionRequest(noInputPaths)).toBe(false); + }); + + it("rejects non-array inputPaths", () => { + expect(isSignTransactionRequest({ ...valid, inputPaths: "bad" })).toBe( + false, + ); + }); + + it("rejects tuple with wrong types", () => { + expect( + isSignTransactionRequest({ ...valid, inputPaths: [[0, "receive"]] }), + ).toBe(false); + }); + + it("rejects tuple with wrong length", () => { + expect( + isSignTransactionRequest({ ...valid, inputPaths: [["receive"]] }), + ).toBe(false); + }); + + it("rejects missing action", () => { + const noAction = { ...valid } as Record; + delete noAction.action; + expect(isSignTransactionRequest(noAction)).toBe(false); + }); + + it("rejects missing transaction", () => { + const noTx = { ...valid } as Record; + delete noTx.transaction; + expect(isSignTransactionRequest(noTx)).toBeFalsy(); + }); + + it("rejects missing sequence", () => { + const noSeq = { ...valid } as Record; + delete noSeq.sequence; + expect(isSignTransactionRequest(noSeq)).toBe(false); + }); +}); diff --git a/packages/core/src/protocols/hdwalletv1.ts b/packages/core/src/protocols/hdwalletv1.ts index b1726c1..ea3699b 100644 --- a/packages/core/src/protocols/hdwalletv1.ts +++ b/packages/core/src/protocols/hdwalletv1.ts @@ -114,6 +114,7 @@ export interface SignTransactionRequest extends ProtocolMessage { action: RelayMsgAction.SignTransactionRequest; transaction: WcSignTransactionRequest; sequence: number; + inputPaths: [PathName, number][]; // [pathName, addressIndex] per sourceOutput } export interface SignTransactionResponse extends ProtocolMessage { @@ -155,7 +156,15 @@ export function isSignTransactionRequest( msg.action === RelayMsgAction.SignTransactionRequest && msg.transaction && typeof msg.transaction === "object" && - typeof msg.sequence === "number" + typeof msg.sequence === "number" && + Array.isArray(msg.inputPaths) && + msg.inputPaths.every( + (p: any) => + Array.isArray(p) && + p.length === 2 && + typeof p[0] === "string" && + typeof p[1] === "number", + ) ); } diff --git a/packages/test-cli/src/dapp.ts b/packages/test-cli/src/dapp.ts index ca1ba40..9239799 100644 --- a/packages/test-cli/src/dapp.ts +++ b/packages/test-cli/src/dapp.ts @@ -86,6 +86,7 @@ async function sendSignRequest( userPrompt: "Test sign request from wiz-test CLI", broadcast: false, }, + inputPaths: [], time: Math.floor(Date.now() / 1000), }; diff --git a/packages/wallet/src/integration/sign-request.test.ts b/packages/wallet/src/integration/sign-request.test.ts new file mode 100644 index 0000000..88b3268 --- /dev/null +++ b/packages/wallet/src/integration/sign-request.test.ts @@ -0,0 +1,161 @@ +// Copyright (C) 2026 Whiterun LLC, +// 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 + +/** + * sign_transaction_request tests — verifies that inputPaths sent by the dapp + * arrive intact on the wallet side via the pendingSignRequest event. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + initiateDappRelay, + RelayMsgAction, + PROTOCOL_NAME, + type RelayUpdatePayload, + type RelayClient, + type DappReadyMessage, + type WalletReadyMessage, + type ProtocolMessage, + type SignTransactionRequest, +} from "@wizardconnect/core"; +import { + WalletConnectionManager, + type PendingSignRequest, +} from "@wizardconnect/wallet"; +import { makeTestAdapter, waitFor } from "./helpers.js"; + +const TEST_RELAY_URL = + process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443"; + +describe("WalletConnectionManager — sign_transaction_request with inputPaths", () => { + let dappCleanup: () => void; + let dappClient: RelayClient | null = null; + + let manager: WalletConnectionManager; + let connectionId: string; + + let walletReadyReceived = false; + + const pendingRequests: PendingSignRequest[] = []; + + beforeAll(async () => { + // ---- Dapp setup ---- + const dappRelay = initiateDappRelay( + (payload: RelayUpdatePayload) => { + if (payload.client && !dappClient) dappClient = payload.client; + }, + { explicitRelayUrls: [TEST_RELAY_URL] }, + ); + + dappCleanup = dappRelay.cleanup; + + dappRelay.events.on("keyexchangecomplete", async () => { + await new Promise((r) => setTimeout(r, 50)); + while (!dappClient!.isKeyExchangeComplete()) { + await new Promise((r) => setTimeout(r, 50)); + } + + dappClient!.on("message", (msg: ProtocolMessage) => { + if (msg.action === RelayMsgAction.WalletReady) { + walletReadyReceived = true; + const wr = msg as WalletReadyMessage; + if (!wr.dapp_discovered) { + const reply: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: true, + time: Math.floor(Date.now() / 1000), + }; + dappClient!.relay(reply).catch(() => {}); + } + } + }); + + const initMsg: DappReadyMessage = { + action: RelayMsgAction.DappReady, + supported_protocols: [PROTOCOL_NAME], + wallet_discovered: false, + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(initMsg); + }); + + // ---- Wallet side ---- + const adapter = makeTestAdapter(); + manager = new WalletConnectionManager(adapter); + + manager.on("pendingSignRequest", (req: PendingSignRequest) => { + pendingRequests.push(req); + }); + + connectionId = manager.connect(dappRelay.uri); + + await waitFor(() => walletReadyReceived, { + timeoutMs: 15000, + what: "wallet_ready received on dapp side", + }); + + // Give time for reactive dapp_ready to be processed + await new Promise((r) => setTimeout(r, 300)); + }, 30000); + + afterAll(() => { + dappCleanup?.(); + manager?.disconnectAll(); + }); + + it("wallet receives inputPaths from dapp sign request", async () => { + const signMsg: SignTransactionRequest = { + action: RelayMsgAction.SignTransactionRequest, + sequence: 1, + transaction: { + transaction: { inputs: [], outputs: [], version: 2, locktime: 0 }, + sourceOutputs: [], + userPrompt: "test", + broadcast: false, + }, + inputPaths: [ + ["receive", 0], + ["defi", 5], + ["change", 2], + ], + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(signMsg); + + await waitFor(() => pendingRequests.length > 0, { + timeoutMs: 5000, + what: "pendingSignRequest event on wallet", + }); + + expect(pendingRequests[0].connectionId).toBe(connectionId); + expect(pendingRequests[0].request.inputPaths).toEqual([ + ["receive", 0], + ["defi", 5], + ["change", 2], + ]); + }, 15000); + + it("wallet receives empty inputPaths for zero-input transaction", async () => { + const signMsg: SignTransactionRequest = { + action: RelayMsgAction.SignTransactionRequest, + sequence: 2, + transaction: { + transaction: { inputs: [], outputs: [], version: 2, locktime: 0 }, + sourceOutputs: [], + broadcast: false, + }, + inputPaths: [], + time: Math.floor(Date.now() / 1000), + }; + await dappClient!.relay(signMsg); + + await waitFor(() => pendingRequests.length >= 2, { + timeoutMs: 5000, + what: "second pendingSignRequest event on wallet", + }); + + expect(pendingRequests[1].request.inputPaths).toEqual([]); + }, 15000); +});