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

Pre-requisites

  • OpenZeppelin Defender account. You can sign up to Defender here.

  • Git and Yarn installed

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.

Copy Webhook

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
  1. Open app: http://localhost:3000/

  2. Change to Sepolia network in Metamask

  3. Enter a name to register and sign the meta-transaction in Metamask

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