EIP-2612 Permit: A Practical Guide to Integrating in Smart Contracts and DApp

The Ethereum Improvement Proposal (EIP) 2612 introduces a streamlined way for users to interact with tokens in Ethereum smart contracts using a feature called “Permit.” The EIP-2612 Permit is built on the EIP-712 standard, allowing users to authorize token transactions with a single signature, simplifying interactions within decentralized applications (DApps).
Traditionally, token transfers in smart contracts require a two-step process: first, the token owner must approve the transaction, then they initiate the transfer. This approach, while secure, can lead to additional gas fees and multiple interactions with the blockchain. EIP-2612 allows users to bypass the typical approve
step, reducing gas fees and improving user experience by streamlining the process.
In this guide, we will explore the EIP-2612 Permit feature, its security implications, and how to implement it in your smart contracts. Additionally, we’ll build an ERC-20 token contract using OpenZeppelin’s library to showcase a practical example of EIP-2612 in action.
What is EIP-2612?
One of the core reasons behind the success of ERC-20 tokens (EIP-20) is the approve
and transferFrom
mechanism, which allows for tokens to not only be transferred between externally owned accounts (EOA), but to be used in other contracts under application specific conditions by abstracting away msg.sender
as the defining mechanism for token access control. However, this mechanism requires the token owner’s explicit approval, executed via their externally owned account (EOA). This double-transaction requirement (approval, followed by transferFrom
within a contract) not only incurs additional gas fees but also demands that the user holds ETH for these transactions.
EIP-2612 extends the ERC-20 standard, adding a permit
function that lets users update their token allowance using a signed message instead of requiring them to send an on-chain transaction. This means that users can authorize transfers by signing data off-chain, passing that signature to a contract, and bypassing the need for the approve
transaction.
With EIP-2612, users can authorize “permit” functions via EIP-712’s signTypedData
signature structure, enabling gas savings while preserving the functionality of the traditional approve
method.
There is an option to save GAS and through the classic approach through
approve
- unlimited approvals, which means that you only need to pay the approval’s gas fee once. This can be a high security risk, since the application keeps this unlimited access indefinitely.
Practical Implementation
To begin, we will write an ERC-20 token smart contract using OpenZeppelin libraries. For those who want to get a ready-made version right away, you can download the project from this article on Github.
Let’s start with the simplest implementation.
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CollectableToken is ERC20 {
constructor()
ERC20("CollectableToken", "CT")
{
}
}
Our basic smart contract is ready. Let’s expand it by allowing unlimited minting for any user and setting the token’s decimal places to 6.
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CollectableToken is ERC20 {
constructor()
ERC20("CollectableToken", "CT")
{
//MINT to the creator's address.
_mint(msg.sender, 1000 * 10 ** decimals());
}
//Unrestricted MINT for example only.
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function decimals() public pure override returns (uint8) {
return 6;
}
}
All that’s left is to add support for Permit according to the EIP-2612 standard. To do this, we need to include the ERC20Permit library and add the constructor ERC20Permit("CollectableToken")
—and that’s it! Pretty simple, right?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract CollectableToken is ERC20, ERC20Permit {
constructor()
ERC20("CollectableToken", "CT")
ERC20Permit("CollectableToken")
{
//MINT to the creator's address.
_mint(msg.sender, 1000 * 10 ** decimals());
}
//Unrestricted MINT for example only.
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function decimals() public pure override returns (uint8) {
return 6;
}
}
These small steps are all that’s required from ERC-20 token smart contract authors to add support for Permit according to the EIP-2612 standard. OpenZeppelin and the standard’s authors have handled the most challenging parts for us — big thanks to them for that!
Now, let’s write a smart contract that will use this token via Permit. For example, let’s create another ERC-20 contract where minting tokens costs a specified amount of CollectableToken
, and we’ll name it YourNewToken
for this example. Normally, a user would first need to authorize YourNewToken
to spend their funds by calling the approve
function and signing a transaction, then call the mint
function in YourNewToken
with a second transaction. With Permit, however, the user will only need to make one transaction on-chain by calling the mint
function directly. This might not be the perfect example, but it’s certainly illustrative.
Let’s get started with the code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract YourNewToken is ERC20, ERC20Permit {
address owner;
address cTokenAddress;
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_;
}
constructor(address collectableTokenAddress)
ERC20("YourNewToken", "YNT")
ERC20Permit("YourNewToken")
{
cTokenAddress = collectableTokenAddress;
owner = msg.sender;
}
function decimals() public pure override returns (uint8) {
return 6;
}
function withdrawCollectableToken(address recipient) public onlyOwner {
IERC20 token = IERC20(cTokenAddress);
uint256 contractBalance = token.balanceOf(address(this));
require(token.transfer(recipient, contractBalance), "Token transfer from YourNewToken failed");
}
}
We’ve created a standard ERC-20 contract with ERC20Permit support, as well as a withdrawCollectableToken
function, which allows the contract owner to withdraw tokens from this smart contract.
For simplicity, we’re using the contract creator as the administrator. In real projects, if your smart contract involves fund accumulation and withdrawals, it is ESSENTIAL to implement functionality for transferring administrative rights, as the administrator’s wallet could be compromised.
Let’s implement the mint
function to issue YourNewToken
. Minting will require the same amount of CollectableToken
.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
interface IERC20PermitWithTransger {
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);
}
contract YourNewToken is ERC20, ERC20Permit {
address owner;
address cTokenAddress;
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_;
}
constructor(address collectableTokenAddress)
ERC20("YourNewToken", "YNT")
ERC20Permit("YourNewToken")
{
cTokenAddress = collectableTokenAddress;
owner = msg.sender;
}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s,
address to,
uint256 amount
) public {
IERC20PermitWithTransger cToken = IERC20PermitWithTransger(cTokenAddress);
cToken.permit(msg.sender, address(this), amount, deadline, v, r, s);
require(cToken.transferFrom(msg.sender, address(this), amount), "Transfer CollectableToken failed");
_mint(to, amount);
}
function withdrawCollectableToken(address recipient) public onlyOwner {
IERC20 token = IERC20(cTokenAddress);
uint256 contractBalance = token.balanceOf(address(this));
require(token.transfer(recipient, contractBalance), "Token transfer from YourNewToken failed");
}
}
To implement the mint
function, we defined the IERC20PermitWithTransfer
interface with permit
and transferFrom
functions.
The mint
function itself calls permit
with the provided signature parameters and permit-related arguments such as deadline
, from
, and to
. It then transfers CollectableToken
from the function caller to the YourNewToken
smart contract address. And that’s it—pretty simple, right?
Testing
Now that we’ve written the code, it’s time to test it. For this, we’ll use HardHat. Let’s start by testing CollectableToken
.
describe("CollectableToken", function () {
it("Deploy contract", async function () {
const ContractFactory = await ethers.getContractFactory("CollectableToken");
const initialOwner = (await ethers.getSigners())[0].address;
const instance = await ContractFactory.deploy();
await instance.waitForDeployment();
expect(await instance.name()).to.equal("CollectableToken");
});
it("Mint", async function () {
const ContractFactory = await ethers.getContractFactory("CollectableToken");
const initialOwner = (await ethers.getSigners())[0].address;
const instance = await ContractFactory.deploy();
await instance.waitForDeployment();
const amount = await instance.balanceOf(initialOwner);
await instance.mint(initialOwner,1000);
const delta = await instance.balanceOf(initialOwner) - amount;
expect(delta).to.equal(1000);
});
});
As part of the tests, we check the ability to deploy and the mint
function. We’ll test Permit
within the YourNewToken
tests.
Let’s move on to testing YourNewToken
. First, we’ll also check the deployment and the fund withdrawal functionality—withdrawCollectableToken
.
describe("YourNewToken", function () {
it("Deploy contract", async function () {
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const initialOwner = (await ethers.getSigners())[0].address;
const instance = await ContractFactory.deploy("0x0000000000000000000000000000000000000000");
await instance.waitForDeployment();
expect(await instance.name()).to.equal("YourNewToken");
});
it("Test withdraw", async function () {
const [initialOwner, user] = await ethers.getSigners();
//ERC-20 with Permit
const CollectableFactory = await ethers.getContractFactory("CollectableToken");
const instanceCollectable = await CollectableFactory.deploy();
await instanceCollectable.waitForDeployment();
// Contract implementing the logic for using Permit
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const instance = await ContractFactory.deploy(await instanceCollectable.getAddress());
await instance.waitForDeployment();
await instanceCollectable.mint(await instance.getAddress(), 100000);
await instance.withdrawCollectableToken(await user.getAddress());
expect(await instanceCollectable.balanceOf(await user.getAddress())).to.equal("100000");
});
});
Now comes the most important part — testing Permit
. This test is crucial for us, as it serves as a key example of code for DApps, meaning it can be shared as an example with front-end developers.
it("Mint with Permint", async function () {
//Get signer from ethers
const [initialOwner] = await ethers.getSigners();
//ERC-20 with Permit
const CollectableFactory = await ethers.getContractFactory("CollectableToken");
const instanceCollectable = await CollectableFactory.deploy();
await instanceCollectable.waitForDeployment();
// Contract implementing the logic for using Permit
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const instance = await ContractFactory.deploy(await instanceCollectable.getAddress());
await instance.waitForDeployment();
// Define the deadline for permit
const deadline = Math.floor(Date.now() / 1000) + 3600; // current time + 1 hour
// Retrieve the nonce
const nonce = await instanceCollectable.nonces(initialOwner.address);
// Generate data for signature (EIP-712)
const domain = {
name: await instanceCollectable.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: await instanceCollectable.getAddress(),
};
//Define types record for signTypedData
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const amount_ = 1000
//Set value for signTypedData
//spender - in our application this is the address of the YourNewToken smartcontrat that uses owner tokens for the mint function.
const value = {
owner: initialOwner.address,
spender: await instance.getAddress(),
value: amount_,
nonce: nonce,
deadline: deadline,
};
// Sign the typed data
const signature = await initialOwner.signTypedData(domain, types, value)
sign = ethers.Signature.from(signature)
await instance.mint(
deadline,
sign.v,
sign.r,
sign.s,
initialOwner.address,
amount_
);
expect(await instance.balanceOf(initialOwner.address)).to.equal(amount_);
});
The most important aspect is the formation of the domain
—based on the data from the smart contract—and the values
—based on the transaction data. Pay close attention to this!
Security Considerations for EIP-2612 Permit:
ERC-20 Permit protect from the risk of replay attacks by using a nonce system.Once a signature is verified and approved, the nonce increases, preventing identical signatures from being reused.
However, because transactions sit in the mempool before execution, an attacker could intercept the signature and use it to execute the permit
function before the user’s transaction is processed. Since this is a valid signature, the token accepts it and increases the nonce, causing the user’s transaction to fail.
While this doesn’t compromise funds, it can disrupt the user experience. To address this risk, developers should consider implementing fallback mechanisms or warnings for users in high-value transactions.
Conclusion:
EIP-2612 provides Solidity developers with a powerful way to streamline token approvals and transfers while reducing gas fees and improving DApp functionality. The examples provided here show how simple it can be to add Permit functionality to ERC-20 tokens.
For DApp developers, integrating Permit can significantly enhance user experience and potentially save users on transaction costs, especially on networks with high gas fees like Ethereum. However, it’s important to maintain the traditional approve
method as a fallback and to incorporate security measures against potential front-running.
The full code is available on github.
Links:
- Martin Lundfall (@Mrchico), “ERC-2612: Permit Extension for EIP-20 Signed Approvals,” Ethereum Improvement Proposals, no. 2612, April 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-2612.
- Remco Bloemen (@Recmo), Leonid Logvinov (@LogvinovLeon), Jacob Evans (@dekz), “EIP-712: Typed structured data hashing and signing,” Ethereum Improvement Proposals, no. 712, September 2017. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-712.
- OpenZeppelin
- Example on Github
- What’s wrong with ERC20Permit? by Oxorio