Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/hdwallet-core/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array | string>;
/** One-request opaque-signing authorization; does not mutate AdvancedMode. */
allowBlindSigning?: boolean;
/** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */
Expand Down
110 changes: 110 additions & 0 deletions packages/hdwallet-keepkey/src/ethereum-x402.test.ts
Original file line number Diff line number Diff line change
@@ -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(<T>(fn: () => Promise<T>) => 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));
});
});
164 changes: 129 additions & 35 deletions packages/hdwallet-keepkey/src/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,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<core.ETHSignedTypedData> {
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<core.ETHSignedTypedData> {
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");
Expand Down
Loading
Loading