diff --git a/package-lock.json b/package-lock.json index c638df32a..30b629384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "workspaces": [ "./packages/*" ], + "dependencies": { + "@noble/hashes": "^1.7.1" + }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", "@changesets/changelog-github": "^0.4.8", @@ -2634,6 +2637,8 @@ }, "node_modules/@ethersproject/bytes": { "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", + "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", "funding": [ { "type": "individual", @@ -2668,6 +2673,8 @@ }, "node_modules/@ethersproject/hash": { "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", + "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", "funding": [ { "type": "individual", @@ -5768,6 +5775,18 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -29201,6 +29220,8 @@ "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", "@onflow/fcl": "1.13.4", "@onflow/rlp": "^1.2.3", "@walletconnect/jsonrpc-http-connection": "^1.0.8", diff --git a/package.json b/package.json index 4699e1f07..c3e04d794 100644 --- a/package.json +++ b/package.json @@ -36,5 +36,8 @@ "@nx/nx-darwin-x64": "^17.3.2", "@nx/nx-linux-x64-gnu": "^17.3.2", "@nx/nx-win32-x64-msvc": "^17.3.2" + }, + "dependencies": { + "@noble/hashes": "^1.7.1" } } diff --git a/packages/fcl-ethereum-provider/package.json b/packages/fcl-ethereum-provider/package.json index 7986990a3..9907ac63d 100644 --- a/packages/fcl-ethereum-provider/package.json +++ b/packages/fcl-ethereum-provider/package.json @@ -37,6 +37,8 @@ }, "dependencies": { "@babel/runtime": "^7.25.7", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", "@onflow/fcl": "1.13.4", "@onflow/rlp": "^1.2.3", "@walletconnect/jsonrpc-http-connection": "^1.0.8", diff --git a/packages/fcl-ethereum-provider/src/hash-utils.test.ts b/packages/fcl-ethereum-provider/src/hash-utils.test.ts new file mode 100644 index 000000000..e57afbca1 --- /dev/null +++ b/packages/fcl-ethereum-provider/src/hash-utils.test.ts @@ -0,0 +1,113 @@ +import {keccak_256} from "@noble/hashes/sha3" +import {bytesToHex} from "@noble/hashes/utils" +import {concat, arrayify} from "@ethersproject/bytes" +import {_TypedDataEncoder as TypedDataEncoder} from "@ethersproject/hash" +import {TypedData} from "./types/eth" +import { + hashTypedDataLegacy, + hashTypedDataV3, + hashTypedDataV4, +} from "./hash-utils" + +jest.mock("@noble/hashes/sha3", () => ({ + keccak_256: jest.fn(() => + Uint8Array.from([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90]) + ), +})) + +jest.mock("@ethersproject/hash", () => { + const original = jest.requireActual("@ethersproject/hash") + return { + ...original, + _TypedDataEncoder: { + hashDomain: jest.fn( + domain => + // Return a valid 32-byte hex string (64 hex characters after "0x") + "0x1111111111111111111111111111111111111111111111111111111111111111" + ), + hash: jest.fn( + (domain, types, message) => + "0x2222222222222222222222222222222222222222222222222222222222222222" + ), + }, + } +}) + +describe("Hash Utils", () => { + const mockTypedData: TypedData = { + domain: {name: "Ether Mail", chainId: 1}, + message: {from: "Alice", to: "Bob", contents: "Hello"}, + types: { + EIP712Domain: [ + {name: "name", type: "string"}, + {name: "chainId", type: "uint256"}, + ], + Mail: [ + {name: "from", type: "string"}, + {name: "to", type: "string"}, + {name: "contents", type: "string"}, + ], + }, + primaryType: "Mail", + } + + afterEach(() => { + jest.clearAllMocks() + }) + + describe("hashTypedDataLegacy", () => { + it("should throw an error for legacy (legacy support is not provided)", () => { + expect(() => hashTypedDataLegacy(mockTypedData)).toThrowError( + "Legacy eth_signTypedData is not supported. Please use eth_signTypedData_v3 or eth_signTypedData_v4 instead." + ) + }) + }) + + describe("hashTypedDataV3", () => { + it("should call the TypedDataEncoder functions and then keccak_256 correctly", () => { + const result = hashTypedDataV3(mockTypedData) + + expect(TypedDataEncoder.hashDomain).toHaveBeenCalledWith( + mockTypedData.domain + ) + + expect(TypedDataEncoder.hash).toHaveBeenCalledWith( + mockTypedData.domain, + mockTypedData.types, + mockTypedData.message + ) + + // The implementation concatenates: + // prefix (0x1901), domainSeparator, and messageHash. + const prefix = "0x1901" + const expectedConcat = concat([ + arrayify(prefix), + arrayify( + "0x1111111111111111111111111111111111111111111111111111111111111111" + ), + arrayify( + "0x2222222222222222222222222222222222222222222222222222222222222222" + ), + ]) + + expect(keccak_256).toHaveBeenCalledWith(expectedConcat) + + // The keccak_256 mock always returns our fixed Uint8Array, + // so the expected hash is: + const expectedV3Hash = + "0x" + + bytesToHex( + Uint8Array.from([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90]) + ) + expect(result).toBe(expectedV3Hash) + }) + }) + + describe("hashTypedDataV4", () => { + it("should produce the same result as v3 (for non-nested cases)", () => { + const v3Result = hashTypedDataV3(mockTypedData) + const v4Result = hashTypedDataV4(mockTypedData) + expect(v4Result).toBe(v3Result) + }) + }) +}) diff --git a/packages/fcl-ethereum-provider/src/hash-utils.ts b/packages/fcl-ethereum-provider/src/hash-utils.ts new file mode 100644 index 000000000..a986dc6f1 --- /dev/null +++ b/packages/fcl-ethereum-provider/src/hash-utils.ts @@ -0,0 +1,41 @@ +import {keccak_256} from "@noble/hashes/sha3" +import {bytesToHex} from "@noble/hashes/utils" +import {arrayify, concat} from "@ethersproject/bytes" +import {_TypedDataEncoder as TypedDataEncoder} from "@ethersproject/hash" +import {TypedData} from "./types/eth" + +export function hashTypedDataLegacy(data: TypedData): string { + throw new Error( + "Legacy eth_signTypedData is not supported. Please use eth_signTypedData_v3 or eth_signTypedData_v4 instead." + ) +} + +/** + * Hash for `eth_signTypedData_v3` + * + * Uses EIP‑712 encoding: + * digest = keccak_256( "\x19\x01" || domainSeparator || messageHash ) + */ +export function hashTypedDataV3(data: TypedData): string { + const domainSeparator = TypedDataEncoder.hashDomain(data.domain) + const messageHash = TypedDataEncoder.hash( + data.domain, + data.types, + data.message + ) + // The EIP‑191 prefix is "0x1901". + const prefix = "0x1901" + const digest = keccak_256( + concat([arrayify(prefix), arrayify(domainSeparator), arrayify(messageHash)]) + ) + return "0x" + bytesToHex(digest) +} + +/** + * Hash for `eth_signTypedData_v4` + * + * For many cases, v3 and v4 yield the same result (if you’re not using arrays or nested dynamic types). + */ +export function hashTypedDataV4(data: TypedData): string { + return hashTypedDataV3(data) +} diff --git a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-signtypeddata.ts b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-signtypeddata.ts new file mode 100644 index 000000000..4a3350cfa --- /dev/null +++ b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-signtypeddata.ts @@ -0,0 +1,30 @@ +import {AccountManager} from "../../accounts/account-manager" +import {SignTypedDataParams} from "../../types/eth" +import { + hashTypedDataLegacy, + hashTypedDataV3, + hashTypedDataV4, +} from "../../hash-utils" + +export async function signTypedData( + accountManager: AccountManager, + params: SignTypedDataParams, + version: "eth_signTypedData" | "eth_signTypedData_v3" | "eth_signTypedData_v4" +) { + const {address, data} = params + + if (!address || !data) { + throw new Error("Missing signer address or typed data") + } + + let hashedMessage: string + if (version === "eth_signTypedData_v3") { + hashedMessage = hashTypedDataV3(data) + } else if (version === "eth_signTypedData_v4") { + hashedMessage = hashTypedDataV4(data) + } else { + hashedMessage = hashTypedDataLegacy(data) + } + + return await accountManager.signMessage(hashedMessage, address) +} diff --git a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts index f86bf7212..efd82c269 100644 --- a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts +++ b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts @@ -5,7 +5,8 @@ import {AccountManager} from "../accounts/account-manager" import {ethSendTransaction} from "./handlers/eth-send-transaction" import {NetworkManager} from "../network/network-manager" import {personalSign} from "./handlers/personal-sign" -import {PersonalSignParams} from "../types/eth" +import {PersonalSignParams, SignTypedDataParams, TypedData} from "../types/eth" +import {signTypedData} from "./handlers/eth-signtypeddata" export class RpcProcessor { constructor( @@ -27,6 +28,32 @@ export class RpcProcessor { return ethRequestAccounts(this.accountManager) case "eth_sendTransaction": return await ethSendTransaction(this.accountManager, params) + case "eth_signTypedData": + case "eth_signTypedData_v3": + case "eth_signTypedData_v4": { + if (!params || typeof params !== "object") { + throw new Error(`${method} requires valid parameters.`) + } + + const {address, data} = params as {address?: unknown; data?: unknown} + + if ( + typeof address !== "string" || + typeof data !== "object" || + data === null + ) { + throw new Error( + `${method} requires 'address' (string) and a valid 'data' object.` + ) + } + + const validParams: SignTypedDataParams = { + address, + data: data as TypedData, + } + + return await signTypedData(this.accountManager, validParams, method) + } case "personal_sign": return await personalSign( this.accountManager, diff --git a/packages/fcl-ethereum-provider/src/types/eth.ts b/packages/fcl-ethereum-provider/src/types/eth.ts index 4cd8076db..92652d89f 100644 --- a/packages/fcl-ethereum-provider/src/types/eth.ts +++ b/packages/fcl-ethereum-provider/src/types/eth.ts @@ -1,3 +1,28 @@ export type EthSignatureResponse = string export type PersonalSignParams = [string, string] + +export interface SignTypedDataParams { + address: string + data: TypedData // This represents the EIP-712 structured data +} + +export interface TypedDataField { + name: string + type: string +} + +export interface TypedDataDomain { + name?: string + version?: string + chainId?: number + verifyingContract?: string + salt?: string +} + +export interface TypedData { + types: Record + domain: TypedDataDomain + primaryType: string + message: Record +}