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:

  1. Name: Enter the desired name for your token. This is typically the full descriptive name, for example, "MagicToken".
  2. Symbol: This is the short form or ticker for your token. For "MagicToken," a symbol could be "MTK".
  3. 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:

Step 3: Enable the Permit Feature

Scroll down to the Features section:

  1. 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. Save the contract address and the ABI for later use


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

primaryType

domain

message

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. Copy the ABI from the Remix IDE

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:

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) :)