Implementing and Testing EIP-712 signatures
Foundry offers multiple utilities to make it easy and reliable to work with EIP-712 signatures.
EIP-712 is a standard for hashing and signing typed structured data. Instead of signing an opaque hash, users can sign human-readable messages, significantly improving usability and security. This is particularly useful for meta-transactions, permit functions (like in ERC-20 permits), and other off-chain signature schemes. However, correctly implementing EIP-712 hashing logic can be intricate. Foundry's suite provides powerful utilities specifically designed to help developers test and validate their EIP-712 implementations with confidence.
This guide will show you how to leverage Foundry's EIP-712 commands and cheatcodes with a practical, real-world example, demonstrating how to validate a complex library like Uniswap's PermitHash.sol
from their Permit2 system. This will showcase how to ensure that a custom EIP-712 hashing implementation aligns perfectly with the standard.
EIP-712 commands
Forge offers a couple of commands which are useful when working with EIP-712 types:
forge eip712
Outputs the canonical type definitions of the structs in the target files in the terminal.
forge bind-json
Automatically generates solidity bindings for the structs in the target files. The generated bindings can easily be serialized to JSON strings, and also parsed from JSON strings. Additionally, these bindings also allow the EIP-712 cheatcodes to derive the type definitions just their name.
EIP-712 cheatcodes
Foundry offers several cheatcodes to interact with EIP-712 types:
vm.eip712HashType
- Generates the
typeHash
for an EIP-712 struct definition. This iskeccak256
of the canonical type encoding. - It can take a direct string definition (i.e.
"Mail(address from,string contents)"
) or a type name if you've usedforge bind-json
to generate bindings from your Solidity structs.
vm.eip712HashStruct
- Computes the
structHash
:keccak256(typeHash + encodeData(struct)).
encodeData(struct)
is the ABI-encoded values of the struct's members.- Like
vm.eip712HashType
, it accepts either a direct type definition string or a type name (with bindings).
vm.eip712HashTypedData
- Generates the final EIP-712 digest to be signed:
keccak256("\x19\x01" + domainSeparator + structHash)
. - It takes a full JSON string representing the typed data as per the EIP-712 specification. Useful for end-to-end testing of signature verification.
Testing Uniswap's PermitHash
library
Uniswap's Permit2
system utilizes the PermitHash.sol
library to create hashes that comply with the EIP-712 standard for various permit structures. In this guide, we will demonstrate how to use Foundry to verify that the library correctly implements the EIP-712 hashing rules.
Our objective is to focus on a few hashing functions within PermitHash.sol
. We will provide these functions with sample data and then use vm.eip712HashStruct
—with the same data and the canonical EIP-712 type definition— to determine if the generated hashes match.
Setting up the test environment
Before starting with the validations, we have to create the PermitHash.t.sol
test file.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
// Import the library we are testing
import {PermitHash} from "src/libraries/PermitHash.sol";
import {IAllowanceTransfer as IAT} from "src/interfaces/IAllowanceTransfer.sol";
/* These are the structs, defined in `IAT`, that `PermitHash` relies on:
struct PermitDetails {
address token;
uint160 amount;
uint48 expiration;
uint48 nonce;
}
struct PermitSingle {
PermitDetails details;
address spender;
uint256 sigDeadline;
}
*/
Tip: as previously explained, you can use
forge bind-json
to leverage Foundry's capabilities, and have higher guarantees when testing. By running that command, you can simply use the struct name when using the EIP-712 cheatcodes, and Foundry will automatically derive the canonical type definition.
Validating typHash
First of all, ensure that the type hashes for each of the structs are correct:
contract PermitHashTest is Test {
function test_validatePermitDetails_typeHash() public {
// This test doesn't rely on the bindings generated by `forge json`, therefore it requires
// the string representation of the type as an input for the cheatcode.
// Assume available on Uniswap's library. Otherwise you'd have to copy-paste it manually.
string memory _PERMIT_DETAILS_TYPEDEF =
"PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)";
// The type hash constant defined in Uniswap's library
bytes32 typeHash = PermitHash._PERMIT_DETAILS_TYPEHASH;
// Use the cheatcode to get the expected hash (with string representation)
bytes32 expected = vm.eip712HashType(_PERMIT_DETAILS_TYPEDEF);
assertEq(typeHash, expected, "PermitDetails typeHash mismatch");
}
function test_validatePermitSingle_typeHash() public {
// The type hash constant defined in Uniswap's library
bytes32 typeHash = PermitHash._PERMIT_SINGLE_TYPEHASH;
// Use the cheatcode to get the expected hash (needs bindings)
bytes32 expected = vm.eip712HashType("PermitSingle");
assertEq(typeHash, expected, "PermitSingle typeHash mismatch");
}
}
Validating structHash
After being certain that the hashes of the type definitions are correct, let's validate that the hashes of the structs follow the EIP-712 specification.
contract PermitHashTest is Test {
address TOKEN = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address SPENDER = 0xdEADBEeF00000000000000000000000000000000;
function test_validatePermitDetails_structHash() public {
// This test doesn't rely on the bindings generated by `forge bind-json`, therefore it requires
// the string representation of the type as an input for the cheatcode.
// Assume available on Uniswap's library. Otherwise you'd have to copy-paste it manually.
string memory _PERMIT_DETAILS_TYPEDEF =
"PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)";
// Prepare the test data for PermitDetails
IAllowanceTransfer.PermitDetails memory details = IAllowanceTransfer.PermitDetails({
token: TOKEN,
amount: 100 ether,
expiration: uint48(block.timestamp + 3600),
nonce: 123
});
// Get the structHash from Uniswap's library.
// Despite private, assume it is available with a public function.
bytes32 structHash = PermitHash._hashPermitDetails(details);
// Use the cheatcode to get the expected hash (with string representation)
bytes32 expected = vm.eip712HashStruct(_PERMIT_DETAILS_TYPEDEF, abi.encode(details));
assertEq(structHash, expected, "PermitDetails structHash mismatch");
}
function test_validatePermitSingle_structHash() public {
IAT.PermitDetails memory details = IAT.PermitDetails({
token: TOKEN,
amount: 200 ether,
expiration: uint48(block.timestamp + 7200),
nonce: 456
});
IAT.PermitSingle memory permitSingle = IAT.PermitSingle({
details: details,
spender: SPENDER,
sigDeadline: block.timestamp + 10800
});
// Get the structHash from Uniswap's library.
bytes32 structHash = PermitHash.hash(permitSingle);
// Use the cheatcode to get the expected hash (needs bindings)
bytes32 expectedStructHash = vm.eip712HashStruct("PermitSingle", abi.encode(permitSingle));
assertEq(structHash, expected, "PermitSingle structHash mismatch");
}
}