-
Notifications
You must be signed in to change notification settings - Fork 119
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
4c7ec61
commit 29d7f07
Showing
8 changed files
with
263 additions
and
1 deletion.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
30 changes: 30 additions & 0 deletions
30
packages/fcl-ethereum-provider/src/rpc/handlers/eth-signtypeddata.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
} |