logo
A Practical Guide To Gasless Token Transfer
by John Oba - AfrodevNov 10, 2022 • 10 min read
Image Banner

This is part 2 of “EIP-3009: Meta Transaction and Atomic Interactions” Navigate to part 1 and complete the introduction before continuing

EIP-3009: Introduction to meta transactions and atomic interactions Part 1

For dApp developers, one popular case study has been on how we can optimize gas, and improve onboarding/UX for new users. Our case study in this article is to implement a gasless transfer of USDC tokens from one address to another.

In this article we will:

  • Build a dApp that allows you to send USDC without holding ETH.
  • Learn about eth_signTypedData_v4 RPC method, handling and verifying signatures
  • Understand EIP-712 message specification

Below is a flow diagram describing this implementation.

Meta Transaction

This is summarized into three major steps.

  • Process message data
  • Get v, r, and s from signature
  • Call transferWithAuthorization in smart contract

Getting started
We will be using Vue 3 to build the simple UI for our dApp. The logic is abstracted to a javascript file to aid interoperability, with React, Angular, or Vanilla.

This is a simple folder structure for our dApp.

Meta Transaction Folder Structure

you can clone this project here to code along, and setup the project by installing dependencies.

cd sendUsdc && npm install && cp .env.example .env  

in the .env file, change the value of MASTERWALLET to your preferred private key.

REMINDER: Masterwallet or paymaster is a wallet that submits the transaction and pays the GAS fees on behalf of the users. There should be ETH in it.

run the local server with

npm run dev

Code preview

In the file src/pages/IndexPage.vue, we have methods such as connectWallet(), checkIfWalletIsConnected(), and makeTransfer().

Check if the user is authenticated

    async checkIfWalletIsConnected() {
      try {
        const { ethereum } = window;

        /*
         * Check if we're authorized to access the user's wallet
         */
        const accounts = await ethereum.request({ method: "eth_accounts" });

        // Validate that we have an account
        if (accounts.length !== 0) {
          const account = accounts[0];

          // Set the current account
          this.setCurrentAccount(account);

          // Display a success message to the user that they are connected
          console.log("🦄 Wallet is Connected!");
        } else {
          console.log("Make sure you have MetaMask Connected!");
        }
      } catch (error) {
        console.log(`${error.message}`);
      }
    },

Connect Wallet

this function requests the account from the Ethereum provider installed on the browser. usually metamask or coinbase wallet. The RPC method used here is eth_requestAccounts

    async connectWallet() {
      try {
        // get injected ethereum from metamask
        const { ethereum } = window;
        if (!ethereum) {
          alert("Ethereum Provider does not exist, get metamask");
          return;
        }

        // request accounts
        const accounts = await ethereum.request({
          method: "eth_requestAccounts",
        });
        this.setCurrentAccount(accounts[0]);
      } catch (error) {
        console.log(error);
      }
    },

Application State

This is the local state for the application. currentAccount represents the user’s wallet address, recipient is the address the user is sending to

 data() {
    return {
      currentAccount: "",
      recipient: "",
      amount: null,
      sending: false,
      hash: null,
    };
  },

Process message data and get signature

The messaging standard conforms with EIP-712 typed data. This should look like this

const data = {
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    TransferWithAuthorization: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
    ],
  },
  domain: {
    name: tokenName,
    version: tokenVersion,
    chainId: selectedChainId,
    verifyingContract: tokenAddress,
  },
  primaryType: "TransferWithAuthorization",
  message: {
    from: userAddress,
    to: recipientAddress,
    value: amountBN.toString(10),
    validAfter: 0,
    validBefore: Math.floor(Date.now() / 1000) + 3600, // Valid for an hour
    nonce: Web3.utils.randomHex(32),
  },
};

Now that the message is prepared, it will be sent to the user to sign. We will be using the eth_signTypedData_v4 rpc method.

const signature = await ethereum.request({
  method: "eth_signTypedData_v4",
  params: [userAddress, JSON.stringify(data)],
});

The message request looks like this

Message request

The nonce value is a random 32 bytes generated using Web3.randomHex(32), valid before 1 hour.

Get v, r, and s values

v, r, s are the values for the transaction's signature. They can be used as in getting the public key of any Ethereum account. A little more information can be seen here.

When the user signs the message, it generates the transaction signature. The v, r, and s is abstracted from the signature as follows

const v = "0x" + signature.slice(130, 132);
const r = signature.slice(0, 66);
const s = "0x" + signature.slice(66, 130);

Contract Interaction

We will be using USDC contract on the goerli testnet to achieve this. Let’s create a token contract object.

import {
  providers,
  Contract,
  Wallet,
} from "ethers";
import Web3 from "web3";
import abi from "./abi.json";

export default class USDCContract {
  ...
  async tokenContract(signer) {
    const connectedContract = new Contract(this.contractAddress, abi, signer);
    return connectedContract;
  }
  ...
}

USDC contract address on goerli: 0x07865c6E87B9F70255377e024ace6630C1Eaa37F

We can get the USDC contract abi here.

Master Wallet

Let’s create the signer for our MasterWallet which will make the contract interaction on behalf of the user.

async transferWithAuthorization(data) {
  
  const masterWallet = new ethers.Wallet(process.env.MASTERWALLET);

  // generate a signer for the master wallet by connecting to a RPC provider
  const signer = masterWallet.connect(
    new providers.JsonRpcProvider(
      "https://eth-goerli.g.alchemy.com/v2/API_KEY" // create an alchemy account to get api key
    )
  );

  const contract = await this.tokenContract(signer)
  
  // call the contract method using the master wallet
  const tx = await contract.transferWithAuthorization(
    data.message.from,
    data.message.to,
    data.message.value,
    data.message.validAfter,
    data.message.validBefore,
    data.message.nonce,
    data.v,
    data.r,
    data.s
  );

  return tx;
 }

Putting it all together,

import {
  providers,
  Contract,
  Wallet,
} from "ethers";
import Web3 from "web3";
import abi from "./abi.json";

export default class USDCContract {
  provider;
  contractAddress;
  constructor(provider, contractAddress) {
    this.provider = new providers.Web3Provider(provider);
    this.contractAddress = contractAddress;
  }
  async tokenContract(signer) {
    const connectedContract = new Contract(this.contractAddress, abi, signer);
    return connectedContract;
  }
  async processSignature(amount, from, to) {
    // timestamp for expiry of this signature
    const valueBefore = Math.floor(Date.now() / 1000) + 3600;
    // random nonce.
    const nonce = Web3.utils.randomHex(32);

    // message data
    const data = {
      types: {
        EIP712Domain: [
          { name: "name", type: "string" },
          { name: "version", type: "string" },
          { name: "chainId", type: "uint256" },
          { name: "verifyingContract", type: "address" },
        ],
        TransferWithAuthorization: [
          { name: "from", type: "address" },
          { name: "to", type: "address" },
          { name: "value", type: "uint256" },
          { name: "validAfter", type: "uint256" },
          { name: "validBefore", type: "uint256" },
          { name: "nonce", type: "bytes32" },
        ],
      },
      // this helps the user validate the token
      domain: {
        name: "USD Coin",
        version: "2",
        chainId: 5,
        // the contract address of USDC
        verifyingContract: this.contractAddress,
      },
      primaryType: "TransferWithAuthorization",
      message: {
        from: from,
        to: to,
        value: Math.pow(10, 6) * amount,
        validAfter: 0,
        validBefore: valueBefore, // Valid for an hour
        nonce: nonce,
      },
    };

    const signature = await this.provider.provider.request({
      method: "eth_signTypedData_v4",
      params: [from, JSON.stringify(data)],
    });

    // v, r and s from signature
    const v = "0x" + signature.slice(130, 132);
    const r = signature.slice(0, 66);
    const s = "0x" + signature.slice(66, 130);
    data.v = v;
    data.s = s;
    data.r = r;
    return data;
  }

  // delegate call to master wallet
  async transferWithAuthorization(data) {
    // create master wallet with private key
    // master wallet pays the gas fee on behalf of the user
    const masterWallet = new Wallet(process.env.MASTERWALLET);

    // generate a signer for the master wallet by connecting to a provider
    const signer = masterWallet.connect(
      new providers.JsonRpcProvider(
        "https://eth-goerli.g.alchemy.com/v2/MbhgMXsdTaM8wSMpDfz5uDI-J8G_IJ3j"
      )
    );

    // call the contract method using the master wallet
    const tx = await (await this.tokenContract(signer)).transferWithAuthorization(
      data.message.from,
      data.message.to,
      data.message.value,
      data.message.validAfter,
      data.message.validBefore,
      data.message.nonce,
      data.v,
      data.r,
      data.s
    );

    return tx;
  }
}

This class can be imported and used in your frontend. From our Vue Frontend we created a method makeTransfer that demonstrates this.

import USDCContract from "../scripts/usdcContract";
export default defineComponent({
...
  methods: {
   ...
    async makeTransfer() {
      // check if user is authenticated
      if (!this.currentAccount) {
        await this.connectWallet();
      }
      try {
        // clear any existing hash
        this.hash = null;
        const usdcContract = new USDCContract(
          window.ethereum,
          "0x07865c6E87B9F70255377e024ace6630C1Eaa37F"
        );

        // create message and get signature
        const signature = await usdcContract.processSignature(
          this.amount,
          this.currentAccount,
          this.recipient
        );
        // set sending to true
        this.sending = true;

        // call transfer
        const result = await usdcContract.transferWithAuthorization(signature);

        await result.wait();

        // set sending to false
        this.sending = false;

        // set transaction hash
        this.hash = result.hash;
      } catch (error) {}
      // create a USDC contract factory
    },
   ...
  }
}

And yeah, that is it.

The source code can be found here. You can play around with the deployed app at https://sendusdc.surge.sh source code


This is not a production ready codebase. It is only for the purpose of this article.


Key takeaway

Since MasterWallet covers the gas fees by submitting transactions on behalf of the users, you can deduct USDC as a transaction fee for users.

Conclusion

It is important to note that the Ethereum ecosystem is powered by the community, periodically Improvement Proposals are submitted as pull requests. These Proposals describe new features, standards, or recommendations for Ethereum or its processes or environment.

Many tokens and smart contracts do not implement this EIP-3009 standard, but it is best we have them in mind for a better architectural design for our system and smart contract.


More Stories from Afrodev

2023 AfroDev