What is ERC-2612#
ERC-2612: Permit Extension for EIP-20 Signed Approvals
ERC-2612 is an optimization for the approve function in ERC20. Traditionally, approve must be initiated by an EOA, which means at least two transactions for the EOA: approve + subsequent operation, incurring some additional gas costs. ERC-2612 extends ERC20 by introducing a new method to achieve approval.
ERC-2612 requires the implementation of three new methods:
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external
function nonces(address owner) external view returns (uint)
function DOMAIN_SEPARATOR() external view returns (bytes32)
The key point is the permit method, which will also modify the approval structure in ERC20 and emit an event.
The permit method requires seven parameters:
- owner: the current token owner
- spender: the authorized person
- value: the amount to approve
- deadline: the deadline for executing the transaction
- v: transaction signature data
- r: transaction signature data
- s: transaction signature data
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);
}
Usage Process of ERC-2612#
Permit#
The main process of permit includes:
- Validating the current transaction deadline
- Parsing the recoveredAddress through ecrecover
- Checking if the recoveredAddress matches the provided owner address
- Executing the approve logic, modifying the allowance, and emitting the Approval event
The important step is the second one, where the recoveredAddress is parsed from the provided parameters and checked against the provided owner before executing logic similar to: token.approve(spender, value)
to complete the approval.
First, the internal logic is wrapped in unchecked
. Using unchecked has several purposes:
- It resolves the issue of default revert for overflow in Solidity 0.8.0 and later, which conflicts with previous code. If I can ensure that my operation won't overflow, I can wrap it in unchecked to prevent Solidity from automatically checking for overflow, which would incur additional gas costs.
- SafeMath is no longer needed after 0.8.0.
Next, the keyword ecrecover
is used. The input parameters for this method are:
- bytes32: the signed message
- uint8: v
- bytes32: r
- bytes32: s
The returned parameter is the signed address recoveredAddress.
Since r, v, and s are all input parameters, let's continue to see what the signed message is.
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
)
)
)
)
keccak256
is a built-in method in Solidity used to compute hashes, and abi.encodePacked
tightly encodes the input parameters (omitting zero padding to save space when not interacting with contracts).
DOMAIN_SEPARATOR#
There is also a method: DOMAIN_SEPARATOR()
, which is required to be implemented in ERC-2612. The purpose of this field is to uniquely identify each contract on each chain and meet the requirements of EIP-712 (a standard structured signature protocol that ensures the security of off-chain signatures and on-chain verification, where the domain separator is a unique identifier to prevent replay attacks).
function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator();
}
function computeDomainSeparator() internal view virtual returns (bytes32) {
return
keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256("1"),
block.chainid,
address(this)
)
);
}
The DOMAIN_SEPARATOR()
method in this example uses keccak256 to hash the contract name, chainID, version number, current contract address, and other information to derive a unique identifier.
After calculating DOMAIN_SEPARATOR
, it is encoded using abi.encode:
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
The parameters here are all passed into permit. It is important to note the nonce
field, which uses nonces[owner]++
to maintain an auto-incrementing user nonce through a custom array, preventing the same signed transaction from being reused.
Through this process, the recoveredAddress is parsed from the hashed signature information. So why must the parsed recoveredAddress match the owner?
How to Use ERC-2612#
As mentioned earlier, the permit method can be called to complete the proxy call for approval, eliminating the need for the EOA to perform the pre-approval operation. When calling permit, three very important parameters r, s, and v are required, which must be provided by the EOA after signing.
Using the following code snippet from Forge as an example:
address alice = vm.addr(1);
bytes32 hash = keccak256("Signed by Alice");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash);
address signer = ecrecover(hash, v, r, s);
assertEq(alice, signer); // [PASS]
Alice signs the hash, returning the parameters v, r, and s, and then ecrecover gives the signer as Alice.
To pass the validation logic in permit, we also need to construct a consistent signature content for the EOA to sign, and then we can use rsv to call permit.
The following code is a wrapper for the permit signing method, defining the Permit structure consistent with the calculation method in ERC-2612.
// 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)
)
);
}
}
Security Issues of ERC-2612#
ERC-2612 essentially adds a set of validation logic on top of approve, allowing users to generate transaction signatures off-chain in advance and then pass the rsv of the transaction signature as parameters to call permit.
I can think of potential security vulnerabilities:
Replay attacks: When calculating DOMAIN_SEPARATOR
, the chainID is used as an identifier. If a chain forks and the chainID is set during the constructor, there will be contracts with the same chainID on both chains, allowing a signature on chain A to be replayed on chain B.