Skip to content

Commit

Permalink
Add eth_signTypedData (#2097)
Browse files Browse the repository at this point in the history
* Add eth signed typed data

* Fix

* Run prettier

* Add tests

* Run prettier

* Remove comment

* Match hashing closer to spec

* Use util

* Fix tests

* Run prettier

* Remove legacy support

* Update packages/fcl-ethereum-provider/src/hash-utils.ts

Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com>

* Fix test

---------

Co-authored-by: Chase Fleming <1666730+chasefleming@users.noreply.github.com>
Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com>
  • Loading branch information
3 people authored Feb 3, 2025
1 parent 4c7ec61 commit 29d7f07
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 1 deletion.
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions packages/fcl-ethereum-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 113 additions & 0 deletions packages/fcl-ethereum-provider/src/hash-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
41 changes: 41 additions & 0 deletions packages/fcl-ethereum-provider/src/hash-utils.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
29 changes: 28 additions & 1 deletion packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/fcl-ethereum-provider/src/types/eth.ts
Original file line number Diff line number Diff line change
@@ -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<string, TypedDataField[]>
domain: TypedDataDomain
primaryType: string
message: Record<string, any>
}

0 comments on commit 29d7f07

Please sign in to comment.