From e39ff8cb57990ffd9eb5e17f8f0a941f86214c91 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 25 Jun 2026 23:00:05 -0500 Subject: [PATCH 1/2] feat(thorchain): allow any denom in MsgSend (TCY, RUJI, secured assets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the hardcoded `denom !== 'rune'` rejection in the keepkey MsgSend signer and forward non-rune denoms to the device via the ThorchainMsgSend.denom proto field (device-protocol 7.16.0+). Firmware defaults an unset denom to 'rune', so denom is only set when it differs — existing RUNE sends keep byte-identical proto/signatures. TCY, RUJI/Rujira and secured assets (e.g. btc-btc) now sign as MsgSend. --- packages/hdwallet-keepkey/src/thorchain.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/hdwallet-keepkey/src/thorchain.ts b/packages/hdwallet-keepkey/src/thorchain.ts index e5944b2a..58841c9a 100644 --- a/packages/hdwallet-keepkey/src/thorchain.ts +++ b/packages/hdwallet-keepkey/src/thorchain.ts @@ -76,14 +76,15 @@ export async function thorchainSignTx(transport: Transport, msg: core.ThorchainS } const denom = m.value.amount[0].denom; - if (denom !== "rune") { - throw new Error("THORChain: Unsupported denomination: " + denom); - } const send = new ThorchainMessages.ThorchainMsgSend(); send.setFromAddress(m.value.from_address); send.setToAddress(m.value.to_address); send.setAmount(m.value.amount[0].amount); + // Firmware defaults an unset denom to "rune"; only set it for non-rune + // (TCY, RUJI/Rujira, secured assets like "btc-btc") so existing RUNE + // sends keep byte-identical proto/signatures. + if (denom && denom !== "rune") send.setDenom(denom); ack = new ThorchainMessages.ThorchainMsgAck(); ack.setSend(send); From 91a9e9d6796a3ad4c6b09744660412c31c3aa76d Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 18:22:38 -0500 Subject: [PATCH 2/2] refactor(eip712): replace @metamask/eth-sig-util with eip-712 for struct hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ethSignTypedData used @metamask/eth-sig-util's TypedDataUtils.hashStruct only to compute the domain separator + message hash sent to the firmware (one import, two call sites). eip-712's getStructHash is a byte-identical V4 replacement — verified across nested structs, V4 struct arrays (the one V3/V4 divergence) and the real Permit2 payload — and hdwallet-core already depends on eip-712. Drops @metamask/eth-sig-util + @metamask/utils (2.8MB) + its NESTED @ethereumjs@4/common chain (a Windows MAX_PATH deep-path offender) and a redundant second EIP-712 lib. Top-level @ethereumjs@3 (tx signing) is untouched. Signatures are unchanged (neutral to the open 7.14 EIP-712 signing incident — same hashes as before). Adds a known-answer regression test pinning getStructHash output. --- packages/hdwallet-keepkey/package.json | 2 +- .../src/eip712-struct-hash.test.ts | 148 ++++++++++++++++++ packages/hdwallet-keepkey/src/ethereum.ts | 13 +- 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 packages/hdwallet-keepkey/src/eip712-struct-hash.test.ts diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 567cfcc1..0cae5d61 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -20,12 +20,12 @@ "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.16.0", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", - "@metamask/eth-sig-util": "^7.0.0", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", "bignumber.js": "^9.0.1", "bs58": "^4.0.0", "bs58check": "^2.1.2", "crypto-js": "^4.0.0", + "eip-712": "^1.0.0", "eip55": "^2.1.0", "google-protobuf": "^3.15.8", "icepick": "^2.4.0", diff --git a/packages/hdwallet-keepkey/src/eip712-struct-hash.test.ts b/packages/hdwallet-keepkey/src/eip712-struct-hash.test.ts new file mode 100644 index 00000000..1f4861b0 --- /dev/null +++ b/packages/hdwallet-keepkey/src/eip712-struct-hash.test.ts @@ -0,0 +1,148 @@ +/** + * Known-answer regression test for the EIP-712 struct hashing used by + * ethSignTypedData (domain separator + message hash sent to the firmware). + * + * These expected hashes were captured from @metamask/eth-sig-util's + * TypedDataUtils.hashStruct(..., V4) and confirmed byte-identical to eip-712's + * getStructHash before eth-sig-util was removed (PR swapping the two). They pin + * the hashing so a future eip-712 bump that changes output fails loudly — the + * hashes are what the device signs, so any drift is a signing-correctness bug. + * + * Coverage spans the V3↔V4 divergence points: nested structs (Permit2, Mail), + * a struct array (Group → Person[], V4-only), and atomic array + dynamic bytes. + */ +import { getStructHash } from "eip-712"; + +const hex = (b: Uint8Array) => "0x" + Buffer.from(b).toString("hex"); +const PERSON = [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, +]; + +const VECTORS: Record = { + permit2: { + typedData: { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + PermitSingle: [ + { name: "details", type: "PermitDetails" }, + { name: "spender", type: "address" }, + { name: "sigDeadline", type: "uint256" }, + ], + PermitDetails: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint160" }, + { name: "expiration", type: "uint48" }, + { name: "nonce", type: "uint48" }, + ], + }, + primaryType: "PermitSingle", + domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" }, + message: { + details: { + token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + amount: "1000000000", + expiration: "1735689600", + nonce: "0", + }, + spender: "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + sigDeadline: "1735689600", + }, + }, + domain: "0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + message: "0x8c5e0f9cb48313e505efa2c4040320595098ae1eda6224aa7d007098fc2b5d08", + }, + mail: { + typedData: { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Person: PERSON, + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ], + }, + primaryType: "Mail", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" }, + to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" }, + contents: "Hello, Bob!", + }, + }, + domain: "0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f", + message: "0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e", + }, + // V4 struct array (Person[]) — the one place V3 and V4 diverge + group: { + typedData: { + types: { + EIP712Domain: [{ name: "name", type: "string" }], + Person: PERSON, + Group: [ + { name: "name", type: "string" }, + { name: "members", type: "Person[]" }, + ], + }, + primaryType: "Group", + domain: { name: "Grp" }, + message: { + name: "devs", + members: [ + { name: "A", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" }, + { name: "B", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" }, + ], + }, + }, + domain: "0x5b2099c3e940e571b07b7102866068899e43ab61378bdf48e0d2247fff37d83a", + message: "0x6e6d8c139490d2784993785a0eb5ed4f3077ada8dbc17fcb3d8f63939831bdac", + }, + // atomic array (uint256[]) + dynamic bytes + bytes32 + bool + atomics: { + typedData: { + types: { + EIP712Domain: [{ name: "name", type: "string" }], + Foo: [ + { name: "nums", type: "uint256[]" }, + { name: "data", type: "bytes" }, + { name: "hash", type: "bytes32" }, + { name: "flag", type: "bool" }, + ], + }, + primaryType: "Foo", + domain: { name: "Foo" }, + message: { + nums: ["1", "2", "340282366920938463463374607431768211455"], + data: "0xdeadbeef", + hash: "0x" + "ab".repeat(32), + flag: true, + }, + }, + domain: "0x1f1a0521ee8101ffc6bbe7c3921c3850ba35af8a5806dfb5a3c36301c441854e", + message: "0x1a1c1292140cb77ad00e71ab075674e1821444dc5eb5f4b5cc0b664ad35a37e8", + }, +}; + +describe("eip-712 getStructHash (EIP-712 V4) — known-answer", () => { + for (const [name, v] of Object.entries(VECTORS)) { + it(`${name}: domain separator + message hash match`, () => { + expect(hex(getStructHash(v.typedData, "EIP712Domain", v.typedData.domain))).toBe(v.domain); + expect(hex(getStructHash(v.typedData, v.typedData.primaryType, v.typedData.message))).toBe(v.message); + }); + } +}); diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 051a2fef..5e8cd254 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -4,7 +4,7 @@ import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; import * as Ethereum from "@keepkey/device-protocol/lib/messages-ethereum_pb"; import * as Types from "@keepkey/device-protocol/lib/types_pb"; import * as core from "@keepkey/hdwallet-core"; -import { SignTypedDataVersion, TypedDataUtils } from "@metamask/eth-sig-util"; +import { getStructHash } from "eip-712"; import * as eip55 from "eip55"; import * as jspb from "google-protobuf"; @@ -453,10 +453,13 @@ export async function ethSignTypedData( msg: core.ETHSignTypedData ): Promise { try { - const version = SignTypedDataVersion.V4; const EIP_712_DOMAIN = "EIP712Domain"; - const { types, primaryType, domain, message } = msg.typedData; - const domainSeparatorHash: Uint8Array = TypedDataUtils.hashStruct(EIP_712_DOMAIN, domain, types, version); + 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); @@ -466,7 +469,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 = TypedDataUtils.hashStruct(primaryType, message, types, version); + messageHash = getStructHash(msg.typedData, primaryType, message); ethereumSignTypedHash.setMessageHash(messageHash); }