From 566663c6cbec69706a4de132d0c89eab6b7e76d0 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 00:30:50 -0300 Subject: [PATCH 01/17] fix(osmosis): forward supported token denominations --- packages/hdwallet-keepkey/src/osmosis.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/hdwallet-keepkey/src/osmosis.ts b/packages/hdwallet-keepkey/src/osmosis.ts index de627cae..1b74f97a 100644 --- a/packages/hdwallet-keepkey/src/osmosis.ts +++ b/packages/hdwallet-keepkey/src/osmosis.ts @@ -79,11 +79,6 @@ export async function osmosisSignTx(transport: Transport, msg: core.OsmosisSignT throw new Error("osmosis: Multiple amounts per msg not supported"); } - const denom = m.value.amount[0].denom; - if (denom !== "uosmo") { - throw new Error("osmosis: Unsupported denomination: " + denom); - } - const send = new OsmosisMessages.OsmosisMsgSend(); send.setFromAddress(m.value.from_address); send.setToAddress(m.value.to_address); @@ -96,11 +91,6 @@ export async function osmosisSignTx(transport: Transport, msg: core.OsmosisSignT } case "cosmos-sdk/MsgDelegate": { // Delegate - const denom = m.value.amount.denom; - if (denom !== "uosmo") { - throw new Error("osmosis: Unsupported denomination: " + denom); - } - const delegate = new OsmosisMessages.OsmosisMsgDelegate(); delegate.setDelegatorAddress(m.value.delegator_address); delegate.setValidatorAddress(m.value.validator_address); @@ -113,11 +103,6 @@ export async function osmosisSignTx(transport: Transport, msg: core.OsmosisSignT } case "cosmos-sdk/MsgUndelegate": { // Undelegate - const denom = m.value.amount.denom; - if (denom !== "uosmo") { - throw new Error("osmosis: Unsupported denomination: " + denom); - } - const undelegate = new OsmosisMessages.OsmosisMsgUndelegate(); undelegate.setDelegatorAddress(m.value.delegator_address); undelegate.setValidatorAddress(m.value.validator_address); @@ -130,11 +115,6 @@ export async function osmosisSignTx(transport: Transport, msg: core.OsmosisSignT } case "cosmos-sdk/MsgBeginRedelegate": { // Redelegate - const denom = m.value.amount.denom; - if (denom !== "uosmo") { - throw new Error("osmosis: Unsupported denomination: " + denom); - } - const redelegate = new OsmosisMessages.OsmosisMsgRedelegate(); redelegate.setDelegatorAddress(m.value.delegator_address); redelegate.setValidatorSrcAddress(m.value.validator_src_address); From d221492765cf85ea1fa6cac0cf3ee68614f8c120 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Jul 2026 17:18:42 -0300 Subject: [PATCH 02/17] feat(solana): carry KKSOLSC1 instruction schemas on SolanaSignTx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema describes how to read one program instruction — it names no amounts and no transaction hash, so one signature covers every future call to that program and the device decodes values from the bytes it signs. The published @keepkey/device-protocol build predates SolanaSignTx fields 5-7, so setSchemaPayload() and friends do not exist. Rather than block on a protocol release, the fields are appended at the wire level: encoded protobuf fields are order-independent and simply concatenate, so firmware's nanopb decoder reads them by field number exactly as if the generator had emitted them. transport.call() only needs serializeBinary(), so a Proxy wrapper adds the bytes without mutating the original message. Remove the shim once a device-protocol release ships the generated setters. Verified on hardware: signer loaded, a Relay deposit signed through decoded screens, the SAME schema signature reused for a second different transaction, and an under-covering schema refused rather than blind-signed. --- packages/hdwallet-core/src/solana.ts | 19 +++ packages/hdwallet-keepkey/src/solana.ts | 183 +++++++++++++++++++++++- 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index 70fec107..b82f51b2 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -12,6 +12,25 @@ export interface SolanaAddress { export interface SolanaSignTx { addressNList: BIP32Path; rawTx: Uint8Array | string; + /** One-request opaque-signing authorization; does not mutate AdvancedMode. */ + allowBlindSigning?: boolean; + /** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */ + swapMetadata?: { + payload: Uint8Array | string; + signature: Uint8Array | string; + signerKeyId: number; + }; + /** + * Signer-attested KKSOLSC1 instruction schema. Unlike swapMetadata this is + * NOT bound to one transaction: it describes how to read a program's + * instruction, so a single signature is reused for every transaction to + * that program and the device decodes values from the bytes it signs. + */ + schema?: { + payload: Uint8Array | string; + signature: Uint8Array | string; + signerKeyId: number; + }; } export interface SolanaSignedTx { diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index a65e1015..2d4f8e0b 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -9,6 +9,75 @@ import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; // Cast to access methods that exist at runtime but not in @types/google-protobuf const Msg = jspb.Message as any; +// ── Minimal protobuf wire encoding ──────────────────────────────────── +// Used to carry fields the installed @keepkey/device-protocol build predates. +// Encoded fields concatenate in any order, so appending well-formed bytes to a +// serialized message is valid protobuf, not a hack around it. + +function toBytes(value: Uint8Array | string): Uint8Array { + if (value instanceof Uint8Array) return value; + return /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0 + ? core.fromHexString(value) + : Uint8Array.from(Buffer.from(value, "base64")); +} + +function encodeVarint(value: number): number[] { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`varint must be a non-negative integer, got ${value}`); + } + const out: number[] = []; + let v = value; + do { + let byte = v & 0x7f; + v >>>= 7; + if (v > 0) byte |= 0x80; + out.push(byte); + } while (v > 0); + return out; +} + +/** field << 3 | 0 (varint) */ +function encodeVarintField(fieldNumber: number, value: number): Uint8Array { + return Uint8Array.from([...encodeVarint((fieldNumber << 3) | 0), ...encodeVarint(value)]); +} + +/** field << 3 | 2 (length-delimited) */ +function encodeLengthDelimited(fieldNumber: number, bytes: Uint8Array): Uint8Array { + return Uint8Array.from([ + ...encodeVarint((fieldNumber << 3) | 2), + ...encodeVarint(bytes.length), + ...bytes, + ]); +} + +function concatBytes(...chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} + +/** + * Wrap a jspb message so serializeBinary() yields the original encoding plus + * `extra`. Transport.call() only ever calls serializeBinary(), so a duck-typed + * wrapper is sufficient and leaves the original message untouched. + */ +function withAppendedFields(msg: jspb.Message, extra: Uint8Array): jspb.Message { + return new Proxy(msg, { + get(target, prop, receiver) { + if (prop === "serializeBinary") { + return () => concatBytes((target as any).serializeBinary(), extra); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as jspb.Message; +} + // ── Solana Message Type IDs (from messages.proto) ───────────────────── const MESSAGETYPE_SOLANAGETADDRESS = 750; const MESSAGETYPE_SOLANAADDRESS = 751; @@ -198,7 +267,9 @@ export namespace SolanaAddress { } /** - * SolanaSignTx: address_n(1, repeated uint32), coin_name(2, string), raw_tx(3, bytes) + * SolanaSignTx: address_n(1), coin_name(2), raw_tx(3), token_info(4), + * swap_metadata_payload(5), swap_metadata_signature(6), + * swap_metadata_signer_key_id(7), allow_opaque(8) */ export class SolanaSignTx extends jspb.Message { static repeatedFields_ = [1]; @@ -242,6 +313,42 @@ export class SolanaSignTx extends jspb.Message { jspb.Message.setField(this, 3, value); } + getSwapMetadataPayload(): Uint8Array | string { + return jspb.Message.getFieldWithDefault(this, 5, "") as Uint8Array | string; + } + getSwapMetadataPayload_asU8(): Uint8Array { + const val = this.getSwapMetadataPayload(); + return val instanceof Uint8Array ? val : jspb.Message.bytesAsU8(val as string); + } + setSwapMetadataPayload(value: Uint8Array | string): void { + jspb.Message.setField(this, 5, value); + } + + getSwapMetadataSignature(): Uint8Array | string { + return jspb.Message.getFieldWithDefault(this, 6, "") as Uint8Array | string; + } + getSwapMetadataSignature_asU8(): Uint8Array { + const val = this.getSwapMetadataSignature(); + return val instanceof Uint8Array ? val : jspb.Message.bytesAsU8(val as string); + } + setSwapMetadataSignature(value: Uint8Array | string): void { + jspb.Message.setField(this, 6, value); + } + + getSwapMetadataSignerKeyId(): number { + return jspb.Message.getFieldWithDefault(this, 7, 0) as number; + } + setSwapMetadataSignerKeyId(value: number): void { + jspb.Message.setField(this, 7, value); + } + + getAllowOpaque(): boolean { + return jspb.Message.getFieldWithDefault(this, 8, false) as boolean; + } + setAllowOpaque(value: boolean): void { + jspb.Message.setField(this, 8, value); + } + serializeBinary(): Uint8Array { const writer = new jspb.BinaryWriter(); SolanaSignTx.serializeBinaryToWriter(this, writer); @@ -253,6 +360,10 @@ export class SolanaSignTx extends jspb.Message { addressNList: this.getAddressNList(), coinName: this.getCoinName(), rawTx: this.getRawTx(), + swapMetadataPayload: this.getSwapMetadataPayload(), + swapMetadataSignature: this.getSwapMetadataSignature(), + swapMetadataSignerKeyId: this.getSwapMetadataSignerKeyId(), + allowOpaque: this.getAllowOpaque(), }; } @@ -282,6 +393,18 @@ export class SolanaSignTx extends jspb.Message { case 3: msg.setRawTx(reader.readBytes()); break; + case 5: + msg.setSwapMetadataPayload(reader.readBytes()); + break; + case 6: + msg.setSwapMetadataSignature(reader.readBytes()); + break; + case 7: + msg.setSwapMetadataSignerKeyId(reader.readUint32()); + break; + case 8: + msg.setAllowOpaque(reader.readBool()); + break; default: reader.skipField(); break; @@ -303,6 +426,22 @@ export class SolanaSignTx extends jspb.Message { if (rawTx.length > 0) { writer.writeBytes(3, rawTx); } + const metadataPayload = message.getSwapMetadataPayload_asU8(); + if (metadataPayload.length > 0) { + writer.writeBytes(5, metadataPayload); + } + const metadataSignature = message.getSwapMetadataSignature_asU8(); + if (metadataSignature.length > 0) { + writer.writeBytes(6, metadataSignature); + } + const metadataSignerKeyId = jspb.Message.getField(message, 7) as number | null; + if (metadataSignerKeyId != null) { + writer.writeUint32(7, metadataSignerKeyId); + } + const allowOpaque = jspb.Message.getField(message, 8) as boolean | null; + if (allowOpaque != null) { + writer.writeBool(8, allowOpaque); + } } } @@ -311,6 +450,10 @@ export namespace SolanaSignTx { addressNList: number[]; coinName?: string; rawTx: Uint8Array | string; + swapMetadataPayload?: Uint8Array | string; + swapMetadataSignature?: Uint8Array | string; + swapMetadataSignerKeyId?: number; + allowOpaque?: boolean; }; } @@ -973,8 +1116,44 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) rawBytes = new Uint8Array(msg.rawTx as any); } signTx.setRawTx(rawBytes); + if (msg.swapMetadata) { + const payload = msg.swapMetadata.payload instanceof Uint8Array + ? msg.swapMetadata.payload + : Uint8Array.from(Buffer.from(msg.swapMetadata.payload, "base64")); + const signature = msg.swapMetadata.signature instanceof Uint8Array + ? msg.swapMetadata.signature + : Uint8Array.from(Buffer.from(msg.swapMetadata.signature, "base64")); + signTx.setSwapMetadataPayload(payload); + signTx.setSwapMetadataSignature(signature); + signTx.setSwapMetadataSignerKeyId(msg.swapMetadata.signerKeyId); + } + if (msg.allowBlindSigning === true) { + signTx.setAllowOpaque(true); + } + + /* + * KKSOLSC1 schema fields (SolanaSignTx 5/6/7) are appended at the wire + * level rather than through generated setters: the published + * @keepkey/device-protocol build predates them, so setSchemaPayload() and + * friends do not exist. Protobuf makes this safe and lossless — encoded + * fields are order-independent and simply concatenate, and firmware's + * nanopb decoder reads them by field number exactly as if the generator + * had emitted them. Drop this shim once a device-protocol release carries + * the fields and the setters appear. + */ + let outbound: jspb.Message = signTx; + if (msg.schema) { + const payload = toBytes(msg.schema.payload); + const signature = toBytes(msg.schema.signature); + const extra = concatBytes( + encodeLengthDelimited(5, payload), + encodeLengthDelimited(6, signature), + encodeVarintField(7, msg.schema.signerKeyId) + ); + outbound = withAppendedFields(signTx, extra); + } - const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, signTx, { + const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT, omitLock: true, }); From fb05dda6069ab4ee72e11afd7f19433ca1e10994 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Jul 2026 16:11:33 -0300 Subject: [PATCH 03/17] feat(zcash): forward Ironwood PCZT metadata --- packages/hdwallet-keepkey/package.json | 2 +- packages/hdwallet-keepkey/src/typeRegistry.ts | 2 +- packages/hdwallet-keepkey/src/zcash.test.ts | 45 +++++++++++-------- packages/hdwallet-keepkey/src/zcash.ts | 21 +++++---- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 0cae5d61..79141034 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.16.0", + "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.17.0", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/packages/hdwallet-keepkey/src/typeRegistry.ts b/packages/hdwallet-keepkey/src/typeRegistry.ts index 76b91568..8b923c27 100644 --- a/packages/hdwallet-keepkey/src/typeRegistry.ts +++ b/packages/hdwallet-keepkey/src/typeRegistry.ts @@ -38,7 +38,7 @@ const AllMessages = ([] as Array<[string, core.Constructor]>) .concat(Object.entries(TonMessages)) .concat(Object.entries(TronMessages)) .concat(Object.entries(MayachainMessages)) - .concat(Object.entries(ZcashMessages)); + .concat(Object.entries(omit(ZcashMessages, "ZcashShieldedPool", "ZcashShieldedPoolMap"))); const upperCasedMessageClasses = AllMessages.reduce((registry, entry: [string, core.Constructor]) => { registry[entry[0].toUpperCase()] = entry[1]; diff --git a/packages/hdwallet-keepkey/src/zcash.test.ts b/packages/hdwallet-keepkey/src/zcash.test.ts index ad42d57b..f86a048c 100644 --- a/packages/hdwallet-keepkey/src/zcash.test.ts +++ b/packages/hdwallet-keepkey/src/zcash.test.ts @@ -15,14 +15,16 @@ import * as ZcashMessages from "@keepkey/device-protocol/lib/messages-zcash_pb"; import { zcashSignPczt } from "./zcash"; // Realistic signing request that mirrors what the Rust sidecar returns for a -// shield transaction: 1 transparent input, 1 transparent output, 2 Orchard actions. +// NU6.3 shield transaction: 1 transparent input, 1 transparent output, +// 2 Ironwood actions. const SHIELD_REQUEST = { n_actions: 2, + pool: "ironwood" as const, account: 0, - branch_id: 0x4dec4df0, + branch_id: 0x37a5165b, header_fields: { - tx_version: 5, - version_group_id: 0x26a7270a, + tx_version: 6, + version_group_id: 0xd884b698, lock_time: 0, expiry_height: 0, }, @@ -31,6 +33,7 @@ const SHIELD_REQUEST = { transparent: "f6424c87af931906154bc15c40fa50b9323fc99271e5c1a98c2d9cc214eb9f94", // sapling intentionally absent — firmware rejects it if set orchard: "a8554ee3a53af330a6b6cf56112a203d3d028f2e421cb494a3f590161d27414a", + ironwood: "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59", }, bundle_meta: { flags: 3, @@ -126,11 +129,11 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { const msg = capturedMsg[0]; // Transaction header fields (firmware recomputes header_digest from these) - expect(msg.getTxVersion()).toBe(5); - expect(msg.getVersionGroupId()).toBe(0x26a7270a); + expect(msg.getTxVersion()).toBe(6); + expect(msg.getVersionGroupId()).toBe(0xd884b698); expect(msg.getLockTime()).toBe(0); expect(msg.getExpiryHeight()).toBe(0); - expect(msg.getBranchId()).toBe(0x4dec4df0); + expect(msg.getBranchId()).toBe(0x37a5165b); // Sub-digests expect(Buffer.from(msg.getHeaderDigest_asU8()).toString("hex")).toBe( @@ -143,24 +146,28 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(Buffer.from(msg.getOrchardDigest_asU8()).toString("hex")).toBe( "a8554ee3a53af330a6b6cf56112a203d3d028f2e421cb494a3f590161d27414a" ); + expect(Buffer.from(msg.getIronwoodDigest_asU8()).toString("hex")).toBe( + "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59" + ); + expect(msg.getShieldedPool()).toBe(1); // Transparent counts expect(msg.getNTransparentOutputs()).toBe(1); expect(msg.getNTransparentInputs()).toBe(1); - // Orchard + // Ironwood reuses the Orchard-family bundle metadata fields. expect(msg.getNActions()).toBe(2); expect(msg.getOrchardFlags()).toBe(3); }); - it("follows full output→input→Orchard protocol sequence", async () => { + it("follows full output→input→Ironwood protocol sequence", async () => { const calls: number[] = []; const capturedOutputMsg: ZcashMessages.ZcashTransparentOutput[] = []; const capturedInputMsg: ZcashMessages.ZcashTransparentInput[] = []; const capturedActionMsg: ZcashMessages.ZcashPCZTAction[] = []; // Firmware 7.15+ sends ZcashTransparentSigned + ZcashSignedPCZT back-to-back after - // the last Orchard action. readResponse is called without sending to drain the second. + // the last Ironwood action. readResponse is called without sending to drain the second. const transparentSigned = new ZcashMessages.ZcashTransparentSigned(); transparentSigned.addSignatures(new Uint8Array(71).fill(0x30)); @@ -288,7 +295,7 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(capturedActionMsg[1].getValue()).toBe(0); expect(capturedActionMsg[1].getRecipient_asU8()).toHaveLength(0); - // Orchard sigs returned, transparent sigs attached (from ZcashTransparentSigned) + // Ironwood sigs returned, transparent sigs attached (from ZcashTransparentSigned) expect(result).toHaveLength(2); expect(result._transparentSignatures).toHaveLength(1); }); @@ -402,18 +409,20 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { }); }); -// Deshield (Z→T): 1 transparent output, 0 transparent inputs, 2 Orchard actions. +// Deshield (Z→T): 1 transparent output, 0 transparent inputs, 2 Ironwood actions. // After the last transparent output firmware sends ZcashPCZTActionAck (not TransparentAck) -// because there are no inputs — it finalises the transparent digest and opens Orchard directly. +// because there are no inputs — it finalises the transparent digest and opens Ironwood directly. const DESHIELD_REQUEST = { n_actions: 2, + pool: "ironwood" as const, account: 0, - branch_id: 0x4dec4df0, - header_fields: { tx_version: 5, version_group_id: 0x26a7270a, lock_time: 0, expiry_height: 0 }, + branch_id: 0x37a5165b, + header_fields: { tx_version: 6, version_group_id: 0xd884b698, lock_time: 0, expiry_height: 0 }, digests: { header: "59bc2475723880114749687687be420e7e3389ce82e0ad6b9ba62e0a28457d3d", transparent: "0a259ca3000000000000000000000000000000000000000000000000000000ff", orchard: "d0f62785000000000000000000000000000000000000000000000000000000ff", + ironwood: "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59", }, bundle_meta: { flags: 3, @@ -427,7 +436,7 @@ const DESHIELD_REQUEST = { transparent_inputs: [], actions: [ { - // Change output back to Orchard — is_spend=false, value is the OUTPUT note value (change amount) + // Change output back to Ironwood — is_spend=false, value is the OUTPUT note value. index: 0, alpha: "aa".repeat(32), cv_net: "bb".repeat(32), @@ -467,7 +476,7 @@ const DESHIELD_REQUEST = { }; describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => { - it("follows output→ZcashPCZTActionAck→Orchard protocol (no TransparentAck after last output)", async () => { + it("follows output→ZcashPCZTActionAck→Ironwood protocol (no TransparentAck after last output)", async () => { const calls: number[] = []; const capturedOutputMsg: ZcashMessages.ZcashTransparentOutput[] = []; const capturedActionMsg: ZcashMessages.ZcashPCZTAction[] = []; @@ -492,7 +501,7 @@ describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => } // Step 2: ZcashTransparentOutput → ZcashPCZTActionAck(0) - // No inputs → firmware finalises transparent digest and opens Orchard directly. + // No inputs → firmware finalises transparent digest and opens Ironwood directly. // Regression: previously threw "expected TransparentAck after output 0, got ZCASHPCZTACTIONACK" if (mtype === Messages.MessageType.MESSAGETYPE_ZCASHTRANSPARENTOUTPUT) { capturedOutputMsg.push(msg); diff --git a/packages/hdwallet-keepkey/src/zcash.ts b/packages/hdwallet-keepkey/src/zcash.ts index b32d06f3..fb5cde5b 100644 --- a/packages/hdwallet-keepkey/src/zcash.ts +++ b/packages/hdwallet-keepkey/src/zcash.ts @@ -116,9 +116,10 @@ export async function zcashSignPczt( transport: Transport, signingRequest: { n_actions: number; + pool?: "orchard" | "ironwood"; account?: number; branch_id?: number; - digests?: { header?: string; transparent?: string; sapling?: string; orchard?: string }; + digests?: { header?: string; transparent?: string; sapling?: string; orchard?: string; ironwood?: string }; bundle_meta?: { flags: number; value_balance: number; anchor: string }; header_fields?: { tx_version: number; version_group_id: number; lock_time: number; expiry_height: number }; actions: Array<{ @@ -157,6 +158,7 @@ export async function zcashSignPczt( signMsg.setBranchId(signingRequest.branch_id ?? 0x37519621); signMsg.setAddressNList([0x80000000 + 32, 0x80000000 + 133, 0x80000000 + account]); signMsg.setAccount(account); + signMsg.setShieldedPool(signingRequest.pool === "ironwood" ? 1 : 0); const totalZat = Math.round(parseFloat(signingRequest.display.amount.replace(" ZEC", "")) * 1e8); const feeZat = Math.round(parseFloat(signingRequest.display.fee.replace(" ZEC", "")) * 1e8); @@ -179,9 +181,10 @@ export async function zcashSignPczt( if (d.transparent) signMsg.setTransparentDigest(hexToBytes(d.transparent)); if (d.sapling) signMsg.setSaplingDigest(hexToBytes(d.sapling)); if (d.orchard) signMsg.setOrchardDigest(hexToBytes(d.orchard)); + if (d.ironwood) signMsg.setIronwoodDigest(hexToBytes(d.ironwood)); } - // Orchard bundle metadata + // Orchard-family bundle metadata (field names are shared with Ironwood) const bm = signingRequest.bundle_meta; if (bm) { signMsg.setOrchardFlags(bm.flags); @@ -204,6 +207,8 @@ export async function zcashSignPczt( transparent_digest: d?.transparent ? d.transparent.slice(0, 8) + "..." : undefined, sapling_digest: d?.sapling ?? "(absent)", orchard_digest: d?.orchard ? d.orchard.slice(0, 8) + "..." : undefined, + ironwood_digest: d?.ironwood ? d.ironwood.slice(0, 8) + "..." : undefined, + shielded_pool: signingRequest.pool ?? "orchard", orchard_flags: bm?.flags, orchard_value_balance: bm?.value_balance, n_transparent_outputs: nTransparentOutputs, @@ -272,7 +277,7 @@ export async function zcashSignPczt( console.info(`[zcash-pczt] ← output[${i}] response:`, response.message_type, response.message_enum); // After the last output with no transparent inputs, firmware skips straight - // to Orchard and sends ZcashPCZTActionAck(0) instead of TransparentAck. + // to the shielded action stream and sends ZcashPCZTActionAck(0). if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHPCZTACTIONACK) { break; } @@ -344,7 +349,7 @@ export async function zcashSignPczt( // Firmware 7.15+: after last input, sends ZcashPCZTActionAck(0) and buffers // transparent ECDSA sigs internally — they come out with ZcashTransparentSigned - // BEFORE ZcashSignedPCZT after the last Orchard action. + // BEFORE ZcashSignedPCZT after the last shielded action. if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHPCZTACTIONACK) { break; } @@ -368,7 +373,7 @@ export async function zcashSignPczt( } } - // Step 3: Stream Orchard actions to device. + // Step 3: Stream Orchard/Ironwood actions to device. // Firmware always sends ZcashPCZTActionAck before each action (including the first — // the ack after the last transparent input doubles as action[0] ack). const orchardSignatures: string[] = []; @@ -377,7 +382,7 @@ export async function zcashSignPczt( if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT) { break; } - throw new Error(`zcash: unexpected response during Orchard signing: ${response.message_type}`); + throw new Error(`zcash: unexpected response during shielded signing: ${response.message_type}`); } const action = signingRequest.actions[i]; @@ -440,7 +445,7 @@ export async function zcashSignPczt( console.info(`[zcash-pczt] ← readResponse:`, response.message_type, response.message_enum); } - // Step 5: Collect Orchard signatures + // Step 5: Collect active-pool signatures if (response.message_enum !== Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT) { throw new Error(`zcash: expected ZcashSignedPCZT, got ${response.message_type}`); } @@ -451,7 +456,7 @@ export async function zcashSignPczt( } console.info( - `[zcash-pczt] DONE: ${orchardSignatures.length} Orchard sig(s), ${transparentSignatures.length} transparent sig(s)` + `[zcash-pczt] DONE: ${orchardSignatures.length} ${signingRequest.pool ?? "orchard"} sig(s), ${transparentSignatures.length} transparent sig(s)` ); if (!hasTransparentPhase) { From 5d9ab1df49419b8c1d1750be9c8cb6e635cb27a4 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Jul 2026 19:20:00 -0300 Subject: [PATCH 04/17] fix(zcash): pin Ironwood client protocol --- .github/workflows/ci.yml | 2 +- package.json | 5 +++-- packages/hdwallet-keepkey/package.json | 2 +- packages/hdwallet-keepkey/src/zcash.test.ts | 19 +++++++++---------- packages/hdwallet-keepkey/src/zcash.ts | 4 +++- yarn.lock | 7 +++---- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 705387cf..46e8c8f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: cache: yarn - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libudev-dev libusb-dev libusb-1.0-0-dev libtool + run: sudo apt-get update && sudo apt-get install -y libudev-dev libusb-dev libusb-1.0-0-dev libtool protobuf-compiler - name: Install dependencies run: yarn --frozen-lockfile diff --git a/package.json b/package.json index 893e1a37..ab9b1b02 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,14 @@ "lint:fix": "yarn lint --fix", "lint:ts": "tsc --noEmit", "format": "prettier .", - "build": "yarn tsc --build", + "build:device-protocol": "npm --prefix node_modules/@keepkey/device-protocol install --ignore-scripts --package-lock=false && npm --prefix node_modules/@keepkey/device-protocol install --ignore-scripts --package-lock=false --no-save google-protobuf@3.21.4 && npm --prefix node_modules/@keepkey/device-protocol run build:js", + "build": "yarn build:device-protocol && yarn tsc --build", "dev:all": "yarn tsc --build --watch", "dev:sandbox": "cd examples/sandbox && yarn dev", "docker:run:emulator": "docker run -p 5000:5000 kktech/kkemu:latest", "docker:stop:run:emulator": "docker stop $(docker ps -a -q --filter ancestor=kktech/kkemu:latest --format=\"{{.ID}}\") && docker run -p 5000:5000 kktech/kkemu:latest", "vercel-install": "yarn install --frozen-lockfile", - "vercel-build": "lerna run build --scope @keepkey/hdwallet-core && lerna run build --scope @keepkey/hdwallet-sandbox --ignore @keepkey/hdwallet-keepkey-nodehid --include-dependencies", + "vercel-build": "yarn build:device-protocol && lerna run build --scope @keepkey/hdwallet-core && lerna run build --scope @keepkey/hdwallet-sandbox --ignore @keepkey/hdwallet-keepkey-nodehid --include-dependencies", "test": "yarn jest --verbose --config jest.config.js", "test:integration": "yarn jest -c integration/jest.config.js", "lerna:version-alpha": "lerna version prerelease --force-publish --no-push --exact --yes", diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 79141034..d8502cfb 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.17.0", + "@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/packages/hdwallet-keepkey/src/zcash.test.ts b/packages/hdwallet-keepkey/src/zcash.test.ts index f86a048c..21214220 100644 --- a/packages/hdwallet-keepkey/src/zcash.test.ts +++ b/packages/hdwallet-keepkey/src/zcash.test.ts @@ -87,7 +87,7 @@ const SHIELD_REQUEST = { rk: "dd".repeat(32), out_ciphertext: "ee".repeat(80), value: 0, - is_spend: true, + is_spend: false, }, ], }; @@ -172,8 +172,6 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { transparentSigned.addSignatures(new Uint8Array(71).fill(0x30)); const signedPczt = new ZcashMessages.ZcashSignedPCZT(); - signedPczt.addSignatures(new Uint8Array(64).fill(0x42)); - signedPczt.addSignatures(new Uint8Array(64).fill(0x43)); const readResponse = jest.fn().mockResolvedValue({ message_enum: Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT, @@ -290,13 +288,13 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(Buffer.from(capturedActionMsg[0].getRecipient_asU8()).toString("hex")).toBe("ab".repeat(43)); expect(capturedActionMsg[0].getRseed_asU8()).toHaveLength(32); expect(Buffer.from(capturedActionMsg[0].getRseed_asU8()).toString("hex")).toBe("cd".repeat(32)); - // action[1] is the dummy spend (is_spend=true) — value 0, no recipient/rseed - expect(capturedActionMsg[1].getIsSpend()).toBe(true); + // action[1] is also a dummy spend — transparent shielding has no Ironwood spend authorization. + expect(capturedActionMsg[1].getIsSpend()).toBe(false); expect(capturedActionMsg[1].getValue()).toBe(0); expect(capturedActionMsg[1].getRecipient_asU8()).toHaveLength(0); // Ironwood sigs returned, transparent sigs attached (from ZcashTransparentSigned) - expect(result).toHaveLength(2); + expect(result).toHaveLength(0); expect(result._transparentSignatures).toHaveLength(1); }); @@ -391,13 +389,15 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { throw new Error(`unexpected call: ${mtype}`); }); - const orchardOnlyRequest = { + const ironwoodOnlyRequest = { ...SHIELD_REQUEST, + bundle_meta: { ...SHIELD_REQUEST.bundle_meta, value_balance: 15000 }, + actions: SHIELD_REQUEST.actions.map((action, index) => ({ ...action, is_spend: index === 1 })), transparent_inputs: [], transparent_outputs: [], }; - const result = await zcashSignPczt(makeMockTransport(call), orchardOnlyRequest, SIGHASH); + const result = await zcashSignPczt(makeMockTransport(call), ironwoodOnlyRequest, SIGHASH); expect(calls).toEqual([ Messages.MessageType.MESSAGETYPE_ZCASHSIGNPCZT, @@ -483,7 +483,6 @@ describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => const signedPczt = new ZcashMessages.ZcashSignedPCZT(); signedPczt.addSignatures(new Uint8Array(64).fill(0x42)); - signedPczt.addSignatures(new Uint8Array(64).fill(0x43)); const call = jest.fn().mockImplementation((mtype: number, msg: any) => { calls.push(mtype); @@ -575,7 +574,7 @@ describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => expect(capturedActionMsg[1].getRseed_asU8()).toHaveLength(32); // Deshield has a transparent phase (outputs) but no ECDSA sigs (no transparent inputs) - expect(result).toHaveLength(2); + expect(result).toHaveLength(1); expect((result as any)._transparentSignatures).toEqual([]); }); }); diff --git a/packages/hdwallet-keepkey/src/zcash.ts b/packages/hdwallet-keepkey/src/zcash.ts index fb5cde5b..5ee9fe63 100644 --- a/packages/hdwallet-keepkey/src/zcash.ts +++ b/packages/hdwallet-keepkey/src/zcash.ts @@ -456,7 +456,9 @@ export async function zcashSignPczt( } console.info( - `[zcash-pczt] DONE: ${orchardSignatures.length} ${signingRequest.pool ?? "orchard"} sig(s), ${transparentSignatures.length} transparent sig(s)` + `[zcash-pczt] DONE: ${orchardSignatures.length} ${signingRequest.pool ?? "orchard"} sig(s), ${ + transparentSignatures.length + } transparent sig(s)` ); if (!hasTransparentPhase) { diff --git a/yarn.lock b/yarn.lock index c43deb2b..d78b050d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,10 +1151,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@npm:@bithighlander/device-protocol@7.16.0": - version "7.16.0" - resolved "https://registry.yarnpkg.com/@bithighlander/device-protocol/-/device-protocol-7.16.0.tgz#b492a791ddd35ad1e47129cbabce427f43a974bc" - integrity sha512-Wo6Kyy2xBiGFLsDfOZElVP8bijYsem7iBzJ1IepurpyM9sT7TaSeGw9s4kq5pZyXZb9nWkmYNYq7VE3YFvvl6Q== +"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae": + version "7.14.1" + resolved "https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae" dependencies: google-protobuf "^3.7.0-rc.2" pbjs "^0.0.5" From 1e6f83b24476674dba4d94756490cfa69a0d1ad2 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Jul 2026 19:59:46 -0300 Subject: [PATCH 05/17] feat(clearsign): integrate attestor client with Ironwood --- .../hdwallet-keepkey/src/clearsign.test.ts | 51 ++++ packages/hdwallet-keepkey/src/clearsign.ts | 283 ++++++++++++++++++ packages/hdwallet-keepkey/src/ethereum.ts | 4 +- packages/hdwallet-keepkey/src/keepkey.ts | 15 +- packages/hdwallet-keepkey/src/solana.ts | 28 +- 5 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 packages/hdwallet-keepkey/src/clearsign.test.ts create mode 100644 packages/hdwallet-keepkey/src/clearsign.ts diff --git a/packages/hdwallet-keepkey/src/clearsign.test.ts b/packages/hdwallet-keepkey/src/clearsign.test.ts new file mode 100644 index 00000000..095ab01f --- /dev/null +++ b/packages/hdwallet-keepkey/src/clearsign.test.ts @@ -0,0 +1,51 @@ +/** + * Regression coverage for ClearSign attestor message decoding. + * + * Transport.fromMessageBuffer always calls deserializeBinaryFromReader on the + * constructor stored in messageTypeRegistry. Keep the attestor entries on the + * canonical generated classes so public-key and signature responses cannot be + * replaced by incomplete hand-written shims. + */ +import "./clearsign"; + +import * as jspb from "google-protobuf"; + +import { messageTypeRegistry } from "./typeRegistry"; + +const ATTESTOR_TYPES = [ + [1700, "ClearsignAttestorGetPublicKey"], + [1701, "ClearsignAttestorPublicKey"], + [1702, "ClearsignAttestorSign"], + [1703, "ClearsignAttestorSignature"], +] as const; + +describe("ClearSign attestor protobuf transport", () => { + it.each(ATTESTOR_TYPES)("registers message type %i (%s) with reader decoding", (typeId) => { + const registeredType = messageTypeRegistry[typeId] as any; + expect(registeredType).toBeDefined(); + expect(typeof registeredType.deserializeBinaryFromReader).toBe("function"); + }); + + it("decodes the public-key response through the transport registry path", () => { + const publicKey = new Uint8Array(33).fill(0x02); + const writer = new jspb.BinaryWriter(); + writer.writeBytes(1, publicKey); + const MType = messageTypeRegistry[1701] as any; + const decoded = MType.deserializeBinaryFromReader(new MType(), new jspb.BinaryReader(writer.getResultBuffer())); + + expect(Array.from(decoded.getPublicKey_asU8())).toEqual(Array.from(publicKey)); + }); + + it("decodes the signature response through the transport registry path", () => { + const signature = new Uint8Array(64).fill(0x5a); + const publicKey = new Uint8Array(33).fill(0x03); + const writer = new jspb.BinaryWriter(); + writer.writeBytes(1, signature); + writer.writeBytes(2, publicKey); + const MType = messageTypeRegistry[1703] as any; + const decoded = MType.deserializeBinaryFromReader(new MType(), new jspb.BinaryReader(writer.getResultBuffer())); + + expect(Array.from(decoded.getSignature_asU8())).toEqual(Array.from(signature)); + expect(Array.from(decoded.getPublicKey_asU8())).toEqual(Array.from(publicKey)); + }); +}); diff --git a/packages/hdwallet-keepkey/src/clearsign.ts b/packages/hdwallet-keepkey/src/clearsign.ts new file mode 100644 index 00000000..0a228212 --- /dev/null +++ b/packages/hdwallet-keepkey/src/clearsign.ts @@ -0,0 +1,283 @@ +import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; +import * as core from "@keepkey/hdwallet-core"; +import * as jspb from "google-protobuf"; + +import { Transport } from "./transport"; +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; + +const MESSAGETYPE_CLEARSIGN_ATTESTOR_GET_PUBLIC_KEY = 1700; +const MESSAGETYPE_CLEARSIGN_ATTESTOR_PUBLIC_KEY = 1701; +const MESSAGETYPE_CLEARSIGN_ATTESTOR_SIGN = 1702; +const MESSAGETYPE_CLEARSIGN_ATTESTOR_SIGNATURE = 1703; + +class ClearsignAttestorGetPublicKeyShim extends jspb.Message { + constructor(optData?: any) { + super(); + jspb.Message.initialize(this, optData || [], 0, -1, null, null); + } + + serializeBinary(): Uint8Array { + return new jspb.BinaryWriter().getResultBuffer(); + } + + toObject(): object { + return {}; + } + + static deserializeBinary(bytes: Uint8Array): ClearsignAttestorGetPublicKeyShim { + return ClearsignAttestorGetPublicKeyShim.deserializeBinaryFromReader( + new ClearsignAttestorGetPublicKeyShim(), + new jspb.BinaryReader(bytes) + ); + } + + static deserializeBinaryFromReader( + message: ClearsignAttestorGetPublicKeyShim, + reader: jspb.BinaryReader + ): ClearsignAttestorGetPublicKeyShim { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + reader.skipField(); + } + return message; + } +} + +class ClearsignAttestorPublicKeyShim extends jspb.Message { + constructor(optData?: any) { + super(); + jspb.Message.initialize(this, optData || [], 0, -1, null, null); + } + + getPublicKey(): Uint8Array | string { + return jspb.Message.getFieldWithDefault(this, 1, "") as Uint8Array | string; + } + + getPublicKey_asU8(): Uint8Array { + const value = this.getPublicKey(); + return value instanceof Uint8Array ? value : jspb.Message.bytesAsU8(value); + } + + setPublicKey(value: Uint8Array | string): void { + jspb.Message.setField(this, 1, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + const value = this.getPublicKey(); + if (value && value.length > 0) writer.writeBytes(1, value); + return writer.getResultBuffer(); + } + + toObject(): object { + return { publicKey: this.getPublicKey() }; + } + + static deserializeBinary(bytes: Uint8Array): ClearsignAttestorPublicKeyShim { + return ClearsignAttestorPublicKeyShim.deserializeBinaryFromReader( + new ClearsignAttestorPublicKeyShim(), + new jspb.BinaryReader(bytes) + ); + } + + static deserializeBinaryFromReader( + message: ClearsignAttestorPublicKeyShim, + reader: jspb.BinaryReader + ): ClearsignAttestorPublicKeyShim { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + if (reader.getFieldNumber() === 1) message.setPublicKey(reader.readBytes()); + else reader.skipField(); + } + return message; + } +} + +class ClearsignAttestorSignShim extends jspb.Message { + constructor(optData?: any) { + super(); + jspb.Message.initialize(this, optData || [], 0, -1, null, null); + } + + getPayload(): Uint8Array | string { + return jspb.Message.getFieldWithDefault(this, 1, "") as Uint8Array | string; + } + + getPayload_asU8(): Uint8Array { + const value = this.getPayload(); + return value instanceof Uint8Array ? value : jspb.Message.bytesAsU8(value); + } + + setPayload(value: Uint8Array | string): void { + jspb.Message.setField(this, 1, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + const value = this.getPayload(); + if (value && value.length > 0) writer.writeBytes(1, value); + return writer.getResultBuffer(); + } + + toObject(): object { + return { payload: this.getPayload() }; + } + + static deserializeBinary(bytes: Uint8Array): ClearsignAttestorSignShim { + return ClearsignAttestorSignShim.deserializeBinaryFromReader( + new ClearsignAttestorSignShim(), + new jspb.BinaryReader(bytes) + ); + } + + static deserializeBinaryFromReader( + message: ClearsignAttestorSignShim, + reader: jspb.BinaryReader + ): ClearsignAttestorSignShim { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + if (reader.getFieldNumber() === 1) message.setPayload(reader.readBytes()); + else reader.skipField(); + } + return message; + } +} + +class ClearsignAttestorSignatureShim extends jspb.Message { + constructor(optData?: any) { + super(); + jspb.Message.initialize(this, optData || [], 0, -1, null, null); + } + + private getBytes(field: number): Uint8Array { + const value = jspb.Message.getFieldWithDefault(this, field, "") as Uint8Array | string; + return value instanceof Uint8Array ? value : jspb.Message.bytesAsU8(value); + } + + getSignature_asU8(): Uint8Array { + return this.getBytes(1); + } + + setSignature(value: Uint8Array | string): void { + jspb.Message.setField(this, 1, value); + } + + getPublicKey_asU8(): Uint8Array { + return this.getBytes(2); + } + + setPublicKey(value: Uint8Array | string): void { + jspb.Message.setField(this, 2, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + const signature = this.getSignature_asU8(); + const publicKey = this.getPublicKey_asU8(); + if (signature.length > 0) writer.writeBytes(1, signature); + if (publicKey.length > 0) writer.writeBytes(2, publicKey); + return writer.getResultBuffer(); + } + + toObject(): object { + return { signature: this.getSignature_asU8(), publicKey: this.getPublicKey_asU8() }; + } + + static deserializeBinary(bytes: Uint8Array): ClearsignAttestorSignatureShim { + return ClearsignAttestorSignatureShim.deserializeBinaryFromReader( + new ClearsignAttestorSignatureShim(), + new jspb.BinaryReader(bytes) + ); + } + + static deserializeBinaryFromReader( + message: ClearsignAttestorSignatureShim, + reader: jspb.BinaryReader + ): ClearsignAttestorSignatureShim { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + if (reader.getFieldNumber() === 1) message.setSignature(reader.readBytes()); + else if (reader.getFieldNumber() === 2) message.setPublicKey(reader.readBytes()); + else reader.skipField(); + } + return message; + } +} + +// Prefer the canonical generated classes when the pinned protocol supplies +// them. The shims keep older hdwallet installs wire-compatible while they are +// being advanced to the RC21 protocol pin. +const runtimeMessages = Messages as any; +const ClearsignAttestorGetPublicKey = + runtimeMessages.ClearsignAttestorGetPublicKey || ClearsignAttestorGetPublicKeyShim; +const ClearsignAttestorPublicKey = runtimeMessages.ClearsignAttestorPublicKey || ClearsignAttestorPublicKeyShim; +const ClearsignAttestorSign = runtimeMessages.ClearsignAttestorSign || ClearsignAttestorSignShim; +const ClearsignAttestorSignature = runtimeMessages.ClearsignAttestorSignature || ClearsignAttestorSignatureShim; + +const attestorMessages: Array<[string, string, number, any]> = [ + [ + "MESSAGETYPE_CLEARSIGNATTESTORGETPUBLICKEY", + "ClearsignAttestorGetPublicKey", + MESSAGETYPE_CLEARSIGN_ATTESTOR_GET_PUBLIC_KEY, + ClearsignAttestorGetPublicKey, + ], + [ + "MESSAGETYPE_CLEARSIGNATTESTORPUBLICKEY", + "ClearsignAttestorPublicKey", + MESSAGETYPE_CLEARSIGN_ATTESTOR_PUBLIC_KEY, + ClearsignAttestorPublicKey, + ], + [ + "MESSAGETYPE_CLEARSIGNATTESTORSIGN", + "ClearsignAttestorSign", + MESSAGETYPE_CLEARSIGN_ATTESTOR_SIGN, + ClearsignAttestorSign, + ], + [ + "MESSAGETYPE_CLEARSIGNATTESTORSIGNATURE", + "ClearsignAttestorSignature", + MESSAGETYPE_CLEARSIGN_ATTESTOR_SIGNATURE, + ClearsignAttestorSignature, + ], +]; + +const messageTypes = Messages.MessageType as unknown as Record; +for (const [enumName, name, id, message] of attestorMessages) { + messageTypes[enumName] = id; + messageNameRegistry[id] = name; + messageTypeRegistry[id] = message; +} + +export async function getAttestorPublicKey(transport: Transport): Promise { + return transport.lockDuring(async () => { + const response = await transport.call( + MESSAGETYPE_CLEARSIGN_ATTESTOR_GET_PUBLIC_KEY, + new ClearsignAttestorGetPublicKey(), + { msgTimeout: core.LONG_TIMEOUT, omitLock: true } + ); + const publicKey = (response.proto as ClearsignAttestorPublicKeyShim).getPublicKey_asU8(); + if (publicKey.length !== 33) throw new Error("Device returned an invalid ClearSign attestor public key"); + return publicKey; + }); +} + +export async function attestPayload( + transport: Transport, + payload: Uint8Array +): Promise<{ signature: Uint8Array; publicKey: Uint8Array }> { + return transport.lockDuring(async () => { + const request = new ClearsignAttestorSign(); + request.setPayload(payload); + const response = await transport.call(MESSAGETYPE_CLEARSIGN_ATTESTOR_SIGN, request, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + const result = response.proto as ClearsignAttestorSignatureShim; + const signature = result.getSignature_asU8(); + const publicKey = result.getPublicKey_asU8(); + if (signature.length !== 64 || publicKey.length !== 33) { + throw new Error("Device returned an invalid ClearSign attestation"); + } + return { signature, publicKey }; + }); +} diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index ed9fc2dd..9656d6c6 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -540,11 +540,12 @@ export async function ethLoadClearsignSigner( icon?: Uint8Array; iconWidth?: number; iconHeight?: number; - /** Keep the identity in device flash across reboots (until WipeDevice). */ + /** @deprecated ClearSign identities are intentionally RAM-only. */ persist?: boolean; } ): Promise<{ ok: true }> { return transport.lockDuring(async () => { + if (msg.persist) throw new Error("Persistent ClearSign signers are not supported; use a RAM-only session signer"); const m = new LoadClearsignSigner(); m.setKeyId(msg.keyId); m.setPubkey(msg.pubkey); @@ -554,7 +555,6 @@ export async function ethLoadClearsignSigner( m.setIconWidth(msg.iconWidth ?? 0); m.setIconHeight(msg.iconHeight ?? 0); } - if (msg.persist) m.setPersist(true); await transport.call(MESSAGETYPE_LOADCLEARSIGNSIGNER, m, { msgTimeout: core.LONG_TIMEOUT, omitLock: true, diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index 07eeaac6..7c775c1d 100644 --- a/packages/hdwallet-keepkey/src/keepkey.ts +++ b/packages/hdwallet-keepkey/src/keepkey.ts @@ -4,6 +4,7 @@ import * as core from "@keepkey/hdwallet-core"; import semver from "semver"; import * as Btc from "./bitcoin"; +import * as Clearsign from "./clearsign"; import * as Cosmos from "./cosmos"; import * as Eos from "./eos"; import * as Eth from "./ethereum"; @@ -1376,9 +1377,7 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Eth.ethSignTx(this.transport, msg); } - /** Load a runtime EVM clear-sign signer into a device key slot (user-confirmed). KeepKey-specific. - * Optionally carries an identity logo (icon, 1bpp mono RLE <=384B) and persist=true to keep the - * identity in device flash across reboots (firmware 7.15+). */ + /** Load an Advanced-mode ClearSign signer into a RAM-only device slot. */ public async loadClearsignSigner(msg: { keyId: number; pubkey: Uint8Array; @@ -1391,6 +1390,16 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Eth.ethLoadClearsignSigner(this.transport, msg); } + /** Return this device's dedicated ClearSign attestation public key. */ + public async clearsignAttestorGetPublicKey(): Promise { + return Clearsign.getAttestorPublicKey(this.transport); + } + + /** Validate, review, and attest a canonical ClearSign descriptor. */ + public async clearsignAttestorSign(payload: Uint8Array): Promise<{ signature: Uint8Array; publicKey: Uint8Array }> { + return Clearsign.attestPayload(this.transport, payload); + } + public async ethGetAddress(msg: core.ETHGetAddress): Promise { return Eth.ethGetAddress(this.transport, msg); } diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 2d4f8e0b..49aef5ea 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -43,11 +43,7 @@ function encodeVarintField(fieldNumber: number, value: number): Uint8Array { /** field << 3 | 2 (length-delimited) */ function encodeLengthDelimited(fieldNumber: number, bytes: Uint8Array): Uint8Array { - return Uint8Array.from([ - ...encodeVarint((fieldNumber << 3) | 2), - ...encodeVarint(bytes.length), - ...bytes, - ]); + return Uint8Array.from([...encodeVarint((fieldNumber << 3) | 2), ...encodeVarint(bytes.length), ...bytes]); } function concatBytes(...chunks: Uint8Array[]): Uint8Array { @@ -1117,12 +1113,14 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) } signTx.setRawTx(rawBytes); if (msg.swapMetadata) { - const payload = msg.swapMetadata.payload instanceof Uint8Array - ? msg.swapMetadata.payload - : Uint8Array.from(Buffer.from(msg.swapMetadata.payload, "base64")); - const signature = msg.swapMetadata.signature instanceof Uint8Array - ? msg.swapMetadata.signature - : Uint8Array.from(Buffer.from(msg.swapMetadata.signature, "base64")); + const payload = + msg.swapMetadata.payload instanceof Uint8Array + ? msg.swapMetadata.payload + : Uint8Array.from(Buffer.from(msg.swapMetadata.payload, "base64")); + const signature = + msg.swapMetadata.signature instanceof Uint8Array + ? msg.swapMetadata.signature + : Uint8Array.from(Buffer.from(msg.swapMetadata.signature, "base64")); signTx.setSwapMetadataPayload(payload); signTx.setSwapMetadataSignature(signature); signTx.setSwapMetadataSignerKeyId(msg.swapMetadata.signerKeyId); @@ -1132,7 +1130,7 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) } /* - * KKSOLSC1 schema fields (SolanaSignTx 5/6/7) are appended at the wire + * KKSOLSC1 schema fields (SolanaSignTx 9/10/11) are appended at the wire * level rather than through generated setters: the published * @keepkey/device-protocol build predates them, so setSchemaPayload() and * friends do not exist. Protobuf makes this safe and lossless — encoded @@ -1146,9 +1144,9 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) const payload = toBytes(msg.schema.payload); const signature = toBytes(msg.schema.signature); const extra = concatBytes( - encodeLengthDelimited(5, payload), - encodeLengthDelimited(6, signature), - encodeVarintField(7, msg.schema.signerKeyId) + encodeLengthDelimited(9, payload), + encodeLengthDelimited(10, signature), + encodeVarintField(11, msg.schema.signerKeyId) ); outbound = withAppendedFields(signTx, extra); } From 920d0d4640ee178b20175f39330b2ef4c6719b62 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 02:36:21 -0300 Subject: [PATCH 06/17] fix(zcash): consume published Ironwood protocol Restore the established @bithighlander/device-protocol npm flow at exact version 7.17.0 and remove the temporary Git dependency source-generation path. --- .github/workflows/ci.yml | 2 +- package.json | 5 ++--- packages/hdwallet-keepkey/package.json | 2 +- yarn.lock | 7 ++++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e8c8f8..705387cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: cache: yarn - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libudev-dev libusb-dev libusb-1.0-0-dev libtool protobuf-compiler + run: sudo apt-get update && sudo apt-get install -y libudev-dev libusb-dev libusb-1.0-0-dev libtool - name: Install dependencies run: yarn --frozen-lockfile diff --git a/package.json b/package.json index ab9b1b02..893e1a37 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,13 @@ "lint:fix": "yarn lint --fix", "lint:ts": "tsc --noEmit", "format": "prettier .", - "build:device-protocol": "npm --prefix node_modules/@keepkey/device-protocol install --ignore-scripts --package-lock=false && npm --prefix node_modules/@keepkey/device-protocol install --ignore-scripts --package-lock=false --no-save google-protobuf@3.21.4 && npm --prefix node_modules/@keepkey/device-protocol run build:js", - "build": "yarn build:device-protocol && yarn tsc --build", + "build": "yarn tsc --build", "dev:all": "yarn tsc --build --watch", "dev:sandbox": "cd examples/sandbox && yarn dev", "docker:run:emulator": "docker run -p 5000:5000 kktech/kkemu:latest", "docker:stop:run:emulator": "docker stop $(docker ps -a -q --filter ancestor=kktech/kkemu:latest --format=\"{{.ID}}\") && docker run -p 5000:5000 kktech/kkemu:latest", "vercel-install": "yarn install --frozen-lockfile", - "vercel-build": "yarn build:device-protocol && lerna run build --scope @keepkey/hdwallet-core && lerna run build --scope @keepkey/hdwallet-sandbox --ignore @keepkey/hdwallet-keepkey-nodehid --include-dependencies", + "vercel-build": "lerna run build --scope @keepkey/hdwallet-core && lerna run build --scope @keepkey/hdwallet-sandbox --ignore @keepkey/hdwallet-keepkey-nodehid --include-dependencies", "test": "yarn jest --verbose --config jest.config.js", "test:integration": "yarn jest -c integration/jest.config.js", "lerna:version-alpha": "lerna version prerelease --force-publish --no-push --exact --yes", diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index d8502cfb..79141034 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae", + "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.17.0", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/yarn.lock b/yarn.lock index d78b050d..7ac797ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,9 +1151,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae": - version "7.14.1" - resolved "https://github.com/keepkey/device-protocol.git#f2246cebea8f96fcd7ec2883588a784a60b430ae" +"@keepkey/device-protocol@npm:@bithighlander/device-protocol@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@bithighlander/device-protocol/-/device-protocol-7.17.0.tgz#23450420224f25475b336687504594d67496b120" + integrity sha512-Nw7hb5ZTqEHyOWvrdb8o7rvT+H79X9EJHR9O7VIv0xQ2711o0Q3Dga8YSYW21MWU7wBoNQ87NcJ9XLEKW+gdJQ== dependencies: google-protobuf "^3.7.0-rc.2" pbjs "^0.0.5" From a4821a56469b08485cc88b6d277e39a46351d149 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 8 May 2026 13:02:26 -0300 Subject: [PATCH 07/17] feat(keepkey): expose firmware-reported pre-image hash on EthSignTx response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EthereumSignedTx (KeepKey custom proto) carries an optional `hash` field — the keccak256 pre-image the firmware actually signed. Until now the SDK ignored it, so the only way to verify which message bytes the device hashed was to recover the signer client-side and compare to the expected address. Surface the hash as `deviceSignedHash` on the returned signed tx so callers can pin the exact firmware behavior in tests/diagnostics. Older firmware doesn't populate the field; absence is non-fatal (try/catch, optional). Motivating use case: EIP-1559 tx hash regression where ethers' Transaction.from() reconstruction recovers the wrong signer — having the firmware-reported hash narrows the divergence to RLP construction vs. signing, instead of guessing. Non-breaking: purely additive. Existing consumers see the same shape. --- packages/hdwallet-keepkey/src/ethereum.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 9656d6c6..df97a9b3 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -503,6 +503,20 @@ export async function ethSignTx(transport: Transport, msg: core.ETHSignTx): Prom const v = core.mustBeDefined(response.getSignatureV()); const v2 = "0x" + v.toString(16); + // Capture the firmware-reported pre-image hash (KeepKey custom field). + // Older firmware doesn't populate this; absence is non-fatal. + let deviceSignedHash: string | undefined; + try { + if (response.hasHash && response.hasHash() && response.getHash_asU8) { + const h = response.getHash_asU8(); + if (h && h.length === 32) { + deviceSignedHash = "0x" + core.toHexString(h); + } + } + } catch { + // ignore — older firmware/proto may not support `hash` + } + const common = Common.custom({ chainId: msg.chainId }); const tx = msg.maxFeePerGas ? FeeMarketEIP1559Transaction.fromTxData({ @@ -520,7 +534,8 @@ export async function ethSignTx(transport: Transport, msg: core.ETHSignTx): Prom s, v, serialized: "0x" + core.toHexString(tx.serialize()), - }; + ...(deviceSignedHash ? { deviceSignedHash } : {}), + } as core.ETHSignedTx & { deviceSignedHash?: string }; }); } From 50ce5ca136c68f4d9179afd04611b0c4d0932df7 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 8 May 2026 13:08:58 -0300 Subject: [PATCH 08/17] fix(core): declare deviceSignedHash on ETHSignedTx so callers can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on PR #40: the previous commit attached deviceSignedHash via a local cast in hdwallet-keepkey, but ETHSignedTx in hdwallet-core didn't declare the field — TypeScript callers couldn't read it without their own cast, which defeated the diagnostic purpose. Add deviceSignedHash?: string to ETHSignedTx (optional, KeepKey-only) and drop the local cast. The shape is identical at runtime; this just makes the field visible to consumers. --- packages/hdwallet-core/src/ethereum.ts | 4 ++++ packages/hdwallet-keepkey/src/ethereum.ts | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/hdwallet-core/src/ethereum.ts b/packages/hdwallet-core/src/ethereum.ts index 213b9565..5204d82f 100644 --- a/packages/hdwallet-core/src/ethereum.ts +++ b/packages/hdwallet-core/src/ethereum.ts @@ -103,6 +103,10 @@ export interface ETHSignedTx { s: string; /** big-endian hex, prefixed with '0x' */ serialized: string; + /** KeepKey-only: keccak256 pre-image the firmware actually signed (32-byte hex). + * Optional — older firmware doesn't populate it. Useful for diagnostics where + * the caller needs to verify which bytes the device hashed. */ + deviceSignedHash?: string; } export interface ETHSignMessage { diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index df97a9b3..93a2b6ca 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -529,13 +529,14 @@ export async function ethSignTx(transport: Transport, msg: core.ETHSignTx): Prom }) : Transaction.fromTxData({ ...utxBase, gasPrice: msg.gasPrice, r: r, s: s, v: v2 }, { common }); - return { + const result: core.ETHSignedTx = { r, s, v, serialized: "0x" + core.toHexString(tx.serialize()), - ...(deviceSignedHash ? { deviceSignedHash } : {}), - } as core.ETHSignedTx & { deviceSignedHash?: string }; + }; + if (deviceSignedHash) result.deviceSignedHash = deviceSignedHash; + return result; }); } From 8aba6e84c6a62b336d4192c00fbca80ff7d5bb96 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 15 May 2026 14:16:07 -0300 Subject: [PATCH 09/17] feat(ripple): pass memo field to device for THORChain swap routing Forward tx.value.memo to the RippleSignTx protobuf message so the firmware can include it in the XRPL Memos array of the signed transaction. --- packages/hdwallet-keepkey/src/ripple.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/hdwallet-keepkey/src/ripple.ts b/packages/hdwallet-keepkey/src/ripple.ts index a94efb57..c95b9f15 100644 --- a/packages/hdwallet-keepkey/src/ripple.ts +++ b/packages/hdwallet-keepkey/src/ripple.ts @@ -26,6 +26,9 @@ export async function rippleSignTx(transport: Transport, msg: core.RippleSignTx) if (msg.payment.destinationTag !== undefined) payment.setDestinationTag(parseInt(msg.payment.destinationTag)); signTx.setPayment(payment); + const memo = msg.tx.value.memo; + if (memo && memo.trim() && memo.trim() !== ' ') signTx.setMemo(memo.trim()); + const resp = await transport.call(Messages.MessageType.MESSAGETYPE_RIPPLESIGNTX, signTx, { msgTimeout: core.LONG_TIMEOUT, omitLock: true, From 06f4a854c0333fefb78c914287fa8ffcd4b0ca98 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 27 Jun 2026 15:10:12 -0500 Subject: [PATCH 10/17] fix(nodewebusb): drop legacy PID 0x0001 from WebUSB getDevice filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebUSB can never serve a 0x0001 device — the TransportDelegate ctor rejects any non-0x0002 PID with FirmwareUpdateRequired. Matching 0x0001 in the active pair filter only produced a doomed WebUSB attempt and a misleading 'Firmware 6.1.0 required' error before the caller's HID fallback. Old devices now skip WebUSB cleanly and pair over HID. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hdwallet-keepkey-nodewebusb/src/adapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts index 51a0a7d1..07d43c2b 100644 --- a/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts +++ b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts @@ -10,10 +10,14 @@ export const NodeWebUSBAdapterDelegate = { return devices.filter((x) => x.vendorId === VENDOR_ID && [WEBUSB_PRODUCT_ID, HID_PRODUCT_ID].includes(x.productId)); }, async getDevice(serialNumber?: string): Promise { + // Only match the WebUSB PID (0x0002). The TransportDelegate ctor rejects any + // other PID with FirmwareUpdateRequired, so matching legacy 0x0001 here just + // produced a doomed pair attempt + a misleading "Firmware 6.1.0 required" + // before the caller's HID fallback. Old (PID 0x0001) devices now skip WebUSB + // cleanly and pair over HID. (getDevices() still lists 0x0001 for detection.) const out = await webusb.requestDevice({ filters: [ { vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber }, - { vendorId: VENDOR_ID, productId: HID_PRODUCT_ID, serialNumber }, ], }); if (out.serialNumber === undefined) throw new Error("expected serial number"); From 7af24f28df9b9b61f4ba7c35d3859088147bf088 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 03:12:55 -0300 Subject: [PATCH 11/17] style(nodewebusb): format request filter --- packages/hdwallet-keepkey-nodewebusb/src/adapter.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts index 07d43c2b..4ae6a686 100644 --- a/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts +++ b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts @@ -16,9 +16,7 @@ export const NodeWebUSBAdapterDelegate = { // before the caller's HID fallback. Old (PID 0x0001) devices now skip WebUSB // cleanly and pair over HID. (getDevices() still lists 0x0001 for detection.) const out = await webusb.requestDevice({ - filters: [ - { vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber }, - ], + filters: [{ vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber }], }); if (out.serialNumber === undefined) throw new Error("expected serial number"); return out as Device; From 94ed6cdb525708f3eb14933ec4a68b291c151431 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 03:13:24 -0300 Subject: [PATCH 12/17] style(ripple): format memo guard --- packages/hdwallet-keepkey/src/ripple.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hdwallet-keepkey/src/ripple.ts b/packages/hdwallet-keepkey/src/ripple.ts index c95b9f15..4ee14ae2 100644 --- a/packages/hdwallet-keepkey/src/ripple.ts +++ b/packages/hdwallet-keepkey/src/ripple.ts @@ -27,7 +27,7 @@ export async function rippleSignTx(transport: Transport, msg: core.RippleSignTx) signTx.setPayment(payment); const memo = msg.tx.value.memo; - if (memo && memo.trim() && memo.trim() !== ' ') signTx.setMemo(memo.trim()); + if (memo && memo.trim() && memo.trim() !== " ") signTx.setMemo(memo.trim()); const resp = await transport.call(Messages.MessageType.MESSAGETYPE_RIPPLESIGNTX, signTx, { msgTimeout: core.LONG_TIMEOUT, From bbc75e3154eb1e9e830417b001b2121ad7476bf3 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 16:28:01 -0300 Subject: [PATCH 13/17] feat: clear-sign x402 payments --- packages/hdwallet-core/src/solana.ts | 16 +++ .../src/ethereum-x402.test.ts | 94 +++++++++++++++ packages/hdwallet-keepkey/src/ethereum.ts | 108 ++++++++++++++++-- .../hdwallet-keepkey/src/solana-x402.test.ts | 93 +++++++++++++++ packages/hdwallet-keepkey/src/solana.ts | 71 ++++++++++-- 5 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 packages/hdwallet-keepkey/src/ethereum-x402.test.ts create mode 100644 packages/hdwallet-keepkey/src/solana-x402.test.ts diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index b82f51b2..e57db2ec 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -9,9 +9,25 @@ export interface SolanaAddress { address: string; } +export interface SolanaTokenInfo { + /** 32-byte SPL mint, encoded as bytes, hex, base64, or base58. */ + mint: Uint8Array | string; + symbol?: string; + decimals?: number; + signature?: Uint8Array | string; + signerKeyId?: number; +} + export interface SolanaSignTx { addressNList: BIP32Path; rawTx: Uint8Array | string; + /** Optional token definitions used by firmware display policy. */ + tokenInfo?: SolanaTokenInfo[]; + /** + * Candidate owners for signed SPL token destinations (for example x402 + * payTo). Firmware displays one only after deriving and matching its ATA. + */ + tokenRecipientOwners?: Array; /** One-request opaque-signing authorization; does not mutate AdvancedMode. */ allowBlindSigning?: boolean; /** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */ diff --git a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts new file mode 100644 index 00000000..16b2da28 --- /dev/null +++ b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts @@ -0,0 +1,94 @@ +import * as Ethereum from "@keepkey/device-protocol/lib/messages-ethereum_pb"; + +import { ethSignTypedData } from "./ethereum"; + +const ETHEREUM_712_TYPES_VALUES = 114; +const PATH = [0x8000002c, 0x8000003c, 0x80000000, 0, 0]; + +function makeMockTransport(call: jest.Mock) { + return { + debugLink: false, + call, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("x402 EVM structured signing", () => { + it("sends the official EIP-3009 authorization as reviewed domain + message", async () => { + const call = jest.fn().mockImplementation((_messageType: number, request: Ethereum.Ethereum712TypesValues) => { + const phase = request.getEip712typevals(); + expect(_messageType).toBe(ETHEREUM_712_TYPES_VALUES); + expect(JSON.parse(request.getEip712primetype() || "{}")).toEqual({ + primaryType: "TransferWithAuthorization", + }); + + const types = JSON.parse(request.getEip712types() || "{}").types; + expect(types.EIP712Domain).toEqual([ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ]); + + if (phase === 1) { + expect(JSON.parse(request.getEip712data() || "{}").domain).toEqual({ + name: "USDC", + version: "2", + chainId: 84532, + verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }); + } else { + expect(JSON.parse(request.getEip712data() || "{}").message).toEqual({ + from: "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + value: "2000", + validAfter: "0", + validBefore: "2000000000", + nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", + }); + } + + const response = new Ethereum.EthereumTypedDataSignature(); + response.setAddress("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); + response.setSignature(new Uint8Array(65).fill(0x42)); + return Promise.resolve({ proto: response }); + }); + + const result = await ethSignTypedData(makeMockTransport(call), { + addressNList: PATH, + typedData: { + // The official x402 client supplies only the authorization type; the + // EIP712Domain type is inferred from the domain object. + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + primaryType: "TransferWithAuthorization", + domain: { + name: "USDC", + version: "2", + chainId: 84532, + verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }, + message: { + from: "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + value: BigInt("2000"), + validAfter: BigInt("0"), + validBefore: BigInt("2000000000"), + nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", + }, + }, + }); + + expect(call).toHaveBeenCalledTimes(2); + expect(result.address).toBe("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); + expect(result.signature).toBe("0x" + "42".repeat(65)); + }); +}); diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 93a2b6ca..76718d17 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -614,11 +614,100 @@ export async function ethSignMessage(transport: Transport, msg: core.ETHSignMess }; } +const EIP3009_TRANSFER_WITH_AUTHORIZATION = [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, +] as const; + +function typedDataJson(value: unknown): string { + return JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item); +} + +function withEip712DomainType(typedData: any): any { + if (Array.isArray(typedData.types?.EIP712Domain)) return typedData; + + const domain = typedData.domain || {}; + const canonicalFields = [ + ["name", "string"], + ["version", "string"], + ["chainId", "uint256"], + ["verifyingContract", "address"], + ["salt", "bytes32"], + ] as const; + const domainType = canonicalFields + .filter(([name]) => domain[name] !== undefined) + .map(([name, type]) => ({ name, type })); + + return { + ...typedData, + types: { ...(typedData.types || {}), EIP712Domain: domainType }, + }; +} + +function isX402Eip3009(typedData: any): boolean { + if (typedData.primaryType !== "TransferWithAuthorization") return false; + const fields = typedData.types?.TransferWithAuthorization; + if (!Array.isArray(fields) || fields.length !== EIP3009_TRANSFER_WITH_AUTHORIZATION.length) return false; + return EIP3009_TRANSFER_WITH_AUTHORIZATION.every( + (expected, index) => fields[index]?.name === expected.name && fields[index]?.type === expected.type + ); +} + +async function signStructuredEip712( + transport: Transport, + addressNList: number[], + typedData: any +): Promise { + const typesJson = typedDataJson({ types: typedData.types }); + const primaryTypeJson = typedDataJson({ primaryType: typedData.primaryType }); + const domainJson = typedDataJson({ domain: typedData.domain || {} }); + const messageJson = typedDataJson({ message: typedData.message || {} }); + + if (typesJson.length > 2048 || domainJson.length > 2048 || messageJson.length > 2048) { + throw new Error("Structured EIP-712 data exceeds firmware limits"); + } + if (primaryTypeJson.length > 80) throw new Error("EIP-712 primary type exceeds firmware limits"); + + const request = (data: string, typeValues: number) => { + const value = new Ethereum.Ethereum712TypesValues(); + value.setAddressNList(addressNList); + value.setEip712types(typesJson); + value.setEip712primetype(primaryTypeJson); + value.setEip712data(data); + value.setEip712typevals(typeValues); + return value; + }; + + // Firmware computes and retains the domain separator, then combines it with + // the independently reviewed message hash in the second request. + await transport.call( + Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, + request(domainJson, 1), + { msgTimeout: core.LONG_TIMEOUT } + ); + const response = await transport.call( + Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, + request(messageJson, 2), + { msgTimeout: core.LONG_TIMEOUT } + ); + const result = response.proto as Ethereum.EthereumTypedDataSignature; + return { + address: result.getAddress() || "", + signature: "0x" + core.toHexString(result.getSignature_asU8()), + }; +} + /** - * Supports EIP-712 eth_signTypedData_v4 - * https://docs.metamask.io/wallet/how-to/sign-data/#use-eth_signtypeddata_v4 - * Due to lack of firmware support, a hashed version of the data is - * displayed to the user on the device when signing + * Supports EIP-712 eth_signTypedData_v4. + * + * x402's EIP-3009 TransferWithAuthorization uses the firmware's structured + * endpoint so the device hashes and displays the actual payment fields. + * Other typed data keeps the legacy hash path, which firmware protects with + * the AdvancedMode blind-signing gate. */ export async function ethSignTypedData( transport: Transport, @@ -626,12 +715,17 @@ export async function ethSignTypedData( ): Promise { try { const EIP_712_DOMAIN = "EIP712Domain"; - const { primaryType, domain, message } = msg.typedData; + const typedData = withEip712DomainType(msg.typedData); + const { primaryType, domain, message } = typedData; + + if (isX402Eip3009(typedData)) { + return await signStructuredEip712(transport, msg.addressNList, typedData); + } // eip-712 getStructHash is a 1:1 byte-identical replacement for // @metamask/eth-sig-util TypedDataUtils.hashStruct(..., V4) — verified across // nested-struct, struct-array (V4) and Permit2 payloads — and drops the heavy // @ethereumjs@4/@metamask-utils nested tree (Windows MAX_PATH risk). - const domainSeparatorHash: Uint8Array = getStructHash(msg.typedData, EIP_712_DOMAIN, domain); + const domainSeparatorHash: Uint8Array = getStructHash(typedData, EIP_712_DOMAIN, domain); const ethereumSignTypedHash = new Ethereum.EthereumSignTypedHash(); ethereumSignTypedHash.setAddressNList(msg.addressNList); @@ -641,7 +735,7 @@ export async function ethSignTypedData( // If "EIP712Domain" is the primaryType, messageHash is not required - look at T1 connect impl ;) // todo: the firmware should define messageHash as an optional Uint8Array field for this case if (primaryType !== EIP_712_DOMAIN) { - messageHash = getStructHash(msg.typedData, primaryType, message); + messageHash = getStructHash(typedData, primaryType, message); ethereumSignTypedHash.setMessageHash(messageHash); } diff --git a/packages/hdwallet-keepkey/src/solana-x402.test.ts b/packages/hdwallet-keepkey/src/solana-x402.test.ts new file mode 100644 index 00000000..ffd4271a --- /dev/null +++ b/packages/hdwallet-keepkey/src/solana-x402.test.ts @@ -0,0 +1,93 @@ +import * as jspb from "google-protobuf"; + +import { SolanaSignedTx, solanaSignTx } from "./solana"; + +const SOLANA_SIGN_TX = 752; +const SOLANA_SIGNED_TX = 753; +const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000]; +const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const PAY_TO = "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB"; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("Solana x402 display metadata", () => { + it("forwards token metadata in field 4 and payTo owner in field 12", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation((messageType: number, msg: jspb.Message) => { + expect(messageType).toBe(SOLANA_SIGN_TX); + const reader = new jspb.BinaryReader((msg as any).serializeBinary()); + let mint: Uint8Array | undefined; + let symbol: string | undefined; + let decimals: number | undefined; + let owner: Uint8Array | undefined; + + while (reader.nextField()) { + if (reader.isEndGroup()) break; + if (reader.getFieldNumber() === 4) { + const nested = new jspb.BinaryReader(reader.readBytes()); + while (nested.nextField()) { + if (nested.isEndGroup()) break; + switch (nested.getFieldNumber()) { + case 1: + mint = nested.readBytes(); + break; + case 2: + symbol = nested.readString(); + break; + case 3: + decimals = nested.readUint32(); + break; + default: + nested.skipField(); + } + } + } else if (reader.getFieldNumber() === 12) { + owner = reader.readBytes(); + } else { + reader.skipField(); + } + } + + expect(mint).toHaveLength(32); + expect(symbol).toBe("USDC"); + expect(decimals).toBe(6); + expect(owner).toHaveLength(32); + + const response = new SolanaSignedTx(); + response.setSignature(new Uint8Array(64).fill(0x42)); + return Promise.resolve({ + message_enum: SOLANA_SIGNED_TX, + message_type: "SolanaSignedTx", + proto: response, + }); + }) + ); + + const result = await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + tokenInfo: [{ mint: USDC_MINT, symbol: "USDC", decimals: 6 }], + tokenRecipientOwners: [PAY_TO], + }); + expect(result.signature).toHaveLength(64); + }); + + it("rejects a malformed recipient owner before device transport", async () => { + const call = jest.fn(); + const transport = makeMockTransport(call); + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + tokenRecipientOwners: [new Uint8Array(31)], + }) + ).rejects.toThrow("exactly 32 bytes"); + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 49aef5ea..017199f2 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; import * as core from "@keepkey/hdwallet-core"; +import * as bs58 from "bs58"; import * as jspb from "google-protobuf"; import { Transport } from "./transport"; @@ -21,6 +22,26 @@ function toBytes(value: Uint8Array | string): Uint8Array { : Uint8Array.from(Buffer.from(value, "base64")); } +function toSolanaPubkey(value: Uint8Array | string, label: string): Uint8Array { + let bytes: Uint8Array; + if (value instanceof Uint8Array) { + bytes = value; + } else if (/^[0-9a-fA-F]{64}$/.test(value)) { + bytes = core.fromHexString(value); + } else { + try { + const decoded = bs58.decode(value); + bytes = Uint8Array.from(decoded); + } catch (_e) { + bytes = Uint8Array.from(Buffer.from(value, "base64")); + } + } + if (bytes.length !== 32) { + throw new Error(`${label} must decode to exactly 32 bytes, got ${bytes.length}`); + } + return bytes; +} + function encodeVarint(value: number): number[] { if (!Number.isInteger(value) || value < 0) { throw new Error(`varint must be a non-negative integer, got ${value}`); @@ -57,6 +78,31 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array { return out; } +function encodeSolanaTokenInfo(info: core.SolanaTokenInfo): Uint8Array { + const fields: Uint8Array[] = [encodeLengthDelimited(1, toSolanaPubkey(info.mint, "token mint"))]; + if (info.symbol !== undefined) { + const symbol = Uint8Array.from(Buffer.from(info.symbol, "utf8")); + if (symbol.length === 0 || symbol.length > 12) { + throw new Error(`token symbol must contain 1-12 UTF-8 bytes, got ${symbol.length}`); + } + fields.push(encodeLengthDelimited(2, symbol)); + } + if (info.decimals !== undefined) { + fields.push(encodeVarintField(3, info.decimals)); + } + if (info.signature !== undefined) { + const signature = toBytes(info.signature); + if (signature.length !== 64) { + throw new Error(`token metadata signature must be 64 bytes, got ${signature.length}`); + } + fields.push(encodeLengthDelimited(4, signature)); + } + if (info.signerKeyId !== undefined) { + fields.push(encodeVarintField(5, info.signerKeyId)); + } + return concatBytes(...fields); +} + /** * Wrap a jspb message so serializeBinary() yields the original encoding plus * `extra`. Transport.call() only ever calls serializeBinary(), so a duck-typed @@ -1130,26 +1176,29 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) } /* - * KKSOLSC1 schema fields (SolanaSignTx 9/10/11) are appended at the wire - * level rather than through generated setters: the published - * @keepkey/device-protocol build predates them, so setSchemaPayload() and - * friends do not exist. Protobuf makes this safe and lossless — encoded - * fields are order-independent and simply concatenate, and firmware's - * nanopb decoder reads them by field number exactly as if the generator - * had emitted them. Drop this shim once a device-protocol release carries - * the fields and the setters appear. + * Additive SolanaSignTx fields are appended at the wire level because this + * file intentionally carries a small jspb compatibility shim. Protobuf + * fields are order-independent, and firmware's nanopb decoder reads the + * same canonical field numbers emitted by device-protocol. */ - let outbound: jspb.Message = signTx; + const extraFields: Uint8Array[] = []; + for (const tokenInfo of msg.tokenInfo || []) { + extraFields.push(encodeLengthDelimited(4, encodeSolanaTokenInfo(tokenInfo))); + } if (msg.schema) { const payload = toBytes(msg.schema.payload); const signature = toBytes(msg.schema.signature); - const extra = concatBytes( + extraFields.push( encodeLengthDelimited(9, payload), encodeLengthDelimited(10, signature), encodeVarintField(11, msg.schema.signerKeyId) ); - outbound = withAppendedFields(signTx, extra); } + for (const owner of msg.tokenRecipientOwners || []) { + extraFields.push(encodeLengthDelimited(12, toSolanaPubkey(owner, "token recipient owner"))); + } + const outbound: jspb.Message = + extraFields.length > 0 ? withAppendedFields(signTx, concatBytes(...extraFields)) : signTx; const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT, From 73884b54faf94f9f4f6f72a3b3f09ae766511e5e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 17:31:16 -0300 Subject: [PATCH 14/17] fix: satisfy x402 CI lint gates --- .../src/ethereum-x402.test.ts | 48 +++++++++++-------- packages/hdwallet-keepkey/src/ethereum.ts | 10 ++-- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts index 16b2da28..c1550f94 100644 --- a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts +++ b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts @@ -15,8 +15,9 @@ function makeMockTransport(call: jest.Mock) { describe("x402 EVM structured signing", () => { it("sends the official EIP-3009 authorization as reviewed domain + message", async () => { + const streamed: Array<{ phase: number; data: any }> = []; const call = jest.fn().mockImplementation((_messageType: number, request: Ethereum.Ethereum712TypesValues) => { - const phase = request.getEip712typevals(); + const phase = request.getEip712typevals() ?? 0; expect(_messageType).toBe(ETHEREUM_712_TYPES_VALUES); expect(JSON.parse(request.getEip712primetype() || "{}")).toEqual({ primaryType: "TransferWithAuthorization", @@ -29,24 +30,7 @@ describe("x402 EVM structured signing", () => { { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ]); - - if (phase === 1) { - expect(JSON.parse(request.getEip712data() || "{}").domain).toEqual({ - name: "USDC", - version: "2", - chainId: 84532, - verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - }); - } else { - expect(JSON.parse(request.getEip712data() || "{}").message).toEqual({ - from: "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", - to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", - value: "2000", - validAfter: "0", - validBefore: "2000000000", - nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", - }); - } + streamed.push({ phase, data: JSON.parse(request.getEip712data() || "{}") }); const response = new Ethereum.EthereumTypedDataSignature(); response.setAddress("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); @@ -88,6 +72,32 @@ describe("x402 EVM structured signing", () => { }); expect(call).toHaveBeenCalledTimes(2); + expect(streamed).toEqual([ + { + phase: 1, + data: { + domain: { + name: "USDC", + version: "2", + chainId: 84532, + verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }, + }, + }, + { + phase: 2, + data: { + message: { + from: "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + value: "2000", + validAfter: "0", + validBefore: "2000000000", + nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", + }, + }, + }, + ]); expect(result.address).toBe("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); expect(result.signature).toBe("0x" + "42".repeat(65)); }); diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 76718d17..86c4eb00 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -624,7 +624,7 @@ const EIP3009_TRANSFER_WITH_AUTHORIZATION = [ ] as const; function typedDataJson(value: unknown): string { - return JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item); + return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item)); } function withEip712DomainType(typedData: any): any { @@ -684,11 +684,9 @@ async function signStructuredEip712( // Firmware computes and retains the domain separator, then combines it with // the independently reviewed message hash in the second request. - await transport.call( - Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, - request(domainJson, 1), - { msgTimeout: core.LONG_TIMEOUT } - ); + await transport.call(Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, request(domainJson, 1), { + msgTimeout: core.LONG_TIMEOUT, + }); const response = await transport.call( Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, request(messageJson, 2), From a24617b84d9c5b52ee0068a4c8f2973142af1c93 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 17:40:46 -0300 Subject: [PATCH 15/17] fix: serialize structured EIP-712 signing --- .../src/ethereum-x402.test.ts | 10 ++- packages/hdwallet-keepkey/src/ethereum.ts | 76 ++++++++++--------- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts index c1550f94..0466402b 100644 --- a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts +++ b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts @@ -9,7 +9,7 @@ function makeMockTransport(call: jest.Mock) { return { debugLink: false, call, - lockDuring: (fn: () => Promise) => fn(), + lockDuring: jest.fn((fn: () => Promise) => fn()), } as any; } @@ -38,7 +38,8 @@ describe("x402 EVM structured signing", () => { return Promise.resolve({ proto: response }); }); - const result = await ethSignTypedData(makeMockTransport(call), { + const transport = makeMockTransport(call); + const result = await ethSignTypedData(transport, { addressNList: PATH, typedData: { // The official x402 client supplies only the authorization type; the @@ -72,6 +73,11 @@ describe("x402 EVM structured signing", () => { }); expect(call).toHaveBeenCalledTimes(2); + expect(transport.lockDuring).toHaveBeenCalledTimes(1); + expect(call.mock.calls.map(([, , options]) => options)).toEqual([ + { msgTimeout: expect.any(Number), omitLock: true }, + { msgTimeout: expect.any(Number), omitLock: true }, + ]); expect(streamed).toEqual([ { phase: 1, diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 86c4eb00..3ee43b75 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -686,11 +686,12 @@ async function signStructuredEip712( // the independently reviewed message hash in the second request. await transport.call(Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, request(domainJson, 1), { msgTimeout: core.LONG_TIMEOUT, + omitLock: true, }); const response = await transport.call( Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, request(messageJson, 2), - { msgTimeout: core.LONG_TIMEOUT } + { msgTimeout: core.LONG_TIMEOUT, omitLock: true } ); const result = response.proto as Ethereum.EthereumTypedDataSignature; return { @@ -712,46 +713,47 @@ export async function ethSignTypedData( msg: core.ETHSignTypedData ): Promise { try { - const EIP_712_DOMAIN = "EIP712Domain"; - const typedData = withEip712DomainType(msg.typedData); - const { primaryType, domain, message } = typedData; + return await transport.lockDuring(async () => { + const EIP_712_DOMAIN = "EIP712Domain"; + const typedData = withEip712DomainType(msg.typedData); + const { primaryType, domain, message } = typedData; - if (isX402Eip3009(typedData)) { - return await signStructuredEip712(transport, msg.addressNList, typedData); - } - // eip-712 getStructHash is a 1:1 byte-identical replacement for - // @metamask/eth-sig-util TypedDataUtils.hashStruct(..., V4) — verified across - // nested-struct, struct-array (V4) and Permit2 payloads — and drops the heavy - // @ethereumjs@4/@metamask-utils nested tree (Windows MAX_PATH risk). - const domainSeparatorHash: Uint8Array = getStructHash(typedData, EIP_712_DOMAIN, domain); - - const ethereumSignTypedHash = new Ethereum.EthereumSignTypedHash(); - ethereumSignTypedHash.setAddressNList(msg.addressNList); - ethereumSignTypedHash.setDomainSeparatorHash(domainSeparatorHash); - - let messageHash: Uint8Array | undefined = undefined; - // If "EIP712Domain" is the primaryType, messageHash is not required - look at T1 connect impl ;) - // todo: the firmware should define messageHash as an optional Uint8Array field for this case - if (primaryType !== EIP_712_DOMAIN) { - messageHash = getStructHash(typedData, primaryType, message); - ethereumSignTypedHash.setMessageHash(messageHash); - } - - const response = await transport.call( - Messages.MessageType.MESSAGETYPE_ETHEREUMSIGNTYPEDHASH, - ethereumSignTypedHash, - { - msgTimeout: core.LONG_TIMEOUT, + if (isX402Eip3009(typedData)) { + return signStructuredEip712(transport, msg.addressNList, typedData); + } + // eip-712 getStructHash is a 1:1 byte-identical replacement for + // @metamask/eth-sig-util TypedDataUtils.hashStruct(..., V4) — verified across + // nested-struct, struct-array (V4) and Permit2 payloads — and drops the heavy + // @ethereumjs@4/@metamask-utils nested tree (Windows MAX_PATH risk). + const domainSeparatorHash: Uint8Array = getStructHash(typedData, EIP_712_DOMAIN, domain); + + const ethereumSignTypedHash = new Ethereum.EthereumSignTypedHash(); + ethereumSignTypedHash.setAddressNList(msg.addressNList); + ethereumSignTypedHash.setDomainSeparatorHash(domainSeparatorHash); + + let messageHash: Uint8Array | undefined = undefined; + // If "EIP712Domain" is the primaryType, messageHash is not required - look at T1 connect impl ;) + // todo: the firmware should define messageHash as an optional Uint8Array field for this case + if (primaryType !== EIP_712_DOMAIN) { + messageHash = getStructHash(typedData, primaryType, message); + ethereumSignTypedHash.setMessageHash(messageHash); } - ); - const result = response.proto as Ethereum.EthereumTypedDataSignature; - const res: core.ETHSignedTypedData = { - address: result.getAddress() || "", - signature: "0x" + core.toHexString(result.getSignature_asU8()), - }; + const response = await transport.call( + Messages.MessageType.MESSAGETYPE_ETHEREUMSIGNTYPEDHASH, + ethereumSignTypedHash, + { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + } + ); - return res; + const result = response.proto as Ethereum.EthereumTypedDataSignature; + return { + address: result.getAddress() || "", + signature: "0x" + core.toHexString(result.getSignature_asU8()), + }; + }); } catch (error) { console.error({ error }); throw new Error("Failed to sign typed ETH message"); From 5785ccfb6471bfdd2c23a275be2b609e86a4ec5f Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 20:46:53 -0300 Subject: [PATCH 16/17] chore: pin portable canonical device protocol --- packages/hdwallet-keepkey/package.json | 2 +- yarn.lock | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 79141034..0a8317c9 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.17.0", + "@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/yarn.lock b/yarn.lock index 7ac797ca..a562ae5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,12 +1151,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@npm:@bithighlander/device-protocol@7.17.0": - version "7.17.0" - resolved "https://registry.yarnpkg.com/@bithighlander/device-protocol/-/device-protocol-7.17.0.tgz#23450420224f25475b336687504594d67496b120" - integrity sha512-Nw7hb5ZTqEHyOWvrdb8o7rvT+H79X9EJHR9O7VIv0xQ2711o0Q3Dga8YSYW21MWU7wBoNQ87NcJ9XLEKW+gdJQ== +"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8": + version "7.14.1" + resolved "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8" dependencies: - google-protobuf "^3.7.0-rc.2" + google-protobuf "3.21.4" pbjs "^0.0.5" "@keepkey/proto-tx-builder@^0.9.1": @@ -7000,7 +6999,7 @@ globby@^11.0.2, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" -google-protobuf@^3.15.8, google-protobuf@^3.17.0, google-protobuf@^3.7.0-rc.2: +google-protobuf@3.21.4, google-protobuf@^3.15.8, google-protobuf@^3.17.0: version "3.21.4" resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.21.4.tgz#2f933e8b6e5e9f8edde66b7be0024b68f77da6c9" integrity sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ== From 0572619c19934ad225fb14f28045c948ae480e05 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 3 Aug 2026 01:20:21 -0300 Subject: [PATCH 17/17] feat(bitcoin): add KeepKey Taproot host support --- .../hdwallet-core/src/bitcoin-taproot.test.ts | 25 ++++ packages/hdwallet-core/src/bitcoin.ts | 25 +++- packages/hdwallet-keepkey/package.json | 2 +- packages/hdwallet-keepkey/src/bitcoin.ts | 24 +++- packages/hdwallet-keepkey/src/keepkey.ts | 11 +- packages/hdwallet-keepkey/src/taproot.test.ts | 132 ++++++++++++++++++ packages/hdwallet-keepkey/src/utils.ts | 4 + yarn.lock | 4 +- 8 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 packages/hdwallet-core/src/bitcoin-taproot.test.ts create mode 100644 packages/hdwallet-keepkey/src/taproot.test.ts diff --git a/packages/hdwallet-core/src/bitcoin-taproot.test.ts b/packages/hdwallet-core/src/bitcoin-taproot.test.ts new file mode 100644 index 00000000..d20007ba --- /dev/null +++ b/packages/hdwallet-core/src/bitcoin-taproot.test.ts @@ -0,0 +1,25 @@ +import { BTCInputScriptType, describeUTXOPath, taprootAccount } from "./bitcoin"; + +describe("Bitcoin Taproot paths", () => { + it("describes a BIP-86 account as Taproot", () => { + const account = taprootAccount("Bitcoin", 0, 7); + expect(account).toEqual({ + coin: "Bitcoin", + scriptType: BTCInputScriptType.SpendTaproot, + addressNList: [0x80000000 + 86, 0x80000000, 0x80000000 + 7], + }); + expect(describeUTXOPath(account.addressNList, "Bitcoin", BTCInputScriptType.SpendTaproot)).toMatchObject({ + coin: "Bitcoin", + accountIdx: 7, + wholeAccount: true, + isKnown: true, + scriptType: BTCInputScriptType.SpendTaproot, + verbose: "Bitcoin Account #7 (Taproot)", + }); + }); + + it("does not describe BIP-86 with another script type", () => { + const path = [0x80000000 + 86, 0x80000000, 0x80000000]; + expect(describeUTXOPath(path, "Bitcoin", BTCInputScriptType.SpendWitness).isKnown).toBe(false); + }); +}); diff --git a/packages/hdwallet-core/src/bitcoin.ts b/packages/hdwallet-core/src/bitcoin.ts index 1c76d957..4b3cd1b6 100644 --- a/packages/hdwallet-core/src/bitcoin.ts +++ b/packages/hdwallet-core/src/bitcoin.ts @@ -76,7 +76,7 @@ type BTCSignTxInputNativeBase = BTCSignTxInputBase & { }; type BTCSignTxInputNativeSegwitBase = BTCSignTxInputNativeBase & { - scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness; + scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.SpendTaproot; }; type BTCSignTxInputNativeSegwitWithHex = BTCSignTxInputNativeSegwitBase & { @@ -106,7 +106,11 @@ type BTCSignTxInputKKBase = BTCSignTxInputBase & { }; type BTCSignTxInputKKSegwit = BTCSignTxInputKKBase & { - scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.External; + scriptType: + | BTCInputScriptType.SpendWitness + | BTCInputScriptType.SpendP2SHWitness + | BTCInputScriptType.SpendTaproot + | BTCInputScriptType.External; hex?: string; }; @@ -231,6 +235,7 @@ export enum BTCInputScriptType { External = "external", SpendWitness = "p2wpkh", SpendP2SHWitness = "p2sh-p2wpkh", + SpendTaproot = "p2tr", } export enum BTCOutputScriptType { @@ -239,6 +244,7 @@ export enum BTCOutputScriptType { Bech32 = "bech32", PayToWitness = "p2wpkh", PayToP2SHWitness = "p2sh-p2wpkh", + PayToTaproot = "p2tr", // device-derived change only } export enum BTCOutputAddressType { @@ -362,12 +368,16 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp const purpose = path[0] & 0x7fffffff; - if (![44, 49, 84].includes(purpose)) return unknown; + if (![44, 49, 84, 86].includes(purpose)) return unknown; if (purpose === 44 && scriptType !== BTCInputScriptType.SpendAddress) return unknown; if (purpose === 49 && scriptType !== BTCInputScriptType.SpendP2SHWitness) return unknown; + if (purpose === 84 && scriptType !== BTCInputScriptType.SpendWitness) return unknown; + + if (purpose === 86 && scriptType !== BTCInputScriptType.SpendTaproot) return unknown; + const wholeAccount = path.length === 3; const script = ( @@ -376,6 +386,7 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp [BTCInputScriptType.SpendP2SHWitness]: [], [BTCInputScriptType.SpendWitness]: ["Segwit"], [BTCInputScriptType.Bech32]: ["Segwit Native"], + [BTCInputScriptType.SpendTaproot]: ["Taproot"], } as Partial> )[scriptType]; @@ -471,3 +482,11 @@ export function segwitNativeAccount(coin: Coin, slip44: number, accountIdx: numb addressNList: [0x80000000 + 84, 0x80000000 + slip44, 0x80000000 + accountIdx], }; } + +export function taprootAccount(coin: Coin, slip44: number, accountIdx: number): BTCAccountPath { + return { + coin, + scriptType: BTCInputScriptType.SpendTaproot, + addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx], + }; +} diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 0a8317c9..93e3f263 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8", + "@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/packages/hdwallet-keepkey/src/bitcoin.ts b/packages/hdwallet-keepkey/src/bitcoin.ts index 719a199e..4a5dfdf6 100644 --- a/packages/hdwallet-keepkey/src/bitcoin.ts +++ b/packages/hdwallet-keepkey/src/bitcoin.ts @@ -26,6 +26,7 @@ const supportedCoins = [ ]; const segwitCoins = ["Bitcoin", "Testnet", "BitcoinGold", "Litecoin"]; +const taprootCoins = ["Bitcoin", "Testnet"]; function legacyAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath { return { @@ -51,6 +52,14 @@ function segwitNativeAccount(coin: core.Coin, slip44: number, accountIdx: number }; } +function taprootAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath { + return { + coin, + scriptType: core.BTCInputScriptType.SpendTaproot, + addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx], + }; +} + function packVarint(n: number): string { if (n < 253) return n.toString(16).padStart(2, "0"); else if (n < 0xffff) return "FD" + n.toString(16).padStart(4, "0"); @@ -120,6 +129,7 @@ function prepareSignTx( if ( inputTx.scriptType === core.BTCInputScriptType.SpendP2SHWitness || inputTx.scriptType === core.BTCInputScriptType.SpendWitness || + inputTx.scriptType === core.BTCInputScriptType.SpendTaproot || inputTx.scriptType === core.BTCInputScriptType.External ) return; @@ -248,6 +258,7 @@ export async function btcSupportsScriptType(coin: core.Coin, scriptType?: core.B if (!supportedCoins.includes(coin)) return false; if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendP2SHWitness) return false; if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendWitness) return false; + if (!taprootCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendTaproot) return false; return true; } @@ -546,6 +557,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array = ( { - Bitcoin: [bip44, bip49, bip84], + Bitcoin: [bip44, bip49, bip84, bip86], Litecoin: [bip44, bip49, bip84], Dash: [bip44], DigiByte: [bip44, bip49, bip84], Dogecoin: [bip44], - Testnet: [bip44, bip49, bip84], + Testnet: [bip44, bip49, bip84, bip86], BitcoinCash: [bip44, btcLegacy], BitcoinSV: [bip44, bchLegacy, btcLegacy], BitcoinGold: [bip44, bip49, bip84, btcLegacy, btcSegwit, btcSegwitNative], @@ -581,7 +593,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array): boolean { if (msg.length < 1) return false; - if (msg.length > 3) return false; + if (msg.length > 4) return false; const account0 = msg[0]; if (account0.addressNList.length != 3) return false; @@ -592,6 +604,7 @@ export function btcIsSameAccount(msg: Array): boolean { [core.BTCInputScriptType.SpendAddress]: 0x80000000 + 44, [core.BTCInputScriptType.SpendP2SHWitness]: 0x80000000 + 49, [core.BTCInputScriptType.SpendWitness]: 0x80000000 + 84, + [core.BTCInputScriptType.SpendTaproot]: 0x80000000 + 86, } as Partial>; if (purposeForScriptType[account0.scriptType] !== purpose) return false; @@ -604,12 +617,13 @@ export function btcIsSameAccount(msg: Array): boolean { if (idx < 0x80000000) return false; // Accounts must have the same SLIP44 and Account Idx, but may have differing - // purpose fields (so long as they're BIP44/BIP49/BIP84) + // purpose fields (so long as they're BIP44/BIP49/BIP84/BIP86) if ( msg.find((path) => { if (path.addressNList.length != 3) return true; - if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84].includes(path.addressNList[0])) return true; + if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84, 0x80000000 + 86].includes(path.addressNList[0])) + return true; if (purposeForScriptType[path.scriptType] !== path.addressNList[0]) return true; diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index 7c775c1d..ddfb3446 100644 --- a/packages/hdwallet-keepkey/src/keepkey.ts +++ b/packages/hdwallet-keepkey/src/keepkey.ts @@ -80,7 +80,7 @@ function describeUTXOPath( const purpose = path[0] & 0x7fffffff; - if (![44, 49, 84].includes(purpose)) return unknown; + if (![44, 49, 84, 86].includes(purpose)) return unknown; if (purpose === 44 && scriptType !== core.BTCInputScriptType.SpendAddress) return unknown; @@ -88,6 +88,8 @@ function describeUTXOPath( if (purpose === 84 && scriptType !== core.BTCInputScriptType.SpendWitness) return unknown; + if (purpose === 86 && scriptType !== core.BTCInputScriptType.SpendTaproot) return unknown; + const wholeAccount = path.length === 3; const script = scriptType @@ -96,6 +98,7 @@ function describeUTXOPath( [core.BTCInputScriptType.SpendAddress]: ["Legacy"], [core.BTCInputScriptType.SpendP2SHWitness]: [], [core.BTCInputScriptType.SpendWitness]: ["Segwit Native"], + [core.BTCInputScriptType.SpendTaproot]: ["Taproot"], } as Partial> )[scriptType] ?? [] : []; @@ -1328,7 +1331,11 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW } public async btcSupportsScriptType(coin: core.Coin, scriptType: core.BTCInputScriptType): Promise { - return this.info.btcSupportsScriptType(coin, scriptType); + const supportedByAdapter = await this.info.btcSupportsScriptType(coin, scriptType); + if (!supportedByAdapter || scriptType !== core.BTCInputScriptType.SpendTaproot) return supportedByAdapter; + + const features = await this.getFeatures(/*cached=*/ true); + return features.supportsTaproot === true; } public async btcGetAddress(msg: core.BTCGetAddress): Promise { diff --git a/packages/hdwallet-keepkey/src/taproot.test.ts b/packages/hdwallet-keepkey/src/taproot.test.ts new file mode 100644 index 00000000..c5942687 --- /dev/null +++ b/packages/hdwallet-keepkey/src/taproot.test.ts @@ -0,0 +1,132 @@ +import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; +import * as Types from "@keepkey/device-protocol/lib/types_pb"; +import * as core from "@keepkey/hdwallet-core"; + +import { btcGetAccountPaths, btcGetAddress, btcIsSameAccount, btcSignTx, btcSupportsScriptType } from "./bitcoin"; +import { KeepKeyHDWallet } from "./keepkey"; +import { translateInputScriptType, translateOutputScriptType } from "./utils"; + +const BIP86_ACCOUNT = [0x80000000 + 86, 0x80000000, 0x80000000]; +const BIP86_ADDRESS = [0x80000000 + 86, 0x80000000, 0x80000000, 0, 0]; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("KeepKey Taproot host support", () => { + it("maps the core P2TR types to the canonical device protocol enums", () => { + expect(translateInputScriptType(core.BTCInputScriptType.SpendTaproot)).toBe(Types.InputScriptType.SPENDTAPROOT); + expect(translateOutputScriptType(core.BTCOutputScriptType.PayToTaproot)).toBe(Types.OutputScriptType.PAYTOTAPROOT); + }); + + it("offers BIP-86 only for Bitcoin networks and recognizes all four Bitcoin account paths", async () => { + const paths = btcGetAccountPaths({ coin: "Bitcoin", accountIdx: 0 }); + expect(paths).toContainEqual({ + coin: "Bitcoin", + scriptType: core.BTCInputScriptType.SpendTaproot, + addressNList: BIP86_ACCOUNT, + }); + expect(btcIsSameAccount(paths)).toBe(true); + + expect( + btcGetAccountPaths({ coin: "Litecoin", accountIdx: 0, scriptType: core.BTCInputScriptType.SpendTaproot }) + ).toEqual([]); + await expect(btcSupportsScriptType("Litecoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(false); + await expect(btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(true); + }); + + it("serializes a displayed BIP-86 address request as SPENDTAPROOT", async () => { + const call = jest.fn().mockImplementation((messageType: number, msg: Messages.GetAddress) => { + expect(messageType).toBe(Messages.MessageType.MESSAGETYPE_GETADDRESS); + expect(msg.getAddressNList()).toEqual(BIP86_ADDRESS); + expect(msg.getCoinName()).toBe("Bitcoin"); + expect(msg.getShowDisplay()).toBe(true); + expect(msg.getScriptType()).toBe(Types.InputScriptType.SPENDTAPROOT); + + const response = new Messages.Address(); + response.setAddress("bc1ptest"); + return Promise.resolve({ proto: response }); + }); + const wallet = { btcSupportsCoin: jest.fn().mockResolvedValue(true) } as any; + + await expect( + btcGetAddress(wallet, makeMockTransport(call), { + coin: "Bitcoin", + addressNList: BIP86_ADDRESS, + showDisplay: true, + scriptType: core.BTCInputScriptType.SpendTaproot, + }) + ).resolves.toBe("bc1ptest"); + }); + + it("requires the firmware-reported supports_taproot capability", async () => { + const supported = new KeepKeyHDWallet( + makeMockTransport(jest.fn().mockResolvedValue({ message: { supportsTaproot: true } })) + ); + const unsupported = new KeepKeyHDWallet( + makeMockTransport(jest.fn().mockResolvedValue({ message: { supportsTaproot: false } })) + ); + + await expect(supported.btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(true); + await expect(unsupported.btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe( + false + ); + await expect(supported.btcSupportsScriptType("Litecoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe( + false + ); + }); + + it("sends a P2TR input amount without demanding a legacy previous transaction", async () => { + let capturedInput: Types.TxInputType | undefined; + const call = jest.fn().mockImplementation((messageType: number, msg: any) => { + if (messageType === Messages.MessageType.MESSAGETYPE_SIGNTX) { + const details = new Types.TxRequestDetailsType(); + details.setRequestIndex(0); + const request = new Messages.TxRequest(); + request.setRequestType(Types.RequestType.TXINPUT); + request.setDetails(details); + return Promise.resolve({ + message_enum: Messages.MessageType.MESSAGETYPE_TXREQUEST, + proto: request, + }); + } + + expect(messageType).toBe(Messages.MessageType.MESSAGETYPE_TXACK); + capturedInput = (msg as Messages.TxAck).getTx()?.getInputsList()[0]; + + const serialized = new Types.TxRequestSerializedType(); + serialized.setSignatureIndex(0); + serialized.setSignature(new Uint8Array(64).fill(0x42)); + const finished = new Messages.TxRequest(); + finished.setRequestType(Types.RequestType.TXFINISHED); + finished.setSerialized(serialized); + return Promise.resolve({ + message_enum: Messages.MessageType.MESSAGETYPE_TXREQUEST, + proto: finished, + }); + }); + const wallet = { btcSupportsCoin: jest.fn().mockResolvedValue(true) } as any; + + const result = await btcSignTx(wallet, makeMockTransport(call), { + coin: "Bitcoin", + inputs: [ + { + txid: "11".repeat(32), + vout: 0, + addressNList: BIP86_ADDRESS, + amount: "100000", + scriptType: core.BTCInputScriptType.SpendTaproot, + }, + ], + outputs: [{ address: "1BitcoinEaterAddressDontSendf59kuE", amount: "90000" }], + }); + + expect(capturedInput?.getScriptType()).toBe(Types.InputScriptType.SPENDTAPROOT); + expect(capturedInput?.getAmount()).toBe(100000); + expect(result.signatures).toEqual(["42".repeat(64)]); + }); +}); diff --git a/packages/hdwallet-keepkey/src/utils.ts b/packages/hdwallet-keepkey/src/utils.ts index e4159b00..5ca6b67e 100644 --- a/packages/hdwallet-keepkey/src/utils.ts +++ b/packages/hdwallet-keepkey/src/utils.ts @@ -45,6 +45,8 @@ export function translateInputScriptType(scriptType: core.BTCInputScriptType): a return Types.InputScriptType.SPENDP2SHWITNESS; case core.BTCInputScriptType.SpendWitness: return Types.InputScriptType.SPENDWITNESS; + case core.BTCInputScriptType.SpendTaproot: + return Types.InputScriptType.SPENDTAPROOT; } throw new Error("unhandled InputSriptType enum: " + scriptType); } @@ -59,6 +61,8 @@ export function translateOutputScriptType(scriptType: core.BTCOutputScriptType): return Types.OutputScriptType.PAYTOP2SHWITNESS; case core.BTCOutputScriptType.PayToWitness: return Types.OutputScriptType.PAYTOWITNESS; + case core.BTCOutputScriptType.PayToTaproot: + return Types.OutputScriptType.PAYTOTAPROOT; } throw new Error("unhandled OutputScriptType enum: " + scriptType); } diff --git a/yarn.lock b/yarn.lock index a562ae5d..418ce772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,9 +1151,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8": +"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879": version "7.14.1" - resolved "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8" + resolved "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879" dependencies: google-protobuf "3.21.4" pbjs "^0.0.5"