How to Implement Meta Transactions With ERC-2771

To implement meta transactions with ERC-2771, four separate pieces have to come together: a forwarder-aware contract, a trusted forwarder, an off-chain signing step, and something willing to submit the transaction and pay for it. None of these four pieces is complicated in isolation — the mistakes happen in how they connect.

This ERC-2771 tutorial walks through that implementation end to end: inheriting the right base contract, signing and relaying a request, choosing a relayer, and closing the security gaps that have actually cost real projects money. If you want the concept first, what meta transactions are and how ERC-2771 works covers that ground — this is the hands-on follow-up for anyone ready to implement meta transactions in a live contract.

What Do You Need Before You Start?

Four things need to be in place before writing any code for this ERC-2771 implementation. Skipping one tends to surface as a confusing bug three steps later rather than an obvious error up front.

  • A contract you can still modify. Forwarder support has to be built in from the start — there’s no way to add ERC-2771 to a contract that’s already deployed and immutable (more on upgradeable contracts in the FAQ below).
  • A trusted forwarder — either your own deployment of OpenZeppelin’s ERC2771Forwarder, or one already running on your target network.
  • A signing library on the client side. ethers.js and viem both handle EIP-712 typed-data signing natively, which is the format a forwarder expects.
  • Something to relay the transaction — your own backend wallet, or a relayer network, covered in the comparison further down.

Step 1: How Do You Make a Contract Forwarder-Aware?

Inheriting ERC2771Context and passing a forwarder address to the constructor is the entire contract-side change. It overrides _msgSender() so the contract reads the real signer instead of the forwarder’s own address, without touching any of the contract’s actual logic:

import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";

contract MyDapp is ERC2771Context {
    constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}

    function doSomething() external {
        address user = _msgSender(); // the real signer, not the forwarder
        // your logic here, unchanged
    }
}

That’s the whole change for a simple contract, and it’s the foundation every other part of an ERC-2771 implementation builds on. Two situations complicate it: multiple inheritance, where every parent contract reading msg.sender directly needs to call _msgSender() instead, and any use of Multicall, which needs a specific fix covered in the security section rather than glossed over here.

Step 2: Where Does the Forwarder Contract Come From?

The choice is control versus setup time. Deploying your own forwarder gives you one dedicated, verified contract with no dependency on anyone else’s infrastructure. Pointing your contract at a forwarder a relayer network already runs means less deployment work, at the cost of trusting infrastructure you don’t control.

Most teams shipping a single dApp deploy their own, the simplest path to an ERC-2771 implementation with no external dependencies. It takes minutes: deploy ERC2771Forwarder with a name string — used in its EIP-712 domain — then point your contract’s constructor at its address. The constructor signature is just constructor(string memory name) EIP712(name, "1"), and that name has to match exactly what your signing code uses in the next step.

Either way, the trusted forwarder’s address isn’t something to hardcode and forget. If it’s ever found to be compromised or buggy, a project needs a way to stop trusting it — which is why some teams deliberately keep _trustedForwarder upgradeable instead of immutable, a trade-off covered below.

Step 3: How Do You Build and Sign the Off-Chain Request?

The signer never touches the blockchain directly — that separation is the whole point of how you implement meta transactions this way. They sign a structured EIP-712 message describing the call they want made, and hand the signature to whatever relays it:

javascript

const domain = {
  name: "MyForwarder", // must match the name passed to the forwarder's constructor
  version: "1",
  chainId: 1,
  verifyingContract: forwarderAddress,
};

const types = {
  ForwardRequest: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "gas", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint48" },
    { name: "data", type: "bytes" },
  ],
};

const message = {
  from: signerAddress,
  to: targetContractAddress,
  value: 0,
  gas: 200000,
  nonce: await forwarder.nonces(signerAddress),
  deadline: Math.floor(Date.now() / 1000) + 3600,
  data: targetContract.interface.encodeFunctionData("doSomething", []),
};

const signature = await signer.signTypedData(domain, types, message);

EIP-712 domain: a set of contract-specific values — name, version, chain ID, and verifying contract address — mixed into every signature so it can’t be replayed against a different contract or a different chain. See EIP-712 for the full specification.

The nonce comes from the forwarder’s own nonces() mapping, and deadline is a plain Unix timestamp — an hour out in the example above. Both exist so a signed request can’t be replayed or left to sit indefinitely before someone finally submits it.

Step 4: How Does the Relayer Actually Submit It?

This is the one on-chain step, and the only place gas gets spent. The relayer takes the signed message and calls the forwarder’s execute() function — but the struct it submits isn’t identical to what got signed:

const request = {
  from: signerAddress,
  to: targetContractAddress,
  value: 0,
  gas: 200000,
  deadline,
  data: callData,
  signature,
};

await forwarder.execute(request); // relayer's wallet pays gas, not the signer's

Notice nonce is missing here. The on-chain ForwardRequestData struct doesn’t store it — the forwarder looks up the current nonce itself and checks it against what was signed, rather than trusting a value the caller provides. Assuming the signed struct and the submitted struct share the same shape is a common first-integration mistake.

Calling verify() before execute() costs nothing and confirms a request is still good — signature intact, nonce current, deadline not passed — which is worth doing before spending gas on a call that might revert anyway.

Should You Run Your Own Relayer or Use a Network?

Someone has to operate the piece that watches for signed requests, decides which ones to sponsor, and actually submits them. Whichever way you implement meta transactions in production, a few established options handle this piece differently enough that the choice matters:

RelayerModelERC-2771 supportNetwork reachNotable number
OpenGSNOpen-source; self-host or integrate with its existing relay infrastructureNative — the original reference implementation7 networks: Ethereum, Polygon, Optimism, Arbitrum, Avalanche, BNB Smart Chain, Gnosis ChainLongest production track record of the four
Gelato RelayHosted, decentralized executor networkNative — dedicated sponsoredCallERC2771 and callWithSyncFeeERC2771 methods17 EVM networks1Balance lets one deposit cover gas across every supported chain
BiconomyHosted, built around its own smart-account stackIndirect — current tooling centers on ERC-4337 rather than ERC-2771 forwardingMultiple EVM networks70M+ transactions processed, 4.5M+ smart accounts deployed
OpenZeppelin RelayerOpen-source, self-hostedGeneral-purpose — pairs with any forwarder, not ERC-2771-specificEVM, Solana, and StellarLicensed AGPL v3.0

OpenGSN and Gelato Relay are the two built specifically around the trusted-forwarder pattern, which makes either a faster starting point for an ERC-2771 implementation than wiring up general-purpose relay infrastructure yourself. Biconomy and OpenZeppelin Relayer are worth knowing about, but neither is an ERC-2771-first tool the way the other two are.

How Do You Test Before Going to Mainnet?

Deploy the same ERC-2771 implementation — forwarder, forwarder-aware contract, signing code — to a testnet first, and run through the full signed-request-to-execute() flow with worthless test ETH before anything touches real funds. A forwarder bug is exactly the kind of thing that’s cheap to catch on a testnet and expensive to discover in production.

Before that, use verify() and a plain eth_call against the forwarder to simulate the request without spending any gas at all — the same pre-flight check a wallet runs before you sign anything, just triggered from your own test script instead. Testnet access is the one piece of infrastructure every step in this ERC-2771 tutorial depends on; providers like NOWNodes give RPC access to Ethereum and 120-plus other networks so a team can test a forwarder deployment without running and syncing a node just for that.

What Security Mistakes Have Actually Cost Real Projects?

The ERC2771Context + Multicall Address-Spoofing Bug

In December 2023, OpenZeppelin publicly disclosed a vulnerability affecting any contract that combined ERC2771Context with a Multicall-style batching function. The issue isn’t specific to OpenZeppelin’s own contracts — thirdweb’s prebuilt contracts were among those broadly affected, since the same combination shows up across many independent codebases.

Here’s the mechanism: Multicall batches several sub-calls into one transaction using delegatecall, which preserves msg.sender across every sub-call. If a trusted forwarder calls multicall(), an attacker can craft one of the batched sub-calls so its own data ends in a fake 20-byte address. Because msg.sender still reads as the trusted forwarder inside that sub-call, _msgSender() trusts the attacker’s fake suffix instead of the real signer — letting the attacker impersonate any address for that nested call.

OpenZeppelin fixed this by giving Multicall a context-suffix length specifically for ERC-2771 data, so a forwarded call is correctly identified and handled the same way across every sub-call rather than trusted blindly. As OpenZeppelin’s Vlad Estoup and Pedro Aisenson put it in the company’s own write-up of the incident: “Even well-audited, secure code can become a liability when combined in unforeseen ways. When importing dependencies, be sure to know how they work on the inside and think about how you’re combining them with other functionality.”

That’s the practical takeaway for anyone working on an ERC-2771 implementation today: an audited base contract isn’t a guarantee once you start combining it with other patterns. The interaction between two individually safe pieces is exactly where new bugs come from — checking that interaction is part of the audit scope, not something either component’s own review already covered.

Smaller Mistakes That Still Break Production

A handful of narrower mistakes show up often enough in a real ERC-2771 implementation to name directly:

  • Treating the trusted forwarder as permanent. An immutable forwarder address can’t be changed if that forwarder turns out to be compromised. Making it upgradeable costs little and gives a project an emergency off switch.
  • Skipping the deadline check on the client side. The contract enforces it, but a relayer that queues requests for too long wastes a user’s signature on something that will simply revert.
  • Forgetting isTrustedForwarder() in a multi-contract system. Each contract decides independently whether to trust a given forwarder — there’s no global setting, so a new contract added to an existing system needs its own explicit opt-in.

Conclusion

The ERC-2771 implementation itself is small: one base contract to inherit, one forwarder to deploy or point at, a few dozen lines of signing code, and a relayer to call execute(). What takes real care is everything around that core — picking a relayer that fits the project, testing the full flow before mainnet, and knowing that a securely audited piece can still fail once it’s combined with something else.

None of that is a reason to avoid the pattern. It’s a reason to treat the trusted forwarder and its interactions as the part of the system that deserves the closest look, since that’s precisely where the one disclosed real-world failure actually happened.

FAQ

Can You Add ERC-2771 Support to an Already-Deployed Contract?

Not to the contract itself — inheriting ERC2771Context changes how the contract is compiled and deployed, so you can’t add an ERC-2771 implementation to an existing immutable contract after the fact. A team in that position typically deploys a new, forwarder-aware version and migrates users over, or wraps the old contract behind a proxy pattern that supports upgrades.

Do You Need a Separate Forwarder for Every Contract?

No. A single trusted forwarder can serve any number of contracts, as long as each one explicitly calls it trusted through isTrustedForwarder(). Most teams run one forwarder across their whole system rather than deploying a new one per contract.

How Much Does It Cost to Run Your Own Relayer?

There’s no gas cost to operate the relayer role itself — the cost is whatever gas the relayer pays on behalf of users, plus normal server hosting for the backend that watches for and submits signed requests. That’s why most relayers apply sponsorship rules rather than covering every request unconditionally.

Does OpenZeppelin’s Forwarder Work With Upgradeable Contracts?

Yes, with the standard caveat that applies to any upgradeable setup: the trusted forwarder’s address and the forwarder-aware logic both have to live in the implementation contract, and _msgSender() needs to resolve correctly through whatever proxy pattern is in use. This is exactly the kind of interaction worth including in a security review rather than assuming by default.