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 that for an EOA, approve plus subsequent operations require at least two transactions, incurring some extra gas costs. ERC-2612 is an extension of ERC20 that introduces new methods to implement approve.
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 address through ecrecover
- Checking whether the recoveredAddress matches the provided owner address
- Executing the approve logic, modifying the allowance, and emitting the Approval event
The important process is the second step, where the recoveredAddress is parsed from the provided parameters and checked against the provided owner, followed by executing logic similar to: token.approve(spender, value)
to complete the approve.
First, the internal logic is wrapped in unchecked
, which has several uses:
- It resolves the issue of default revert on overflow in Solidity 0.8.0, which conflicts with previous code. If I can ensure that my calculation will not overflow, I can wrap it in unchecked to prevent Solidity from automatically checking for overflow, which would incur additional gas costs.
- After 0.8.0, SafeMath is no longer needed.
Next, the keyword ecrecover
is used, which takes the following input parameters:
- 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 example's DOMAIN_SEPARATOR()
method 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 again using abi.encode:
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
All parameters here are those passed into permit. It is important to note the nonce
field, which uses nonces[owner]++
to maintain an 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 recoveredAddress match the owner?
How to Use ERC-2612#
As mentioned earlier, the permit method can be called to complete the approve proxy call, eliminating the need for the EOA to perform a pre-approve operation. When calling permit, three important parameters r, s, and v are required, which must be provided by the EOA after signing.
Using the following code 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 through ecrecover, the signer is determined to be 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 the rsv to call permit.
The following code encapsulates the permit signing method, defining the Permit structure in accordance 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 pass the rsv of the transaction signature as parameters to call permit.
I can think of potential security vulnerabilities:
Replay Attack: When calculating DOMAIN_SEPARATOR
, the chainID is used as an identifier. If a chain forks and the chainID is set during the constructor, there could be contracts with the same chainID on both chains, allowing a signature on chain A to be replayed on chain B.