Testing EIP-712 Signatures
Intro
EIP-712 introduced the ability to sign transactions off-chain which other users can later execute on-chain. A common example is EIP-2612 gasless token approvals.
Traditionally, setting a user or contract allowance to transfer ERC-20 tokens from an owner's balance required the owner to submit an approval on-chain. As this proved to be poor UX, DAI introduced ERC-20 permit
(later standardized as EIP-2612) allowing the owner to sign the approval off-chain which the spender (or anyone else!) can submit on-chain prior to the transferFrom
.
This guide will cover testing this pattern in Solidity using Foundry.
Diving In
First we'll cover a basic token transfer:
- Owner signs approval off-chain
- Spender calls
permit
andtransferFrom
on-chain
We'll use Solmate's ERC-20, as EIP-712 and EIP-2612 batteries come included. Take a glance over the full contract if you haven't already - here is permit
implemented:
/*//////////////////////////////////////////////////////////////
EIP-2612 LOGIC
//////////////////////////////////////////////////////////////*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");
// Unchecked because the only math done is incrementing
// the owner's nonce which cannot realistically overflow.
unchecked {
address recoveredAddress = ecrecover(
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
)
)
),
v,
r,
s
);
require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");
allowance[recoveredAddress][spender] = value;
}
emit Approval(owner, spender, value);
}
We'll also be using a custom SigUtils
contract to help create, hash, and sign the approvals off-chain.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract SigUtils {
bytes32 internal DOMAIN_SEPARATOR;
constructor(bytes32 _DOMAIN_SEPARATOR) {
DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
}
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
struct Permit {
address owner;
address spender;
uint256 value;
uint256 nonce;
uint256 deadline;
}
// computes the hash of a permit
function getStructHash(Permit memory _permit)
internal
pure
returns (bytes32)
{
return
keccak256(
abi.encode(
PERMIT_TYPEHASH,
_permit.owner,
_permit.spender,
_permit.value,
_permit.nonce,
_permit.deadline
)
);
}
// computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer
function getTypedDataHash(Permit memory _permit)
public
view
returns (bytes32)
{
return
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
getStructHash(_permit)
)
);
}
}
Handling Dynamic Values
While the Permit struct passed in the getStructHash() function above doesn't contain any dynamic value types, if you're using them it's important to remember that 'bytes' and 'string' types must be encoded as a 'keccak256' hash of their contents. More on this aspect of the EIP 712 Spec here.
Setup
- Deploy a mock ERC-20 token and
SigUtils
helper with the token's EIP-712 domain separator - Create private keys to mock the owner and spender
- Derive their addresses using the
vm.addr
cheatcode - Mint the owner a test token
contract ERC20Test is Test {
MockERC20 internal token;
SigUtils internal sigUtils;
uint256 internal ownerPrivateKey;
uint256 internal spenderPrivateKey;
address internal owner;
address internal spender;
function setUp() public {
token = new MockERC20();
sigUtils = new SigUtils(token.DOMAIN_SEPARATOR());
ownerPrivateKey = 0xA11CE;
spenderPrivateKey = 0xB0B;
owner = vm.addr(ownerPrivateKey);
spender = vm.addr(spenderPrivateKey);
token.mint(owner, 1e18);
}
Testing: permit
- Create an approval for the spender
- Compute its digest using
sigUtils.getTypedDataHash
- Sign the digest using the
vm.sign
cheatcode with the owner's private key - Store the
uint8 v, bytes32 r, bytes32 s
of the signature - Call
permit
with the signed permit and signature to execute the approval on-chain
function test_Permit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
assertEq(token.allowance(owner, spender), 1e18);
assertEq(token.nonces(owner), 1);
}
- Ensure failure for calls with an expired deadline, invalid signer, invalid nonce, and signature replay
function testRevert_ExpiredPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
vm.warp(1 days + 1 seconds); // fast forward one second past the deadline
vm.expectRevert("PERMIT_DEADLINE_EXPIRED");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
function testRevert_InvalidSigner() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(spenderPrivateKey, digest); // spender signs owner's approval
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
function testRevert_InvalidNonce() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 1, // owner nonce stored on-chain is 0
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
function testRevert_SignatureReplay() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
Testing: transferFrom
- Create, sign, and execute an approval for the spender
- Call
tokenTransfer
as the spender using thevm.prank
cheatcode to execute the transfer
function test_TransferFromLimitedPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.prank(spender);
token.transferFrom(owner, spender, 1e18);
assertEq(token.balanceOf(owner), 0);
assertEq(token.balanceOf(spender), 1e18);
assertEq(token.allowance(owner, spender), 0);
}
function test_TransferFromMaxPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: type(uint256).max,
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.prank(spender);
token.transferFrom(owner, spender, 1e18);
assertEq(token.balanceOf(owner), 0);
assertEq(token.balanceOf(spender), 1e18);
assertEq(token.allowance(owner, spender), type(uint256).max);
}
- Ensure failure for calls with an invalid allowance and invalid balance
function testFail_InvalidAllowance() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 5e17, // approve only 0.5 tokens
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.prank(spender);
token.transferFrom(owner, spender, 1e18); // attempt to transfer 1 token
}
function testFail_InvalidBalance() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 2e18, // approve 2 tokens
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.prank(spender);
token.transferFrom(owner, spender, 2e18); // attempt to transfer 2 tokens (owner only owns 1)
}
Bundled Example
Here is a section of a mock contract that just deposits ERC-20 tokens. Note how deposit
requires a preliminary approve
or permit
tx in order to transfer tokens, while depositWithPermit
sets the allowance and transfers the tokens in a single tx.
/// ///
/// DEPOSIT ///
/// ///
/// @notice Deposits ERC-20 tokens (requires pre-approval)
/// @param _tokenContract The ERC-20 token address
/// @param _amount The number of tokens
function deposit(address _tokenContract, uint256 _amount) external {
ERC20(_tokenContract).transferFrom(msg.sender, address(this), _amount);
userDeposits[msg.sender][_tokenContract] += _amount;
emit TokenDeposit(msg.sender, _tokenContract, _amount);
}
/// ///
/// DEPOSIT w/ PERMIT ///
/// ///
/// @notice Deposits ERC-20 tokens with a signed approval
/// @param _tokenContract The ERC-20 token address
/// @param _amount The number of tokens to transfer
/// @param _owner The user signing the approval
/// @param _spender The user to transfer the tokens (ie this contract)
/// @param _value The number of tokens to approve the spender
/// @param _deadline The timestamp the permit expires
/// @param _v The 129th byte and chain id of the signature
/// @param _r The first 64 bytes of the signature
/// @param _s Bytes 64-128 of the signature
function depositWithPermit(
address _tokenContract,
uint256 _amount,
address _owner,
address _spender,
uint256 _value,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external {
ERC20(_tokenContract).permit(
_owner,
_spender,
_value,
_deadline,
_v,
_r,
_s
);
ERC20(_tokenContract).transferFrom(_owner, address(this), _amount);
userDeposits[_owner][_tokenContract] += _amount;
emit TokenDeposit(_owner, _tokenContract, _amount);
}
Setup
- Deploy the
Deposit
contract, a mock ERC-20 token, andSigUtils
helper with the token's EIP-712 domain separator - Create a private key to mock the owner (the spender is now the
Deposit
address) - Derive the owner address using the
vm.addr
cheatcode - Mint the owner a test token
contract DepositTest is Test {
Deposit internal deposit;
MockERC20 internal token;
SigUtils internal sigUtils;
uint256 internal ownerPrivateKey;
address internal owner;
function setUp() public {
deposit = new Deposit();
token = new MockERC20();
sigUtils = new SigUtils(token.DOMAIN_SEPARATOR());
ownerPrivateKey = 0xA11CE;
owner = vm.addr(ownerPrivateKey);
token.mint(owner, 1e18);
}
Testing: depositWithPermit
- Create an approval for the
Deposit
contract - Compute its digest using
sigUtils.getTypedDataHash
- Sign the digest using the
vm.sign
cheatcode with the owner's private key - Store the
uint8 v, bytes32 r, bytes32 s
of the signature- Note: can convert to bytes via
bytes signature = abi.encodePacked(r, s, v)
- Note: can convert to bytes via
- Call
depositWithPermit
with the signed approval and signature to transfer the tokens into the contract
function test_DepositWithLimitedPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: address(deposit),
value: 1e18,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
deposit.depositWithPermit(
address(token),
1e18,
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
assertEq(token.balanceOf(owner), 0);
assertEq(token.balanceOf(address(deposit)), 1e18);
assertEq(token.allowance(owner, address(deposit)), 0);
assertEq(token.nonces(owner), 1);
assertEq(deposit.userDeposits(owner, address(token)), 1e18);
}
function test_DepositWithMaxPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: address(deposit),
value: type(uint256).max,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
deposit.depositWithPermit(
address(token),
1e18,
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
assertEq(token.balanceOf(owner), 0);
assertEq(token.balanceOf(address(deposit)), 1e18);
assertEq(token.allowance(owner, address(deposit)), type(uint256).max);
assertEq(token.nonces(owner), 1);
assertEq(deposit.userDeposits(owner, address(token)), 1e18);
}
- Ensure failure for invalid
permit
andtransferFrom
calls as previously shown
TLDR
Use Foundry cheatcodes addr
, sign
, and prank
to test EIP-712 signatures in Foundry.
All source code can be found here.