diff --git a/packages/hardhat-core/src/internal/hardhat-network/provider/modules/eth.ts b/packages/hardhat-core/src/internal/hardhat-network/provider/modules/eth.ts index de2600abaf..0a022e9d21 100644 --- a/packages/hardhat-core/src/internal/hardhat-network/provider/modules/eth.ts +++ b/packages/hardhat-core/src/internal/hardhat-network/provider/modules/eth.ts @@ -249,7 +249,19 @@ export class EthModule { throw new MethodNotSupportedError(method); case "eth_signTypedData": - return this._signTypedDataAction(...this._signTypedDataParams(params)); + throw new MethodNotSupportedError(method); + + case "eth_signTypedData_v3": + throw new MethodNotSupportedError(method); + + // TODO: we're currently mimicking the MetaMask implementation here. + // The EIP 712 is still a draft. It doesn't actually distinguish different versions + // of the eth_signTypedData API. + // Also, note that go-ethereum implemented this in a clef JSON-RPC API: account_signTypedData. + case "eth_signTypedData_v4": + return this._signTypedDataV4Action( + ...this._signTypedDataV4Params(params) + ); case "eth_submitHashrate": throw new MethodNotSupportedError(method); @@ -881,17 +893,34 @@ export class EthModule { // eth_signTransaction - // eth_signTypedData + // eth_signTypedData_v4 - private _signTypedDataParams(params: any[]): [Buffer, any] { + private _signTypedDataV4Params(params: any[]): [Buffer, any] { + // Validation of the TypedData parameter is handled by eth-sig-util return validateParams(params, rpcAddress, rpcUnknown); } - private async _signTypedDataAction( + private async _signTypedDataV4Action( address: Buffer, typedData: any ): Promise { - return this._node.signTypedData(address, typedData); + let typedMessage: any = typedData; + + // According to the MetaMask implementation, + // the message parameter may be JSON stringified in versions later than V1 + // See https://github.com/MetaMask/metamask-extension/blob/0dfdd44ae7728ed02cbf32c564c75b74f37acf77/app/scripts/metamask-controller.js#L1736 + // In fact, ethers.js JSON stringifies the message at the time of writing. + if (typeof typedData === "string") { + try { + typedMessage = JSON.parse(typedData); + } catch (error) { + throw new InvalidInputError( + `The message parameter is an invalid JSON. Either pass a valid JSON or a plain object conforming to EIP712 TypedData schema.` + ); + } + } + + return this._node.signTypedDataV4(address, typedMessage); } // eth_submitHashrate diff --git a/packages/hardhat-core/src/internal/hardhat-network/provider/node.ts b/packages/hardhat-core/src/internal/hardhat-network/provider/node.ts index a9b308b87e..3a4ea43133 100644 --- a/packages/hardhat-core/src/internal/hardhat-network/provider/node.ts +++ b/packages/hardhat-core/src/internal/hardhat-network/provider/node.ts @@ -6,6 +6,7 @@ import { RunBlockResult } from "@nomiclabs/ethereumjs-vm/dist/runBlock"; import { StateManager } from "@nomiclabs/ethereumjs-vm/dist/state"; import chalk from "chalk"; import debug from "debug"; +import * as ethSigUtil from "eth-sig-util"; import Common from "ethereumjs-common"; import { FakeTransaction, Transaction } from "ethereumjs-tx"; import { @@ -75,10 +76,6 @@ import { putGenesisBlock } from "./utils/putGenesisBlock"; const log = debug("hardhat:core:hardhat-network:node"); -// This library's types are wrong, they don't type check -// tslint:disable-next-line no-var-requires -const ethSigUtil = require("eth-sig-util"); - export const COINBASE_ADDRESS = toBuffer( "0xc014ba5ec014ba5ec014ba5ec014ba5ec014ba5e" ); @@ -623,7 +620,10 @@ export class HardhatNode extends EventEmitter { return ecsign(messageHash, privateKey); } - public async signTypedData(address: Buffer, typedData: any): Promise { + public async signTypedDataV4( + address: Buffer, + typedData: any + ): Promise { const privateKey = this._getLocalAccountPrivateKey(address); return ethSigUtil.signTypedData_v4(privateKey, { diff --git a/packages/hardhat-core/test/internal/hardhat-network/provider/modules/eth.ts b/packages/hardhat-core/test/internal/hardhat-network/provider/modules/eth.ts index e9e8fe3179..854558c622 100644 --- a/packages/hardhat-core/test/internal/hardhat-network/provider/modules/eth.ts +++ b/packages/hardhat-core/test/internal/hardhat-network/provider/modules/eth.ts @@ -1,4 +1,5 @@ import { assert } from "chai"; +import { recoverTypedSignature, recoverTypedSignature_v4 } from "eth-sig-util"; import Common from "ethereumjs-common"; import { Transaction } from "ethereumjs-tx"; import { BN, bufferToHex, toBuffer, zeroAddress } from "ethereumjs-util"; @@ -3390,8 +3391,102 @@ describe("Eth module", function () { }); }); - describe("eth_signTypedData", async function () { - // TODO: Test this. Note that it just forwards to/from eth-sign-util + describe("eth_signTypedData", function () { + it("is not supported", async function () { + await assertNotSupported(this.provider, "eth_signTypedData"); + }); + }); + + describe("eth_signTypedData_v3", function () { + it("is not supported", async function () { + await assertNotSupported(this.provider, "eth_signTypedData_v3"); + }); + }); + + describe("eth_signTypedData_v4", function () { + // See https://eips.ethereum.org/EIPS/eip-712#parameters + // There's a json schema and an explanation for each field. + const typedMessage = { + domain: { + chainId: 31337, + name: "Hardhat Network test suite", + }, + message: { + name: "Translation", + start: { + x: 200, + y: 600, + }, + end: { + x: 300, + y: 350, + }, + cost: 50, + }, + primaryType: "WeightedVector", + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "chainId", type: "uint256" }, + ], + WeightedVector: [ + { name: "name", type: "string" }, + { name: "start", type: "Point" }, + { name: "end", type: "Point" }, + { name: "cost", type: "uint256" }, + ], + Point: [ + { name: "x", type: "uint256" }, + { name: "y", type: "uint256" }, + ], + }, + }; + const [address] = DEFAULT_ACCOUNTS_ADDRESSES; + + it("should sign a message", async function () { + const signature = await this.provider.request({ + method: "eth_signTypedData_v4", + params: [address, typedMessage], + }); + const signedMessage = { + data: typedMessage, + sig: signature, + }; + + const recoveredAddress = recoverTypedSignature_v4( + signedMessage as any + ); + assert.equal(address.toLowerCase(), recoveredAddress.toLowerCase()); + }); + + it("should sign a message that is JSON stringified", async function () { + const signature = await this.provider.request({ + method: "eth_signTypedData_v4", + params: [address, JSON.stringify(typedMessage)], + }); + const signedMessage = { + data: typedMessage, + sig: signature, + }; + + const recoveredAddress = recoverTypedSignature_v4( + signedMessage as any + ); + assert.equal(address.toLowerCase(), recoveredAddress.toLowerCase()); + }); + + it("should fail with an invalid JSON", async function () { + try { + const signature = await this.provider.request({ + method: "eth_signTypedData_v4", + params: [address, "{an invalid JSON"], + }); + } catch (error) { + assert.include(error.message, "is an invalid JSON"); + return; + } + assert.fail("should have failed with an invalid JSON"); + }); }); describe("eth_submitHashrate", async function () { diff --git a/packages/hardhat-ethers/src/signers.ts b/packages/hardhat-ethers/src/signers.ts index 6ecf6df0b6..48294f1cb9 100644 --- a/packages/hardhat-ethers/src/signers.ts +++ b/packages/hardhat-ethers/src/signers.ts @@ -1,13 +1,13 @@ import { ethers } from "ethers"; export class SignerWithAddress extends ethers.Signer { - public static async create(signer: ethers.Signer) { + public static async create(signer: ethers.providers.JsonRpcSigner) { return new SignerWithAddress(await signer.getAddress(), signer); } private constructor( public readonly address: string, - private readonly _signer: ethers.Signer + private readonly _signer: ethers.providers.JsonRpcSigner ) { super(); (this as any).provider = _signer.provider; @@ -37,6 +37,12 @@ export class SignerWithAddress extends ethers.Signer { return new SignerWithAddress(this.address, this._signer.connect(provider)); } + public _signTypedData( + ...params: Parameters + ): Promise { + return this._signer._signTypedData(...params); + } + public toJSON() { return ``; } diff --git a/packages/hardhat-ethers/test/index.ts b/packages/hardhat-ethers/test/index.ts index f23a1a8304..2022bfbfb5 100644 --- a/packages/hardhat-ethers/test/index.ts +++ b/packages/hardhat-ethers/test/index.ts @@ -773,5 +773,58 @@ describe("Ethers plugin", function () { code = await this.env.ethers.provider.getCode(receipt.contractAddress); assert.lengthOf(code, 2); }); + + it("_signTypedData integration test", async function () { + // See https://eips.ethereum.org/EIPS/eip-712#parameters + // There's a json schema and an explanation for each field. + const typedMessage = { + domain: { + chainId: 31337, + name: "Hardhat Network test suite", + }, + message: { + name: "Translation", + start: { + x: 200, + y: 600, + }, + end: { + x: 300, + y: 350, + }, + cost: 50, + }, + primaryType: "WeightedVector", + types: { + // ethers.js derives the EIP712Domain type from the domain object itself + // EIP712Domain: [ + // { name: "name", type: "string" }, + // { name: "chainId", type: "uint256" }, + // ], + WeightedVector: [ + { name: "name", type: "string" }, + { name: "start", type: "Point" }, + { name: "end", type: "Point" }, + { name: "cost", type: "uint256" }, + ], + Point: [ + { name: "x", type: "uint256" }, + { name: "y", type: "uint256" }, + ], + }, + }; + const [signer] = await this.env.ethers.getSigners(); + + const signature = await signer._signTypedData( + typedMessage.domain, + typedMessage.types, + typedMessage.message + ); + + const byteToHex = 2; + const hexPrefix = 2; + const signatureSizeInBytes = 65; + assert.lengthOf(signature, signatureSizeInBytes * byteToHex + hexPrefix); + }); }); });