Summary
The contract EIP712Base
deviates from the EIP-712 standard by omitting the chainId
field in the domain separator. This creates compatibility issues with clients that expect chainId
, such as MetaMask. This non-standard implementation results in interoperability issues, where signatures generated by clients adhering to EIP-712 specifications cannot be verified by the contract, breaking core protocol functionality.
Vulnerability Details
This vulnerability arises because the protocol fails to include the chainId
field in the domain separator. EIP-712 specifies the inclusion of chainId
to uniquely identify signatures across different chains and prevent replay attacks. Clients like MetaMask and other standard-compliant wallets sign messages with chainId
included, following the EIP-712 specification. Due to this mismatch, signatures signed by clients with chainId
will not match the contract’s calculated domain separator, resulting in failed signature verifications.
The following PoC showcases the described issue. To run it, create a new Hardhat JavaScript project, copy-paste the code below, run npm install --save-dev @nomicfoundation/hardhat-toolbox ethers chai
& npm install @openzeppelin/contracts
, and run the test with npx hardhat test
.
PoC
pragma solidity ^0.8.22;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712Base {
using ECDSA for bytes32;
bytes32 public immutable _CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable _TYPE_HASH;
string private constant SIGNING_DOMAIN = "MyApp";
string private constant SIGNATURE_VERSION = "1";
struct MyMessage {
address user;
uint256 amount;
string message;
}
bytes32 private constant MESSAGE_TYPEHASH = keccak256("MyMessage(address user,uint256 amount,string message)");
constructor() {
bytes32 hashedName = keccak256(bytes(SIGNING_DOMAIN));
bytes32 hashedVersion = keccak256(bytes(SIGNATURE_VERSION));
bytes32 typeHash = keccak256("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)");
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
_TYPE_HASH = typeHash;
}
function hashMessage(MyMessage calldata message) internal view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, message.user, message.amount, keccak256(bytes(message.message))))
);
}
function verify(MyMessage calldata message, bytes calldata signature) external view returns (bool) {
bytes32 digest = hashMessage(message);
address signer = digest.recover(signature);
return signer == message.user;
}
function _domainSeparatorV4() internal view returns (bytes32) {
return _CACHED_DOMAIN_SEPARATOR;
}
function _buildDomainSeparator(bytes32 typeHash, bytes32 name, bytes32 version) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, name, version, address(this), bytes32(getChainId())));
}
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
}
function getChainId() public view returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}
}
pragma solidity ^0.8.22;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712ChainId {
using ECDSA for bytes32;
bytes32 public immutable _CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable _TYPE_HASH;
string private constant SIGNING_DOMAIN = "MyApp";
string private constant SIGNATURE_VERSION = "1";
struct MyMessage {
address user;
uint256 amount;
string message;
}
bytes32 private constant MESSAGE_TYPEHASH = keccak256("MyMessage(address user,uint256 amount,string message)");
constructor() {
bytes32 hashedName = keccak256(bytes(SIGNING_DOMAIN));
bytes32 hashedVersion = keccak256(bytes(SIGNATURE_VERSION));
bytes32 typeHash =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
_TYPE_HASH = typeHash;
}
function hashMessage(MyMessage calldata message) internal view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, message.user, message.amount, keccak256(bytes(message.message))))
);
}
function verify(MyMessage calldata message, bytes calldata signature) external view returns (bool) {
bytes32 digest = hashMessage(message);
address signer = digest.recover(signature);
return signer == message.user;
}
function _domainSeparatorV4() internal view returns (bytes32) {
return _CACHED_DOMAIN_SEPARATOR;
}
function _buildDomainSeparator(bytes32 typeHash, bytes32 name, bytes32 version) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, name, version, getChainId(), address(this), bytes32(getChainId())));
}
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
}
function getChainId() public view returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}
}
const { ethers } = require("hardhat");
const { expect } = require("chai");
describe("EIP712", function () {
it("should fail to verify the signature when chainId is missing", async function () {
const EIP712Bytes32 = await ethers.getContractFactory("EIP712Bytes32");
const contractBytes32 = await EIP712Bytes32.deploy();
const pk = "1fa74b7ab62483c591ddae0447619d8cf10fa7e88b0fca68937847862f593059";
const signer = new ethers.Wallet(pk);
const domain = {
name: "MyApp",
version: "1",
chainId: 31337,
verifyingContract: await contractBytes32.target,
salt: "0x0000000000000000000000000000000000000000000000000000000000007a69",
};
const types = {
MyMessage: [
{ name: "user", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "message", type: "string" },
],
};
const message = {
user: signer.address,
amount: 100,
message: "Hello, EIP-712!",
};
const signature = await signer.signTypedData(domain, types, message);
const isValid = await contractBytes32.verify(message, signature);
expect(isValid).to.be.false;
});
it("should pass to verify the signature when chainId is present", async function () {
const EIP712 = await ethers.getContractFactory("EIP712");
const contractUint256 = await EIP712.deploy();
const privateKey = "1fa74b7ab62483c591ddae0447619d8cf10fa7e88b0fca68937847862f593059";
const wallet = new ethers.Wallet(privateKey);
const domain = {
name: "MyApp",
version: "1",
chainId: 31337,
verifyingContract: await contractUint256.target,
salt: "0x0000000000000000000000000000000000000000000000000000000000007a69",
};
const types = {
MyMessage: [
{ name: "user", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "message", type: "string" }
]
};
const message = {
user: wallet.address,
amount: 100,
message: "Hello, EIP-712!"
};
const signature = await wallet.signTypedData(domain, types, message);
const isValid = await contractUint256.verify(message, signature);
expect(isValid).to.be.true;
});
});
console output
➜ EIP712Test1 npx hardhat test
EIP712
✔ should fail to verify the signature when chainId is missing (923ms)
✔ should pass to verify the signature when chainId is present
2 passing (945ms)
Impact
This issue directly impacts core protocol functionality, as the EIP712Base
contract is inherited by NativeMetaTransaction
and subsequently by MembershipFactory
. The lack of interoperability caused by omitting chainId
renders the contract unable to verify valid signatures signed by standard EIP-712 clients, effectively breaking the NativeMetaTransaction
feature and any dependent functionality within the protocol.
Tools Used
VSCode, ethers.js
Recommendations
Update the domainSeparator
in the EIP712Base
contract to include the chainId
field.