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: UNLICENSED
pragma solidity 0.8.13;
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)
)
);
}
}
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 appprove 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.