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-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-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index 70fec107..e57db2ec 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -9,9 +9,44 @@ 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. */ + 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-nodewebusb/src/adapter.ts b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts index 51a0a7d1..4ae6a686 100644 --- a/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts +++ b/packages/hdwallet-keepkey-nodewebusb/src/adapter.ts @@ -10,11 +10,13 @@ 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 }, - ], + filters: [{ vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber }], }); if (out.serialNumber === undefined) throw new Error("expected serial number"); return out as Device; diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 0cae5d61..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": "npm:@bithighlander/device-protocol@7.16.0", + "@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/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-x402.test.ts b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts new file mode 100644 index 00000000..0466402b --- /dev/null +++ b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts @@ -0,0 +1,110 @@ +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: jest.fn((fn: () => Promise) => fn()), + } as any; +} + +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() ?? 0; + 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" }, + ]); + streamed.push({ phase, data: JSON.parse(request.getEip712data() || "{}") }); + + const response = new Ethereum.EthereumTypedDataSignature(); + response.setAddress("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); + response.setSignature(new Uint8Array(65).fill(0x42)); + return Promise.resolve({ proto: response }); + }); + + const transport = makeMockTransport(call); + const result = await ethSignTypedData(transport, { + 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(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, + 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 ed9fc2dd..3ee43b75 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({ @@ -515,12 +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()), }; + if (deviceSignedHash) result.deviceSignedHash = deviceSignedHash; + return result; }); } @@ -540,11 +556,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 +571,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, @@ -598,52 +614,146 @@ 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, + omitLock: true, + }); + const response = await transport.call( + Messages.MessageType.MESSAGETYPE_ETHEREUM712TYPESVALUES, + request(messageJson, 2), + { msgTimeout: core.LONG_TIMEOUT, omitLock: true } + ); + 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, msg: core.ETHSignTypedData ): Promise { try { - const EIP_712_DOMAIN = "EIP712Domain"; - const { primaryType, domain, message } = msg.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 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(msg.typedData, primaryType, message); - ethereumSignTypedHash.setMessageHash(messageHash); - } + return await transport.lockDuring(async () => { + const EIP_712_DOMAIN = "EIP712Domain"; + const typedData = withEip712DomainType(msg.typedData); + const { primaryType, domain, message } = typedData; - 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"); diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index 07eeaac6..ddfb3446 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"; @@ -79,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; @@ -87,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 @@ -95,6 +98,7 @@ function describeUTXOPath( [core.BTCInputScriptType.SpendAddress]: ["Legacy"], [core.BTCInputScriptType.SpendP2SHWitness]: [], [core.BTCInputScriptType.SpendWitness]: ["Segwit Native"], + [core.BTCInputScriptType.SpendTaproot]: ["Taproot"], } as Partial> )[scriptType] ?? [] : []; @@ -1327,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 { @@ -1376,9 +1384,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 +1397,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/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); diff --git a/packages/hdwallet-keepkey/src/ripple.ts b/packages/hdwallet-keepkey/src/ripple.ts index a94efb57..4ee14ae2 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, 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 a65e1015..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"; @@ -9,6 +10,116 @@ 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 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}`); + } + 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; +} + +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 + * 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 +309,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 +355,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 +402,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 +435,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 +468,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 +492,10 @@ export namespace SolanaSignTx { addressNList: number[]; coinName?: string; rawTx: Uint8Array | string; + swapMetadataPayload?: Uint8Array | string; + swapMetadataSignature?: Uint8Array | string; + swapMetadataSignerKeyId?: number; + allowOpaque?: boolean; }; } @@ -973,8 +1158,49 @@ 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); + } + + /* + * 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. + */ + 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); + extraFields.push( + encodeLengthDelimited(9, payload), + encodeLengthDelimited(10, signature), + encodeVarintField(11, msg.schema.signerKeyId) + ); + } + 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, signTx, { + const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT, omitLock: true, }); 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/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/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/packages/hdwallet-keepkey/src/zcash.test.ts b/packages/hdwallet-keepkey/src/zcash.test.ts index ad42d57b..21214220 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, @@ -84,7 +87,7 @@ const SHIELD_REQUEST = { rk: "dd".repeat(32), out_ciphertext: "ee".repeat(80), value: 0, - is_spend: true, + is_spend: false, }, ], }; @@ -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,30 +146,32 @@ 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)); 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, @@ -283,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); - // Orchard sigs returned, transparent sigs attached (from ZcashTransparentSigned) - expect(result).toHaveLength(2); + // Ironwood sigs returned, transparent sigs attached (from ZcashTransparentSigned) + expect(result).toHaveLength(0); expect(result._transparentSignatures).toHaveLength(1); }); @@ -384,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, @@ -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,14 +476,13 @@ 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[] = []; 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); @@ -492,7 +500,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); @@ -566,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 b32d06f3..5ee9fe63 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,9 @@ 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) { diff --git a/yarn.lock b/yarn.lock index c43deb2b..418ce772 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.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#674777f6d4dd16e2b8c4c2df10608976375ee879": + version "7.14.1" + resolved "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879" 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==