diff --git a/contracts/utils/cryptography/MessageHashUtils.sol b/contracts/utils/cryptography/MessageHashUtils.sol index 232090f31db..8e97e22ab46 100644 --- a/contracts/utils/cryptography/MessageHashUtils.sol +++ b/contracts/utils/cryptography/MessageHashUtils.sol @@ -13,6 +13,8 @@ import {Strings} from "../Strings.sol"; * specifications. */ library MessageHashUtils { + error ERC5267ExtensionsNotSupported(); + /** * @dev Returns the keccak256 digest of an ERC-191 signed data with version * `0x45` (`personal_sign` messages). @@ -96,4 +98,131 @@ library MessageHashUtils { digest := keccak256(ptr, 0x42) } } + + /** + * @dev Returns the EIP-712 domain separator constructed from an `eip712Domain`. See {IERC5267-eip712Domain} + * + * This function dynamically constructs the domain separator based on which fields are present in the + * `fields` parameter. It contains flags that indicate which domain fields are present: + * + * * Bit 0 (0x01): name + * * Bit 1 (0x02): version + * * Bit 2 (0x04): chainId + * * Bit 3 (0x08): verifyingContract + * * Bit 4 (0x10): salt + * + * Arguments that correspond to fields which are not present in `fields` are ignored. For example, if `fields` is + * `0x0f` (`0x01111`), then the `salt` parameter is ignored. + */ + function toDomainSeparator( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt + ) internal pure returns (bytes32 hash) { + return + toDomainSeparator( + fields, + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + verifyingContract, + salt + ); + } + + /// @dev Variant of {toDomainSeparator-bytes1-string-string-uint256-address-bytes32} that uses hashed name and version. + function toDomainSeparator( + bytes1 fields, + bytes32 nameHash, + bytes32 versionHash, + uint256 chainId, + address verifyingContract, + bytes32 salt + ) internal pure returns (bytes32 hash) { + bytes32 domainTypeHash = toDomainTypeHash(fields); + + assembly ("memory-safe") { + // align fields to the right for easy processing + fields := shr(248, fields) + + // FMP used as scratch space + let fmp := mload(0x40) + mstore(fmp, domainTypeHash) + + let ptr := add(fmp, 0x20) + if and(fields, 0x01) { + mstore(ptr, nameHash) + ptr := add(ptr, 0x20) + } + if and(fields, 0x02) { + mstore(ptr, versionHash) + ptr := add(ptr, 0x20) + } + if and(fields, 0x04) { + mstore(ptr, chainId) + ptr := add(ptr, 0x20) + } + if and(fields, 0x08) { + mstore(ptr, verifyingContract) + ptr := add(ptr, 0x20) + } + if and(fields, 0x10) { + mstore(ptr, salt) + ptr := add(ptr, 0x20) + } + + hash := keccak256(fmp, sub(ptr, fmp)) + } + } + + /// @dev Builds an EIP-712 domain type hash depending on the `fields` provided, following https://eips.ethereum.org/EIPS/eip-5267[ERC-5267] + function toDomainTypeHash(bytes1 fields) internal pure returns (bytes32 hash) { + if (fields & 0x20 == 0x20) revert ERC5267ExtensionsNotSupported(); + + assembly ("memory-safe") { + // align fields to the right for easy processing + fields := shr(248, fields) + + // FMP used as scratch space + let fmp := mload(0x40) + mstore(fmp, "EIP712Domain(") + + let ptr := add(fmp, 0x0d) + // name field + if and(fields, 0x01) { + mstore(ptr, "string name,") + ptr := add(ptr, 0x0c) + } + // version field + if and(fields, 0x02) { + mstore(ptr, "string version,") + ptr := add(ptr, 0x0f) + } + // chainId field + if and(fields, 0x04) { + mstore(ptr, "uint256 chainId,") + ptr := add(ptr, 0x10) + } + // verifyingContract field + if and(fields, 0x08) { + mstore(ptr, "address verifyingContract,") + ptr := add(ptr, 0x1a) + } + // salt field + if and(fields, 0x10) { + mstore(ptr, "bytes32 salt,") + ptr := add(ptr, 0x0d) + } + // if any field is enabled, remove the trailing comma + ptr := sub(ptr, iszero(iszero(and(fields, 0x1f)))) + // add the closing brace + mstore8(ptr, 0x29) // add closing brace + ptr := add(ptr, 1) + + hash := keccak256(fmp, sub(ptr, fmp)) + } + } } diff --git a/test/utils/cryptography/MessageHashUtils.test.js b/test/utils/cryptography/MessageHashUtils.test.js index 57e82867efd..7f18b51c929 100644 --- a/test/utils/cryptography/MessageHashUtils.test.js +++ b/test/utils/cryptography/MessageHashUtils.test.js @@ -2,7 +2,8 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { domainSeparator, hashTypedData } = require('../../helpers/eip712'); +const { domainType, domainSeparator, hashTypedData } = require('../../helpers/eip712'); +const { generators } = require('../../helpers/random'); async function fixture() { const mock = await ethers.deployContract('$MessageHashUtils'); @@ -94,4 +95,55 @@ describe('MessageHashUtils', function () { await expect(this.mock.$toTypedDataHash(domainSeparator(domain), structhash)).to.eventually.equal(expectedHash); }); }); + + describe('ERC-5267', function () { + const fullDomain = { + name: generators.string(), + version: generators.string(), + chainId: generators.uint256(), + verifyingContract: generators.address(), + salt: generators.bytes32(), + }; + + for (let fields = 0; fields < 1 << Object.keys(fullDomain).length; ++fields) { + const domain = Object.fromEntries(Object.entries(fullDomain).filter((_, i) => fields & (1 << i))); + const domainTypeName = new ethers.TypedDataEncoder({ EIP712Domain: domainType(domain) }).encodeType( + 'EIP712Domain', + ); + + describe(domainTypeName, function () { + it('toDomainSeparator(bytes1,string,string,uint256,address,bytes32)', async function () { + await expect( + this.mock.$toDomainSeparator( + ethers.toBeHex(fields), + ethers.Typed.string(fullDomain.name), + ethers.Typed.string(fullDomain.version), + fullDomain.chainId, + fullDomain.verifyingContract, + fullDomain.salt, + ), + ).to.eventually.equal(domainSeparator(domain)); + }); + + it('toDomainSeparator(bytes1,bytes32,bytes32,uint256,address,bytes32)', async function () { + await expect( + this.mock.$toDomainSeparator( + ethers.toBeHex(fields), + ethers.Typed.bytes32(ethers.id(fullDomain.name)), + ethers.Typed.bytes32(ethers.id(fullDomain.version)), + fullDomain.chainId, + fullDomain.verifyingContract, + fullDomain.salt, + ), + ).to.eventually.equal(domainSeparator(domain)); + }); + + it('toDomainTypeHash', async function () { + await expect(this.mock.$toDomainTypeHash(ethers.toBeHex(fields))).to.eventually.equal( + ethers.id(domainTypeName), + ); + }); + }); + } + }); });