Relaying gasless meta-transactions with a web app

Defender provides a seamless experience and secure way to implement gasless meta-transactions using Relayers. These Relayers handle sending transactions on behalf of users, eliminating the need for users to manage private keys, transaction signing, nonce management, gas estimation, and transaction inclusion.

This demo app showcases how to implement meta-transactions using not just ERC-2771, but also explores other gasless transaction standards:

  • ERC-2771: Secure Native Meta Transactions: 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).

  • ERC-2612: Permit Function: This standard introduces a method for enabling gasless token approvals in ERC-20 tokens. Users can grant spending permission to a relayer service by signing a message instead of directly paying gas fees for the traditional "approve" function. This allows the relayer to handle token approvals on the user’s behalf.

  • ERC-3009: Transfer with Authorization: This standard facilitates gasless token transfers through off-chain authorizations. Users sign messages authorizing specific token transfers, which can then be submitted to the blockchain by anyone, including a relayer service.

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.

Pre-requisites

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

  • Git and Yarn installed

1. ERC-2771: Secure Native Meta Transactions

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

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

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

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

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

1.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);
}

2. ERC-2612: Permit Function

EIP-2612 introduces the permit function, a tool for enabling gasless transactions in ERC-20 tokens. By extending the ERC-20 interface with a method allowing users to modify their allowance via a signed message instead of the approve function, this standard empowers users to approve tokens without directly paying gas fees. This standard enables relayer services to execute transactions on behalf of users by paying gas fees, while the user only needs to sign a message.

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external

This function modifies the allowance of the spender for the owner’s tokens, based on a signed approval. The signature is split into v, r, and s components for verification.

2.1 EIP-712 signing front-end

How it uses EIP-712 for structured data signing: EIP-2612 leverages EIP-712 for creating and signing structured data. This provides a human-readable representation of the data being signed, enhancing security and user experience. Example code:

// ...
 const domain = {
    name: name,
    version: '1',
    chainId: chainId,
    verifyingContract: ERC20_ADDRESS,
  };

  const types = {
    Permit: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ]
  };

  const value = {
    owner: OWNER_ADDRESS,
    spender: SPENDER_ADDRESS,
    value: amount,
    nonce: nonce,
    deadline: deadline,
  };


  const signature = await wallet.signTypedData(domain, types, value);
  const sig = ethers.Signature.from(signature);
  const recoveredAddress = ethers.verifyTypedData(domain, types, value, signature);

  const request = {
    owner: OWNER_ADDRESS,
    spender: SPENDER_ADDRESS,
    amount,
    deadline,
    v: sig.v,
    r: sig.r,
    s: sig.s
  };

  return fetch(`${url}/relayerForwardMessage`, {
    method: 'POST',
    body: JSON.stringify(request),
    headers: { 'Content-Type': 'application/json' },
  });

2.2 Relayer service

Create a back-end service to interact with Defender Relayers. The service will initially require the setup of Defender Relayers. Once configured, it will handle incoming requests from the front-end and forward the signed EIP-712 message to the contract. The service will utilize the Relayers to execute the contract’s permit function, allowing the Relayer to cover gas fees. The service will facilitate token approvals for end-users, enabling subsequent operations with the Relayers, such as transferring tokens to different wallets.

import { ethers, defender } from "hardhat";

// ...
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 erc20 = await ethers.getContractAt("ERC20Token", CONTRACT_ADDRESS);

// You can now use these values to call the permit function
// permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
const tx = await erc20.permit(
  request.owner,
  request.spender,
  request.amount,
  request.deadline,
  request.v,
  request.r,
  request.s
);

await tx.wait();
console.log("Permit executed!");

// Example subsequent operation
const transferTx = await erc20.transferFrom(request.owner, to, request.amount);
await transferTx.wait();
// ...

3. ERC-3009: Transfer with Authorization

ERC-3009 introduces a standard for gasless token transfers through off-chain authorizations. This standard allows users to sign messages authorizing token transfers, which can then be submitted to on-chain by anyone, through the Defender Relayers service. Comparison with EIP-2612 (signing differences): While EIP-2612 focuses on approvals, ERC-3009 directly authorizes transfers. The key differences are:

  • Purpose: ERC-3009 authorizes specific transfers, while EIP-2612 approves an allowance.

  • Flexibility: ERC-3009 doesn’t require EIP-712 for structured data signing, offering more flexibility in message formatting.

  • Time Window: ERC-3009 includes validAfter and validBefore parameters, allowing for more precise control over when the authorization can be executed.

The function definition:

function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external

3.1 EIP-712 signing front-end

Similar to ERC-2612, you can use the EIP-712 format to sign messages on the front-end as the end user. While ERC-3009 offers more flexibility for front-end message signing, this example adheres to the EIP-712 standard. Example code:

  //...

  const validAfter = Math.floor(Date.now() / 1000); // Now
  const validBefore = validAfter + 3600; // 1 hour from validAfter
  const value = ethers.parseEther("10"); // Amount to transfer
  const nonce = ethers.randomBytes(32);

  const domain = {
    name: name,
    version: '1',
    chainId: chainId,
    verifyingContract: ERC20_ADDRESS,
  };

  const types = {
    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' },
    ]
  };

  const valueToSign = {
    from: FROM_ADDRESS,
    to: TO_ADDRESS,
    value: value,
    validAfter: validAfter,
    validBefore: validBefore,
    nonce: nonce,
  };


  const signature = await wallet.signTypedData(domain, types, valueToSign);
  const sig = ethers.Signature.from(signature);
  const request = {
    from: FROM_ADDRESS,
    to: TO_ADDRESS,
    value,
    validAfter,
    validBefore,
    nonce,
    v: sig.v,
    r: sig.r,
    s: sig.s
  };

  return fetch(`${url}/relayerForwardMessage`, {
    method: 'POST',
    body: JSON.stringify(request),
    headers: { 'Content-Type': 'application/json' },
  });

3.2 Relayer service

Create a back-end service to interact with Defender Relayers. The service will initially require the setup of Defender Relayers. Once configured, it will handle incoming requests from the front-end and forward the signed messages to the contract. The service will utilize the Relayers to execute the contract’s transferWithAuthorization function, allowing the Relayer to cover gas fees. The service will facilitate the transfer of tokens for the end-users.

import { ethers, defender } from "hardhat";

// ...
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 erc20 = await ethers.getContractAt("ERC20Token", CONTRACT_ADDRESS);

const tx = await erc20.transferWithAuthorization(
    request.from,
    request.to,
    request.value,
    request.validAfter,
    request.validBefore,
    request.nonce,
    request.v,
    request.r,
    request.s
);

await tx.wait();
console.log("TransferWithAuthorization executed!");
// ...

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.