Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add eth_signTypedData #2097

Merged
merged 14 commits into from
Feb 3, 2025
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 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 signTypedData is not supported. Please use eth_signTypedData_v3 or eth_signTypedData_v4 instead."
chasefleming marked this conversation as resolved.
Show resolved Hide resolved
)
}

/**
* 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 @@ -6,7 +6,8 @@ import * as fcl from "@onflow/fcl"
import {FLOW_CHAINS, FlowNetwork} from "../constants"
import {ethSendTransaction} from "./handlers/eth-send-transaction"
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 @@ -28,6 +29,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>
}