ERC20 Meta Transactions with Ethers.js (v6)
Overview
This article serves as a practical guide to signing EIP-712 (opens in a new tab) Typed Data with Ethers.js (v6) for gasless fungible token (ERC-20 (opens in a new tab)) approvals (ERC-2612 (opens in a new tab)). If some or all of these terms are unfamiliar to you, don't worry; I'll cover them all in detail.
The Ethers.js library is my favorite tool for interacting with EVM blockchains, thanks to its comprehensive documentation and user-friendly API. However, its latest version, v6, brought with it several breaking changes. Additionally, it introduced new APIs like the TypedDataEncoder – a topic I'll explore in this piece – which aren't extensively covered in the sample codes found online. My aim with this article is to assist you in navigating these new APIs, or to help you kickstart your journey with Ethers.js, hopefully saving you some valuable time in the process. Enjoy the reading ✨
Concepts and Terminology
What is a Meta Transaction?
Meta Transactions are akin to gift cards: they hold value and can be traded for goods or cash, provided one adheres to their terms of service and doesn't attempt to cash an expired gift card. In the Web3 context, these rules are set by smart contracts. In this article, I'll demonstrate how to use the Ethers v6 library to perform gasless transfers of ERC-20 (opens in a new tab) tokens supporting the ERC-2612 (opens in a new tab) standard, a prevalent use case for meta transactions.
In a way, meta transactions resemble unexecuted transactions, much like prior authorizations you might observe in your banking app. However, unlike those authorizations, meta transactions don't reserve any funds from your balance. It's akin to issuing a check without offering any assurances to the recipient until they cash it.
ERC-20 (opens in a new tab), EIP-712 (opens in a new tab), and ERC-2612 (opens in a new tab) Explained
ERC-20 (opens in a new tab): The ERC-20 standard is foundational to the Ethereum token ecosystem. If you've encountered terms like "tokens" or "altcoins" on the Ethereum platform, they likely adhere to this standard.
EIP-712 (opens in a new tab): EIP-712 sets a standard for structuring and signing data within Ethereum's digital realm. This ensures the data remains verifiable and untampered. EIP-712 permits Web3 wallets, such as MetaMask, to present users with the exact data they're about to sign.
ERC-2612 (opens in a new tab): ERC-2612 is an extension of the ERC-20 (opens in a new tab) standard. It introduces the "Permit" function, significantly enhancing the user experience. Through this, token holders can greenlight a token transfer by simply signing a message.
Creating an ERC20 Token with the Permit Feature
In this section, we'll use the OpenZeppelin Wizard to create an ERC20 Token with the Permit feature enabled. The Permit extension allows our token to support the ERC-2612 standard, which I mentioned earlier. So, let's start building!
Step 1: Open the OpenZeppelin Wizard
Below is the embedded OpenZeppelin Wizard. It provides an intuitive interface to craft your custom ERC20 token with the Permit feature.
You may be a bit overwhelmed by the many options in the wizard, but don't worry; I'll guide you through the process.
Step 2: Token Configuration
In the OpenZeppelin Wizard:
- Name: Enter the desired name for your token. This is typically the full descriptive name, for example, "MagicToken".
- Symbol: This is the short form or ticker for your token. For "MagicToken," a symbol could be "MTK".
- Premint: This is the amount of tokens that will be minted (created) when the contract is deployed. For example, if you enter "1000000", then 1 million tokens will be created when the contract is deployed and will be credited to the account you used to deploy the contract.
Let's set the following values for our token:
- Name: MagicToken
- Symbol: MTK
- Premint: 1000000 (1,000,000 tokens)
Step 3: Enable the Permit Feature
Scroll down to the Features
section:
- Find and enable the Permit feature. This will integrate the ERC-2612 standard into your token, allowing for gasless approvals via meta transactions.
Step 4: Open in Remix
Once you've made all the desired selections, press the "Open in Remix" button at the top of the OpenZeppelin Wizard. Clicking on this button will take you to Remix with your custom token contract loaded, allowing you to compile and deploy it from there.
Step 5: Deploy the Contract
For the purpose of this article, we'll be deploying the contract on the Polygon Mumbai Testnet. Unfortunately, this topic is too broad to cover here. If you're not familiar with deploying contracts, I recommend you check out this guide (opens in a new tab) by QuickNode:
That's it! You've successfully created your own ERC20 token supporting the Permit feature compliant with the ERC-2612 standard. Now, let's explore how to use the Ethers.js library to sign the data for gasless approvals. We're almost there!
Please save the contract address; we will need it later.
Signing EIP-712 Typed Data with Ethers.js (v6)
Step 1: Installation
To kick things off, let's add version 6 of the ethers library to your project:
yarn add ethers@6
# OR FOR NPM USERS:
# npm i ethers@6
Step 2: Initialization
Once installed, you'll need to import the library into your project.
import ethers from 'ethers';
Step 3: TypedData Structure
Before diving into the signing process, it's essential to outline the data structure we're aiming to sign, in line with the EIP-712 standard. The ERC-2612 prescribes the following structure (opens in a new tab) for signing:
{
"types": {
"EIP712Domain": [
{
"name": "name",
"type": "string"
},
{
"name": "version",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
},
{
"name": "verifyingContract",
"type": "address"
}
],
"Permit": [
{
"name": "owner",
"type": "address"
},
{
"name": "spender",
"type": "address"
},
{
"name": "value",
"type": "uint256"
},
{
"name": "nonce",
"type": "uint256"
},
{
"name": "deadline",
"type": "uint256"
}
],
},
"primaryType": "Permit",
"domain": {
"name": erc20name,
"version": version,
"chainId": chainid,
"verifyingContract": tokenAddress
},
"message": {
"owner": owner,
"spender": spender,
"value": value,
"nonce": nonce,
"deadline": deadline
}
}
It's quite a chunk, isn't it? But don't fret, the Ethers.js library will handle most of the heavy lifting. This structure is defined by the EIP-712 standard. Let's break it down a bit:
types
- The
types
field outlines the data types you're signing. For this article, we'll align them with the ERC-2612 standard requirements (opens in a new tab). This might look intimidating, but remember, the Ethers.js library will manage most of this for you.
primaryType
- The
primaryType
field specifies the starting point type for encoding the data. Typically, the library you're using sets this automatically based on the types you've provided. Ethers.js does precisely that.
domain
- The
domain
field establishes your application's domain as per the EIP-712 standard. This domain is a set of parameters that identify the signing domain. It offers additional context to users when signing a message and ensures the signature is exclusive to a specific version of a particular smart contract on a specific blockchain. After all, no one wants to approve a million Shiba tokens only to discover their signature authorized a million USDT transfer to a random address, right? :)
message
- The
message
field defines the actual data you're signing. In our scenario, it's straightforward: we just need to specify the owner, spender, value, nonce, and deadline as outlined in thetypes
field.
Feeling more comfortable now? Let's see how it all fits together.
Step 4: Setting the Domain
Firstly, establish your application's domain:
const domain = {
name: 'MagicToken', // Your token's name
version: '1', // Your token's version. For this article, we'll stick with 1.
chainId: 80001, // The network's Chain ID where you deployed your contract. For the Polygon Mumbai Testnet, it's 80001.
verifyingContract: '[CONTRACT_ADDRESS]' // The contract address you deployed earlier.
};
Ensure you replace the [CONTRACT_ADDRESS]
with your previously deployed contract's address.
Feel free to adjust the name, version, and chainId as needed. However, remember it should always align with where you deployed your contract and the name and version set within the contract.
For this article, we're focusing on OpenZeppelin's implementation of the ERC-2612 standard. Thus, the version will always be 1
, and the name is determined in the contract's constructor, which defaults to your token name (in our case, MagicToken
). Here's an example of the contract constructor where the name is set to MagicToken
within the ERC20Permit(name)
modifier:
contract MagicToken is ERC20, ERC20Permit {
constructor() ERC20("MagicToken", "MTK") ERC20Permit("MagicToken") {}
}
For a deeper dive into OpenZeppelin's ERC-2612 standard implementation, check out: ERC20Permit (opens in a new tab)
Step 5: Setting the Types
This step is straightforward. Just copy and paste the Permit type definition from Step 3:
const types = {
Permit: [
{
name: "owner",
type: "address",
},
{
name: "spender",
type: "address",
},
{
name: "value",
type: "uint256",
},
{
name: "nonce",
type: "uint256",
},
{
name: "deadline",
type: "uint256",
},
],
};
Step 6: Ethers.js Magic ✨ (TypedDataEncoder)
Ethers.js v6 introduces the nifty TypedDataEncoder
utility class, making encoding data as per the EIP-712 standard a breeze. While it was an experimental feature in the library's previous version (v5), it's now a stable feature in v6! 🎉
However, it's generally best to use the higher-level signer.signTypedData
function instead of directly using the utility class. Let's craft a helper function for this. The domain
and types
are variables we defined earlier, and the signer is the Signer
class instance from the Ethers.js library. We'll delve into the signer
aspect shortly, but for now, let's assume we have it in place.
import { Signer } from 'ethers';
interface Payload {
owner: string;
spender: string;
value: BigInt;
nonce: string;
deadline: number;
}
async function signTokenPermit (signer: Signer, payload: Payload) {
return await signer.signTypedData(domain, types, payload);
};
Step 7: Creating the Signer
Now, let's craft the signer. The process differs slightly between client-side and server-side, so we'll explore both.
Client-Side
On the client-side, you'd typically use the BrowserProvider
, previously known as Web3Provider
in older library versions. This provider grants access to the provider
exposed by Web3 wallets like MetaMask, WalletConnect, and others. Thanks to the EIP-1193 (opens in a new tab) standard, the ethers.js library can seamlessly work with any Web3 wallet that supports it, allowing data signing.
If you're a TypeScript user, you'll need to create a global declaration for the ethereum
object. Achieve this by creating a global.d.ts
file at your project's root with the following content:
import type { BrowserProvider, Eip1193Provider } from 'ethers';
declare global {
interface Window {
ethereum: Eip1193Provider & BrowserProvider;
}
}
Next, let's devise a helper function to retrieve the signer:
import { BrowserProvider } from 'ethers';
async function getSigner() {
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
return signer;
}
Server-Side
On the server-side, you'd typically employ the Wallet
implementation of the Signer
class. This allows data signing using the account's private key that you have access to. Here's a helper function to retrieve the signer:
import { JsonRpcProvider, Wallet } from 'ethers';
const provider = new JsonRpcProvider(
'https://rpc.ankr.com/polygon_mumbai'
); // If you're targeting a chain other than the Polygon Mumbai Testnet, modify the RPC URL accordingly.
async function getSigner() {
const signer = new Wallet('[PRIVATE_KEY]', provider); // Replace [PRIVATE_KEY] with the private key of the account you wish to use for data signing.
return signer;
}
Step 8: Contract Instance
Let's now create a helper function to obtain the contract instance. First, you'll need your newly crafted contract's ABI. Retrieve this from the Remix IDE by selecting the ABI
button in the Compile
tab. This action will copy the ABI to your clipboard.
The ABI facilitates interaction with the contract. It's a JSON representation of the contract interface, informing the Ethers.js library about available functions, their expected arguments, and their returns. Dive deeper into it here: Contract ABI Specification (opens in a new tab)
Create an abi.json
file and paste the copied ABI from the Remix IDE, ensuring it's accessible for later use.
Now, let's craft a helper function to obtain the contract instance:
import abi from './abi.json'; // Import the ABI from the previous step.
import { Contract } from 'ethers';
async function getContract(signer: Signer, address: string) {
const contract = new Contract(address, abi, signer);
return contract;
}
Step 9: Payload Helper
Lastly, let's design a helper function to generate the payload for the signTokenPermit
function we created in Step 6. This function will require:
signer
: An instance of the Ethers.js library'sSigner
class. We've already discussed helper functions for this in a previous step.spender
: The address of the account you're authorizing to spend your tokens.value
: The token amount you're approving for spending.
async function getCurrentTimestamp(signer: Signer) {
if (!signer.provider) throw new Error('Signer must be connected to a provider');
const currentBlock = await signer.provider.getBlock('latest');
if (!currentBlock) throw new Error('Could not fetch current block');
return currentBlock.timestamp;
}
async function getPayload(signer: Signer, spender: string, value: BigInt): Promise<Payload> {
const contract = await getContract(signer, '[CONTRACT_ADDRESS]'); // Replace [CONTRACT_ADDRESS] with your previously deployed contract's address.
const owner = await signer.getAddress(); // Retrieve the owner's address.
const deadline = await getCurrentTimestamp(signer) + 3600; // Set the deadline to 1 hour from the current time. Adjust as needed. A value of -1 makes it valid indefinitely.
const nonce = await contract.nonces(owner); // Fetch the owner's nonce.
const payload = {
owner,
spender,
value,
nonce,
deadline,
};
return payload;
}
Step 10: Issuing the Permit
Now, let's bring it all together:
import { parseUnits } from 'ethers';
async function issuePermit() {
const spender = '[SPENDER_ADDRESS]'; // Replace [SPENDER_ADDRESS] with the address of the account you're authorizing to spend your tokens.
const decimals = 18; // Adjust for your token's decimals. By default, it's 18. More info: https://docs.openzeppelin.com/contracts/3.x/erc20#a-note-on-decimals
const value = parseUnits('[VALUE]', decimals); // Replace [VALUE] with the token amount you're approving for spending, e.g., 1, 10.5, or 69.05. Note that value is a BigInt, so it doesn't behave like a regular number. BigInts prevent precision errors since values can be quite large. It's calculated as: [VALUE] * 10^decimals. For instance, 1 * 10^18 = 1000000000000000000.
const signer = await getSigner();
const payload = await getPayload(signer, spender, value);
const signature = await signTokenPermit(signer, payload); // This signature allows the spender to use your tokens.
return {
signature,
payload,
};
}
Step 11: Applying the Permit
Fantastic! Now that we have a streamlined permit issuance process, how will the spender utilize it? Let's devise a helper function for this:
import type { SignatureLike } from 'ethers';
import { Signature } from 'ethers';
async function applyPermit(payload: Payload, signature: SignatureLike) {
const signer = await getSigner();
const contract = await getContract(signer, '[CONTRACT_ADDRESS]'); // Replace [CONTRACT_ADDRESS] with your previously deployed contract's address.
const spender = await signer.getAddress(); // Assuming the spender is now invoking this function with their wallet connected to the app, we can retrieve their address from the signer.
const signatureInstance = Signature.from(signature);
const { value, deadline, owner } = payload;
const tx = await contract.permit(
owner, // Token owner
spender,
value,
deadline,
signatureInstance.v,
signatureInstance.r,
signatureInstance.s,
).send();
return tx;
}
Conclusion
And there you have it! You've now mastered the art of signing EIP-712 Typed Data with Ethers.js (v6) for gasless ERC-20 token approvals (ERC-2612). I hope you found this guide helpful and picked up some new insights. Feel free to ping me on Twitter if you have any questions: @ClockRide (opens in a new tab) :)