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.
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.
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
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
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.