Meta transactions - can't solve invalid signature

I'm following this workshop in an attempt to implement meta transactions -

I am able to sign and submit the tx from my dapp to the autotask webhook URL.

But the autotask fails verification on the forwarder. If I skip the check, the transaction is submitted to the forwarder contract and fails with an invalid signature error: Fail with error 'MinimalForwarder: signature does not match request'

:computer: Environment

I'm using autotasks and relayer. The autotask uses the Defender relayer provider to submit the tx to a forwarder. This is on Mumbai.

The forwarder inherits the MinimalForwarder OZ contract -

While the recipient inherits ERC2771Context and specifies the forwarder as trusted in the constructor -


I'm using the code from the workshop example for signing the meta transacation.

I'm just not sure really where to start. I'm confident the args to the function are correct.

Are there any common missteps here to look out for? Ideas for how to debug why the signature is invalid?

:1234: Code to reproduce

import ethSigUtil from 'eth-sig-util';

const EIP712Domain = [
  { name: 'name', type: 'string' },
  { name: 'version', type: 'string' },
  { name: 'chainId', type: 'uint256' },
  { name: 'verifyingContract', type: 'address' },

const ForwardRequest = [
  { name: 'from', type: 'address' },
  { name: 'to', type: 'address' },
  { name: 'value', type: 'uint256' },
  { name: 'gas', type: 'uint256' },
  { name: 'nonce', type: 'uint256' },
  { name: 'data', type: 'bytes' },

function getMetaTxTypeData(chainId, verifyingContract) {
  return {
    types: {
    domain: {
      name: 'GSNv2 Forwarder',
      version: '0.0.1',
    primaryType: 'ForwardRequest',

async function signTypedData(signer, from, data) {
  // If signer is a private key, use it to sign
  if (typeof signer === 'string') {
    const privateKey = Buffer.from(signer.replace(/^0x/, ''), 'hex');
    return ethSigUtil.signTypedMessage(privateKey, { data });

  // Otherwise, send the signTypedData RPC call
  // Note that hardhatvm and metamask require different EIP712 input
  const isHardhat = data.domain.chainId == 31337;
  const [method, argData] = isHardhat
    ? ['eth_signTypedData', data]
    : ['eth_signTypedData_v4', JSON.stringify(data)];
  return await signer.send(method, [from, argData]);

async function buildRequest(forwarder, input) {
  const nonce = await forwarder
    .then((nonce) => nonce.toString());
  return { value: 0, gas: 1e6, nonce, ...input };

async function buildTypedData(forwarder, request) {
  const chainId = await forwarder.provider.getNetwork().then((n) => n.chainId);
  const typeData = getMetaTxTypeData(chainId, forwarder.address);
  return { ...typeData, message: request };

async function signMetaTxRequest(signer, forwarder, input) {
  const request = await buildRequest(forwarder, input);
  const toSign = await buildTypedData(forwarder, request);
  const signature = await signTypedData(signer, input.from, toSign);
  return { signature, request };

export { signMetaTxRequest, buildRequest, buildTypedData };
import { Provider } from '@ethersproject/abstract-provider';
import { Signer } from '@ethersproject/abstract-signer';
import { Contract } from '@ethersproject/contracts';
import Forwarder from '../../deployments/mumbai/Forwarder.json';
import TicTacToe from '../../deployments/mumbai/TicTacToe.json';
import { signMetaTxRequest } from './signer';

export async function sendMetaTx(
  provider: Provider,
  signer: Signer,
  method: string,
  args: any[],
) {
  const url = process.env.REACT_APP_WEBHOOK_URL;
  if (!url) throw new Error(`Missing relayer url`);

  const forwarder = new Contract(Forwarder.address, Forwarder.abi, provider);
  const ticTacToe = new Contract(TicTacToe.address, TicTacToe.abi, provider);
  const from = await signer.getAddress();
  const data = ticTacToe.interface.encodeFunctionData(method, args);
  const to = ticTacToe.address;

  const request = await signMetaTxRequest(signer.provider, forwarder, {


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


async function relay(forwarder: Contract, request: any, signature: string) {
  // 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});

// Entrypoint for the Autotask
export async function handler(event: any) {
  // 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 credentials = {...event};
  const provider = new DefenderRelayProvider(credentials);
  const signer = new DefenderRelaySigner(credentials, provider, {
    speed: 'fast',
  const forwarder = new Contract(Forwarder.address, Forwarder.abi, signer);

  // Relay transaction!
  const tx = await relay(forwarder, request, signature);
  console.log(`Sent meta-tx: ${tx.hash}`);
  return {txHash: tx.hash};
1 Like

Hey @sbauch! The issue you describe is typically caused by an error in the EIP712 signature. Most likely, the payload you're signing does not match the one expected by the EIP712 verifier contract. You'll want to compare the getMetaTxTypeData function in the signer with the verify method in the MinimalForwarder, and make sure they are both operating on the same structured data.

That said, from a quick look, it'd seem that the incompatibility is in the domain name: you're using GSNv2 Forwarder in the signer and MinimalForwarder in the contract. Try changing the domain name to MinimalForwarder in the signer as well, and try again.

Please let me know if this is code you lifted from the workshop, so we can fix it so no one else runs into this! Or even better, if you can send a PR, it'd be greatly appreciated!


thanks so much @spalladino!

that looks to have been the issue, and im now properly relaying the tx!

was a great workshop, but yes, I lifted that code with the GSN domain name from here -

I am a little confused though - can I use a name other than "MinimalForwarder" for UX purposes? What would I need to change in the contract?

Absolutely, you can use whatever name you want, as long as it matches the signer. However, the MinimalForwarder in the Contracts library does not allow you to customize it. If you want to do it, you should copy the code into your codebase, and change the constructor so it passes a different name to the EIP712 constructor:

@Amxx any thoughts on how to approach this, instead of vendoring the contract?

Fixed it, thanks!

1 Like

gotcha thanks! certainly no stranger to copy pasta-ing code, so this is certainly not a problem for me lol


Thanks for this thread! This helped me troubleshoot the same issue I ran into using the tutorial.

Somehow today, using yarn, it pulled stale OZ contracts with

constructor() EIP712("GSNv2 Forwarder", "0.0.1") {}

Indeed, the dependency to OZ Contracts was set up incorrectly in the workshop's code (see here). It's now fixed.