diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index b82f51b2..e57db2ec 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -9,9 +9,25 @@ export interface SolanaAddress { address: string; } +export interface SolanaTokenInfo { + /** 32-byte SPL mint, encoded as bytes, hex, base64, or base58. */ + mint: Uint8Array | string; + symbol?: string; + decimals?: number; + signature?: Uint8Array | string; + signerKeyId?: number; +} + export interface SolanaSignTx { addressNList: BIP32Path; rawTx: Uint8Array | string; + /** Optional token definitions used by firmware display policy. */ + tokenInfo?: SolanaTokenInfo[]; + /** + * Candidate owners for signed SPL token destinations (for example x402 + * payTo). Firmware displays one only after deriving and matching its ATA. + */ + tokenRecipientOwners?: Array; /** One-request opaque-signing authorization; does not mutate AdvancedMode. */ allowBlindSigning?: boolean; /** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */ diff --git a/packages/hdwallet-keepkey/src/ethereum-x402.test.ts b/packages/hdwallet-keepkey/src/ethereum-x402.test.ts new file mode 100644 index 00000000..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 93a2b6ca..3ee43b75 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -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 { + 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/solana-x402.test.ts b/packages/hdwallet-keepkey/src/solana-x402.test.ts new file mode 100644 index 00000000..ffd4271a --- /dev/null +++ b/packages/hdwallet-keepkey/src/solana-x402.test.ts @@ -0,0 +1,93 @@ +import * as jspb from "google-protobuf"; + +import { SolanaSignedTx, solanaSignTx } from "./solana"; + +const SOLANA_SIGN_TX = 752; +const SOLANA_SIGNED_TX = 753; +const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000]; +const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const PAY_TO = "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB"; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("Solana x402 display metadata", () => { + it("forwards token metadata in field 4 and payTo owner in field 12", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation((messageType: number, msg: jspb.Message) => { + expect(messageType).toBe(SOLANA_SIGN_TX); + const reader = new jspb.BinaryReader((msg as any).serializeBinary()); + let mint: Uint8Array | undefined; + let symbol: string | undefined; + let decimals: number | undefined; + let owner: Uint8Array | undefined; + + while (reader.nextField()) { + if (reader.isEndGroup()) break; + if (reader.getFieldNumber() === 4) { + const nested = new jspb.BinaryReader(reader.readBytes()); + while (nested.nextField()) { + if (nested.isEndGroup()) break; + switch (nested.getFieldNumber()) { + case 1: + mint = nested.readBytes(); + break; + case 2: + symbol = nested.readString(); + break; + case 3: + decimals = nested.readUint32(); + break; + default: + nested.skipField(); + } + } + } else if (reader.getFieldNumber() === 12) { + owner = reader.readBytes(); + } else { + reader.skipField(); + } + } + + expect(mint).toHaveLength(32); + expect(symbol).toBe("USDC"); + expect(decimals).toBe(6); + expect(owner).toHaveLength(32); + + const response = new SolanaSignedTx(); + response.setSignature(new Uint8Array(64).fill(0x42)); + return Promise.resolve({ + message_enum: SOLANA_SIGNED_TX, + message_type: "SolanaSignedTx", + proto: response, + }); + }) + ); + + const result = await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + tokenInfo: [{ mint: USDC_MINT, symbol: "USDC", decimals: 6 }], + tokenRecipientOwners: [PAY_TO], + }); + expect(result.signature).toHaveLength(64); + }); + + it("rejects a malformed recipient owner before device transport", async () => { + const call = jest.fn(); + const transport = makeMockTransport(call); + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + tokenRecipientOwners: [new Uint8Array(31)], + }) + ).rejects.toThrow("exactly 32 bytes"); + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 49aef5ea..017199f2 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; import * as core from "@keepkey/hdwallet-core"; +import * as bs58 from "bs58"; import * as jspb from "google-protobuf"; import { Transport } from "./transport"; @@ -21,6 +22,26 @@ function toBytes(value: Uint8Array | string): Uint8Array { : Uint8Array.from(Buffer.from(value, "base64")); } +function toSolanaPubkey(value: Uint8Array | string, label: string): Uint8Array { + let bytes: Uint8Array; + if (value instanceof Uint8Array) { + bytes = value; + } else if (/^[0-9a-fA-F]{64}$/.test(value)) { + bytes = core.fromHexString(value); + } else { + try { + const decoded = bs58.decode(value); + bytes = Uint8Array.from(decoded); + } catch (_e) { + bytes = Uint8Array.from(Buffer.from(value, "base64")); + } + } + if (bytes.length !== 32) { + throw new Error(`${label} must decode to exactly 32 bytes, got ${bytes.length}`); + } + return bytes; +} + function encodeVarint(value: number): number[] { if (!Number.isInteger(value) || value < 0) { throw new Error(`varint must be a non-negative integer, got ${value}`); @@ -57,6 +78,31 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array { return out; } +function encodeSolanaTokenInfo(info: core.SolanaTokenInfo): Uint8Array { + const fields: Uint8Array[] = [encodeLengthDelimited(1, toSolanaPubkey(info.mint, "token mint"))]; + if (info.symbol !== undefined) { + const symbol = Uint8Array.from(Buffer.from(info.symbol, "utf8")); + if (symbol.length === 0 || symbol.length > 12) { + throw new Error(`token symbol must contain 1-12 UTF-8 bytes, got ${symbol.length}`); + } + fields.push(encodeLengthDelimited(2, symbol)); + } + if (info.decimals !== undefined) { + fields.push(encodeVarintField(3, info.decimals)); + } + if (info.signature !== undefined) { + const signature = toBytes(info.signature); + if (signature.length !== 64) { + throw new Error(`token metadata signature must be 64 bytes, got ${signature.length}`); + } + fields.push(encodeLengthDelimited(4, signature)); + } + if (info.signerKeyId !== undefined) { + fields.push(encodeVarintField(5, info.signerKeyId)); + } + return concatBytes(...fields); +} + /** * Wrap a jspb message so serializeBinary() yields the original encoding plus * `extra`. Transport.call() only ever calls serializeBinary(), so a duck-typed @@ -1130,26 +1176,29 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) } /* - * KKSOLSC1 schema fields (SolanaSignTx 9/10/11) are appended at the wire - * level rather than through generated setters: the published - * @keepkey/device-protocol build predates them, so setSchemaPayload() and - * friends do not exist. Protobuf makes this safe and lossless — encoded - * fields are order-independent and simply concatenate, and firmware's - * nanopb decoder reads them by field number exactly as if the generator - * had emitted them. Drop this shim once a device-protocol release carries - * the fields and the setters appear. + * Additive SolanaSignTx fields are appended at the wire level because this + * file intentionally carries a small jspb compatibility shim. Protobuf + * fields are order-independent, and firmware's nanopb decoder reads the + * same canonical field numbers emitted by device-protocol. */ - let outbound: jspb.Message = signTx; + const extraFields: Uint8Array[] = []; + for (const tokenInfo of msg.tokenInfo || []) { + extraFields.push(encodeLengthDelimited(4, encodeSolanaTokenInfo(tokenInfo))); + } if (msg.schema) { const payload = toBytes(msg.schema.payload); const signature = toBytes(msg.schema.signature); - const extra = concatBytes( + extraFields.push( encodeLengthDelimited(9, payload), encodeLengthDelimited(10, signature), encodeVarintField(11, msg.schema.signerKeyId) ); - outbound = withAppendedFields(signTx, extra); } + for (const owner of msg.tokenRecipientOwners || []) { + extraFields.push(encodeLengthDelimited(12, toSolanaPubkey(owner, "token recipient owner"))); + } + const outbound: jspb.Message = + extraFields.length > 0 ? withAppendedFields(signTx, concatBytes(...extraFields)) : signTx; const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT,