Relaying gasless meta-transactions with a web app
Gasless meta-transactions offer users a more seamless experience, and potentially one where they don’t have to spend as much money to engage with the blockchain. This method gives users the option to sign a transaction for free and have it securely executed by a third party, with that other party paying the gas to execute the transaction.
A gasless meta-transaction relay can be easily and securely implemented using Defender with Relayers, which allow you to send transactions easily without needing to manage private keys, transaction signing, nonce management, gas estimation, and transaction inclusion.
This demo app implements meta-transactions using ERC2771Forwarder and ERC2771Context to separate msg.sender
from the Relayer’s address. All the user needs to do is sign a message using the account they would like to issue the transaction from. The signature is formed for the target contract and the data of the desired transaction, using the user’s private key. This signing happens off-chain and costs no gas. The signature is passed to the Relayer so it can execute the transaction for the user (and pay the gas).
Demo App Overview
You can view the live demo app here. It accepts registrations directly if the user has the available funds to pay for the transaction, otherwise the data is sent as a meta-transaction.
In the example code, the functionality of the SimpleRegistry
contract is to take a string and store it. The contract’s meta-transaction implementation achieves the same result by decoupling the signer from the sender of the transaction.
When comparing the code, note the meta-transaction’s use of _msgSender()
as opposed to the SimpleRegistry’s use of msg.sender
. By extending from ERC2771Context
and ERC2771Forwarder
, the contract becomes meta-transaction capable.
All OpenZeppelin contracts are compatible with the use of _msgSender() .
|
The second fundamental change between the two contracts is the need for the meta-transaction contract (Registry) to specify the address of the trusted forwarder, which in this case is the address of the ERC2771Forwarder
contract.
1. Configure the project
First, fork the repository and navigate to the directory for this guide. There, install the dependencies with yarn
:
$ git clone https://github.com/openzeppelin/workshops.git
$ cd workshops/25-defender-metatx-api/
$ yarn
Create a .env
file in the project root and supply an API key and secret from the API keys page. A private key will be used for local testing but the Relayer is used for actual contract deployment.
PRIVATE_KEY="0xabc"
API_KEY="abc"
API_SECRET="abc"
2. Create Relayer
Run the Relayer creation script, which will use the Defender API parameters in the .env
file:
$ yarn create-relay
The Relayer is created using the defender-sdk
package:
// ...
const client = new Defender(creds);
// Create Relayer using Defender SDK client.
const requestParams = {
name: 'MetaTxRelayer',
network: 'sepolia',
minBalance: BigInt(1e17).toString(),
};
const relayer = await client.relay.create(requestParams);
// ...
After creating it, the script will fetch the Relayer ID and create an API key and secret set to send transacitons via it. The Relayer ID is automatically stored in the relayer.json
file, and its API paramteres in the .env
file.
3. Compile the Contract Using Hardhat
Within the contracts
directory, you can find the both the SimpleRegistry.sol
and Registry.sol
contracts. The former contract contains the meta-transaction functionality, as you can see here:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";
import "@openzeppelin/contracts/metatx/ERC2771Forwarder.sol";
contract Registry is ERC2771Context {
event Registered(address indexed who, string name);
mapping(address => string) public names;
mapping(string => address) public owners;
constructor(ERC2771Forwarder forwarder) // Initialize trusted forwarder
ERC2771Context(address(forwarder)) {
}
function register(string memory name) external {
require(owners[name] == address(0), "Name taken");
address owner = _msgSender(); // Changed from msg.sender
owners[name] = owner;
names[owner] = name;
emit Registered(owner, name);
}
}
Run npx hardhat compile
to compile it for deployment.
4. Deploy Using Relayer
You can easily deploy a compiled smart contract without handling a private key by using the Relayer client from the defender-sdk
package.
The deploy.js
script pulls the Relayer’s credentials from the local .env
file along with the artifacts for the Registry
and ERC2771Forwarder
contracts and uses ethers.js to deploy. The relevant addresses of these contracts are saved to the local file deploy.json
.
// ...
const creds = {
relayerApiKey: process.env.RELAYER_API_KEY,
relayerApiSecret: process.env.RELAYER_API_SECRET,
};
const client = new Defender(creds);
const provider = client.relaySigner.getProvider();
const signer = client.relaySigner.getSigner(provider, { speed: 'fast' });
const forwarderFactory = await ethers.getContractFactory('ERC2771Forwarder', signer)
const forwarder = await forwarderFactory.deploy('ERC2771Forwarder')
.then((f) => f.deployed())
const registryFactory = await ethers.getContractFactory('Registry', signer)
const registry = await registryFactory.deploy(forwarder.address)
.then((f) => f.deployed())
// ...
Run this script with yarn deploy
.
After the contracts are deployed, the Relayer key and secret can be safely deleted; they are not needed unless additional local testing is desired. The contract addresses will be saved in the deploy.json
file.
5. Create Action via API
The demo app uses an Action to supply the necessary logic for telling the Relayer to send a transaction to the Forwarder
contract, supplying the signer’s address. The Action will get triggered by each call to its webhook from the app.
Due to the tight relationship between components, the Relayer credentials are securely available to the Action simply by instantiating a new provider and signer.
The position of the Action here is crucial — only the Action’s webhook is exposed to the frontend. The Action’s role is to execute the transaction according to the logic assigned to it: if the user has funds, they pay for the transaction. If not, the Relayer pays for the transaction.
It’s important that the Relayer’s API key and secret are insulated from the frontend. If the Relayer keys were exposed, anyone could potentially use the Relayer to send any transaction they wanted.
Here is the code for the Action, found in action/index.js
:
const { Defender } = require('@openzeppelin/defender-sdk');
const { ethers } = require('hardhat')
const { ForwarderAbi } = require('../../src/forwarder');
const ForwarderAddress = require('../../deploy.json').ERC2771Forwarder;
async function relay(forwarder, request, signature, whitelist) {
// Decide if we want to relay this request based on a whitelist
const accepts = !whitelist || whitelist.includes(request.to);
if (!accepts) throw new Error(`Rejected request to ${request.to}`);
// Validate request on the forwarder contract
const valid = await forwarder.verify(request, signature);
if (!valid) throw new Error(`Invalid request`);
// Send meta-tx through relayer to the forwarder contract
const gasLimit = (parseInt(request.gas) + 50000).toString();
return await forwarder.execute(request, signature, { gasLimit });
}
async function handler(event) {
// Parse webhook payload
if (!event.request || !event.request.body) throw new Error(`Missing payload`);
const { request, signature } = event.request.body;
console.log(`Relaying`, request);
// Initialize Relayer provider and signer, and forwarder contract
const creds = { ... event };
const client = new Defender(creds);
const provider = client.relaySigner.getProvider();
const signer = client.relaySigner.getSigner(provider, { speed: 'fast' });
const forwarder = new ethers.Contract(ForwarderAddress, ForwarderAbi, signer);
// Relay transaction!
const tx = await relay(forwarder, request, signature);
console.log(`Sent meta-tx: ${tx.hash}`);
return { txHash: tx.hash };
}
module.exports = {
handler,
relay,
}
Note that the Action code must include an index.js
file that exports a handler entrypoint. If the code relies on any external dependencies (such as an imported ABI) it’s necessary to bundle the Action using webpack, rollup, etc. You can create an Action via Defender or with the defender-sdk
package.
Run yarn create-action
to compile the code and create the Action with the bundled code via the SDK’s action.create()
method:
// ...
const { actionId } = await client.action.create({
name: "Relay MetaTx",
encodedZippedCode: await client.action.getEncodedZippedCodeFromFolder('./build/action'),
relayerId: relayerId,
trigger: {
type: 'webhook'
},
paused: false
});
// ...
Head to Defender Actions and copy the Actions’s webhook so that you can test functionality and connect the app to the Action for relaying meta-transactions.
Save the Action webhook in your .env
file as WEBHOOK_URL
and in the /app .env
file as the REACT_APP_WEBHOOK_URL
.
Test the meta-transaction’s functionality with yarn sign
followed by yarn invoke
.
6. Create Web App
The key building blocks have been laid, so next it is a matter of crafting a web application that makes use of these components.
You can see the details of this relationship in the register.js
file. The user’s transaction request is sent to the Relayer by way of the Action’s webhook, and this executes the Actions’s logic given the parameters supplied by the application. Note that the signer’s nonce is incremented from the transaction.
import { ethers } from 'ethers';
import { createInstance } from './forwarder';
import { signMetaTxRequest } from './signer';
async function sendTx(registry, name) {
console.log(`Sending register tx to set name=${name}`);
return registry.register(name);
}
async function sendMetaTx(registry, provider, signer, name) {
console.log(`Sending register meta-tx to set name=${name}`);
const url = process.env.REACT_APP_WEBHOOK_URL;
if (!url) throw new Error(`Missing relayer url`);
const forwarder = createInstance(provider);
const from = await signer.getAddress();
const data = registry.interface.encodeFunctionData('register', [name]);
const to = registry.address;
const request = await signMetaTxRequest(signer.provider, forwarder, { to, from, data });
return fetch(url, {
method: 'POST',
body: JSON.stringify(request),
headers: { 'Content-Type': 'application/json' },
});
}
export async function registerName(registry, provider, name) {
if (!name) throw new Error(`Name cannot be empty`);
if (!window.ethereum) throw new Error(`User wallet not found`);
await window.ethereum.enable();
const userProvider = new ethers.BrowserProvider(window.ethereum);
const userNetwork = await userProvider.getNetwork();
console.log(userNetwork)
if (userNetwork.chainId !== 11155111) throw new Error(`Please switch to Sepolia for signing`);
const signer = userProvider.getSigner();
const from = await signer.getAddress();
const balance = await provider.getBalance(from);
const canSendTx = balance.gt(1e15);
if (canSendTx) return sendTx(registry.connect(signer), name);
else return sendMetaTx(registry, provider, signer, name);
}
Try the app
Install the necessary dependencies and run the app.
$ cd app
$ yarn
$ yarn start
-
Open app: http://localhost:3000/
-
Change to Sepolia network in Metamask
-
Enter a name to register and sign the meta-transaction in Metamask
-
Your name will be registered, showing the address that created the meta-transaction and the name.
Use the frontend to see it working for yourself! Compare what happens when you sign the registry with an account that has funds, and then try it with an account that has a zero ETH balance.