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
ForwardRequestpayloads on-chain
- HTTP API that validates and relays
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)
- The user signs a ForwardRequest (EIP-712 typed data).
- The relayer submits
forwarder.execute(request)on-chain and pays gas. - The forwarder verifies:
- signature matches
request.from - nonce is correct (anti-replay)
- deadline not expired
- signature matches
- The forwarder calls the recipient with the original sender appended to calldata.
- The recipient uses
ERC2771Contextso_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_ADDRESSREGISTRY_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
- Inherit
- Frontend
- Encode calldata for the action
- Fetch
noncefrom 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.senderis the trusted forwarder. - Whitelist allowed
tocontracts (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.lengthbecause 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
- Forwarder contract
- Relayer service
- Reference: ERC-2771 flow + implementation details
- Watr POC architecture