Skip to main content

Gasless Meta Transactions (ERC-2771)

ERC-2771 is a standard pattern for gasless (meta) transactions on EVM chains. The key idea is that a user can sign an off-chain request authorizing an action, while a relayer pays gas to submit the on-chain transaction. Your smart contract still attributes the action to the original user via _msgSender() (not msg.sender).

This documentation is based on the working reference implementation included in this repo at watr-gasless-transactions/ (contracts + relay server + frontend).

The upstream repository for this implementation is: Watr-Protocol/gasless-transactions.

Reference implementation (repo layout)

The watr-gasless-transactions/ project is organized as:

  • backend/ (Hardhat)
    • contracts/Forwarder.sol (trusted forwarder wrapper)
    • contracts/Greeter.sol (minimal recipient example)
    • contracts/ShipmentRegistry.sol (business example using _msgSender())
    • scripts/deploy*.ts (deploy helpers)
    • scripts/relay.ts (POC relayer script)
  • relay-server/
    • HTTP API that validates and relays ForwardRequest payloads on-chain
  • frontend/
    • React (Vite) app that signs EIP-712 requests and calls the relay server

The actors (who does what)

  • User (Transaction Signer): signs an EIP-712 typed request. Does not need gas.
  • Relayer (Gas Payer): submits an on-chain transaction and pays gas.
  • Trusted Forwarder: verifies the signature/nonce/deadline, then forwards the call to your contract and attaches the original sender.
  • Recipient contract (your contract): is “ERC-2771 aware” and reads the real caller via _msgSender().

Mental model

Think of a bridge between “authorization” and “gas payment”:

  • The user authorizes an action by signing structured data.
  • The relayer pays to execute that authorization on-chain.

The trusted forwarder is the verifier + delivery mechanism that makes the recipient contract reliably see the original user.

What actually happens on-chain (high level)

  1. The user signs a ForwardRequest (EIP-712 typed data).
  2. The relayer submits forwarder.execute(request) on-chain and pays gas.
  3. The forwarder verifies:
    • signature matches request.from
    • nonce is correct (anti-replay)
    • deadline not expired
  4. The forwarder calls the recipient with the original sender appended to calldata.
  5. The recipient uses ERC2771Context so _msgSender() resolves to the user.

What you need to build (components)

To provide a gasless UX end-to-end, you need:

  • On-chain
    • A trusted forwarder contract (ERC-2771 forwarder)
    • One or more recipient contracts that use ERC2771Context
  • Off-chain
    • A relayer wallet funded with the chain’s gas token
    • A relay service that accepts { request, signature } and calls the forwarder
  • Client
    • A frontend (or SDK) that builds + signs the EIP-712 request

Step-by-step (Watr demo project in this repo)

The reference implementation lives in watr-gasless-transactions/ and supports:

  • Local Hardhat test (fastest way to validate mechanics)
  • Deployment to Watr testnet
  • A relay server (HTTP API)
  • A frontend that triggers a “gasless” action

Step 0: Prerequisites

You will need:

  • Node.js and npm
  • A wallet like MetaMask (for the frontend flow)
  • A relayer private key funded with Watr testnet gas (for Watr deployment)

Step 1: Prove the core mechanics locally (Hardhat)

This is the quickest sanity check: relayed call succeeds and _msgSender() is the user.

cd watr-gasless-transactions/backend
npm install
npm run build
npm test

Step 2: Deploy forwarder + recipient to Watr testnet

Create watr-gasless-transactions/backend/.env:

RELAYER_PRIVATE_KEY=0x...    # funded on Watr testnet
WATR_RPC_URL=https://rpc.testnet.watr.org/...

Deploy (ShipmentRegistry demo):

cd watr-gasless-transactions/backend
npm run deploy:shipment:watr

Save the printed addresses:

  • FORWARDER_ADDRESS
  • REGISTRY_ADDRESS

Step 3: Run the relay server (pays gas)

Create watr-gasless-transactions/relay-server/.env based on .env.example:

RELAYER_PRIVATE_KEY=0x...
WATR_RPC_URL=https://rpc.testnet.watr.org/...
FORWARDER_ADDRESS=0xFORWARDER_ADDRESS
REGISTRY_ADDRESS=0xREGISTRY_ADDRESS
PORT=3001

Run:

cd watr-gasless-transactions/relay-server
npm install
npm run dev

Step 4: Run the frontend (user signs typed data, no gas)

Create watr-gasless-transactions/frontend/.env based on .env.example:

VITE_FORWARDER_ADDRESS=0xFORWARDER_ADDRESS
VITE_REGISTRY_ADDRESS=0xREGISTRY_ADDRESS
VITE_RELAY_API_URL=http://localhost:3001
VITE_WATR_CHAIN_ID=92870
VITE_WATR_EXPLORER=https://explorer.testnet.watr.org

Run:

cd watr-gasless-transactions/frontend
npm install
npm run dev

In MetaMask, the user will see a Sign data prompt (EIP-712) instead of a gas-paying transaction confirmation.

Step 5: Verify the flow on the explorer (what you should see)

After a successful “gasless” action:

  • Outer transaction: from = relayer, to = Forwarder
  • Forwarded/internal call: Forwarder -> Recipient (e.g. ShipmentRegistry.bookShipment(...))
  • Application attribution: events emitted by the recipient should show the user address (because the contract uses _msgSender())

80/20 checklist (make it work)

  • Forwarder
    • Deploy a trusted forwarder on the target network
  • Contracts
    • Inherit ERC2771Context
    • Replace msg.sender -> _msgSender() wherever identity matters
  • Frontend
    • Encode calldata for the action
    • Fetch nonce from the forwarder for the user
    • Build and EIP-712 sign the request
    • Send { request, signature } to the relayer
  • Relayer
    • Verify + execute on-chain
    • Monitor relayer balance and rate-limit requests

Security and operational notes

  • Only treat appended sender bytes as authoritative when msg.sender is the trusted forwarder.
  • Whitelist allowed to contracts (and optionally function selectors) in the relayer to prevent it being abused as a public gas faucet.
  • Be careful with logic that depends on exact msg.data.length because ERC-2771 appends 20 bytes.

Code snippets (from watr-gasless-transactions/)

A) Forwarder (constructor name is part of the EIP-712 domain)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract Forwarder is ERC2771Forwarder {
constructor(string memory name) ERC2771Forwarder(name) {}
}

B) Recipient contract (use _msgSender())

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract Greeter is ERC2771Context {
event GreetingUpdated(address indexed sender, string greeting);
string private _greeting;

constructor(address trustedForwarder_, string memory initialGreeting)
ERC2771Context(trustedForwarder_)
{
_greeting = initialGreeting;
}

function setGreeting(string calldata newGreeting) external {
address sender = _msgSender();
_greeting = newGreeting;
emit GreetingUpdated(sender, newGreeting);
}
}

C) Frontend: typed data types and domain must match the deployed forwarder

export const FORWARD_REQUEST_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" }
]
};

export function getForwarderDomain(chainId, forwarderAddress, name = "ShipmentForwarder") {
return { name, version: "1", chainId, verifyingContract: forwarderAddress };
}

Next pages

References