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
15 changes: 15 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"
}
}
66 changes: 66 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,66 @@
import {keccak_256} from "@noble/hashes/sha3"
import {TypedData} from "./types/eth"
import {
hashTypedDataLegacy,
hashTypedDataV3,
hashTypedDataV4,
} from "./hash-utils"

jest.mock("@noble/hashes/sha3", () => ({
keccak_256: jest.fn(() => Buffer.from("abcdef1234567890", "hex")),
}))

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()
})

it("should hash data correctly for eth_signTypedData (legacy)", () => {
const result = hashTypedDataLegacy(mockTypedData)

expect(keccak_256).toHaveBeenCalledWith(
Buffer.from(JSON.stringify(mockTypedData), "utf8")
)
expect(result).toBe("0xabcdef1234567890")
})

it("should hash data correctly for eth_signTypedData_v3", () => {
const result = hashTypedDataV3(mockTypedData)

expect(keccak_256).toHaveBeenCalledTimes(3) // domain, message, and final hash
expect(keccak_256).toHaveBeenCalledWith(
Buffer.from(JSON.stringify(mockTypedData.domain), "utf8")
)
expect(keccak_256).toHaveBeenCalledWith(
Buffer.from(JSON.stringify(mockTypedData.message), "utf8")
)
expect(keccak_256).toHaveBeenCalledWith(expect.any(Buffer))
expect(result).toBe("0xabcdef1234567890")
})

it("should hash data correctly for eth_signTypedData_v4", () => {
const result = hashTypedDataV4(mockTypedData)

expect(keccak_256).toHaveBeenCalledWith(
Buffer.from(JSON.stringify(mockTypedData), "utf8")
)
expect(result).toBe("0xabcdef1234567890")
})
})
38 changes: 38 additions & 0 deletions packages/fcl-ethereum-provider/src/hash-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {keccak_256} from "@noble/hashes/sha3"
import {TypedData} from "./types/eth"

/**
* Hash for legacy `eth_signTypedData`
*/
export function hashTypedDataLegacy(data: TypedData): string {
return `0x${Buffer.from(keccak_256(Buffer.from(JSON.stringify(data), "utf8"))).toString("hex")}`
jribbink marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Hash for `eth_signTypedData_v3`
*/
export function hashTypedDataV3(data: TypedData): string {
const domainHash = keccak_256(
Buffer.from(JSON.stringify(data.domain), "utf8")
)
const messageHash = keccak_256(
Buffer.from(JSON.stringify(data.message), "utf8")
)

const fullHash = keccak_256(
Buffer.concat([
Buffer.from("\x19\x01"), // EIP-712 prefix
domainHash,
messageHash,
])
)

return `0x${Buffer.from(fullHash).toString("hex")}`
}

/**
* Hash for `eth_signTypedData_v4`
*/
export function hashTypedDataV4(data: TypedData): string {
return `0x${Buffer.from(keccak_256(Buffer.from(JSON.stringify(data), "utf8"))).toString("hex")}`
}
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
17 changes: 17 additions & 0 deletions packages/fcl-ethereum-provider/src/types/eth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
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 TypedData {
types: Record<string, Array<{name: string; type: string}>>
domain: {
name?: string
version?: string
chainId?: number
verifyingContract?: string
}
primaryType: string
message: Record<string, any>
}