Meta-Transactions: Gasless UX and New Attack Vectors
39 min read
November 26, 2025
🚧 Site Migration Notice
I've recently migrated this site from Ghost CMS to a new Astro-based frontend. While I've worked hard to ensure everything transferred correctly, some articles may contain formatting errors or broken elements.
If you spot any issues, I'd really appreciate it if you could let me know! Your feedback helps improve the site for everyone.

Gas fees kill dApp adoption.
Every on-chain action requires ETH. Users need to navigate exchanges, complete KYC, understand blockchain mechanics, and manage wallets just to try your application. For mainstream adoption, this is a dealbreaker.
Meta-transactions solve the UX problem by decoupling who signs from who pays. A user signs a message off-chain (free, no gas). A relayer submits it on-chain and pays the gas. The smart contract verifies the signature and executes as if the user sent it directly.
Perfect for UX. Terrible for security if you get it wrong.
Meta-transactions introduce new attack surfaces you won’t see in standard transactions. Replay vulnerabilities. Nonce desynchronization. Front-running opportunities. Malicious relayer scenarios. The architecture requires trusting a third party and implementing bulletproof signature verification.
Miss one check, and attackers can drain funds or exploit MEV opportunities.
In this post, we’ll dissect the meta-transaction architecture, examine the EIP-2771 trusted forwarder standard, implement complete working examples, and explore the attack vectors that make this pattern both powerful and dangerous. We’re building on our understanding of transaction and message signatures to see how they enable gasless UX.
What Are Meta-Transactions?
Standard Ethereum transactions are simple. The signer pays gas. The signer submits the transaction. One account, one action.
Meta-transactions break that model.
The flow works like this. User signs a message off-chain containing their desired function call and parameters. They send this signed message to a relayer via HTTP. The relayer wraps the signed message in a real transaction, pays the gas, and submits it to a forwarder contract. The forwarder verifies the signature and forwards the call to the target contract. The target contract executes the action as if the original user called it directly.
This enables powerful use cases. Onboard new users without requiring ETH. Let dApps subsidize gas costs. Batch multiple user operations into single transactions for efficiency.
But here’s the catch. The relayer controls when (or if) your transaction gets submitted. They can front-run your action. Censor specific users. Extract MEV. The smart contract must correctly verify signatures and prevent replay attacks.
One mistake compromises the entire system.
Meta-Transaction Architecture
A complete meta-transaction system has four components.
User (Signer): Creates and signs messages off-chain. Doesn’t pay gas. Doesn’t interact directly with the blockchain.
Relayer: Receives signed messages from users, validates them, wraps them in real transactions, pays gas fees, and submits to the blockchain. Can be centralized (single server) or decentralized (network of relayers).
Forwarder Contract: Receives transactions from relayers, verifies signatures, manages nonces, and forwards calls to recipient contracts. This is the trust boundary. Must be audited and battle-tested.
Recipient Contract: The actual application logic. Receives forwarded calls and must correctly identify the original signer (not the relayer or forwarder). Uses special functions like _msgSender() to extract the real sender from calldata.
Here’s how they interact:
The security boundary exists at the forwarder contract. Everything before it is off-chain and untrusted. The forwarder must enforce all security invariants: signature verification, nonce management, replay protection.
Once the call reaches the recipient contract, it must trust that the forwarder correctly identified the original signer.
The message structure contains everything needed to reconstruct and verify the intended action. Sender address, recipient contract, function calldata, nonce for replay protection, gas limit, and deadline for time-bound execution. This entire structure gets signed by the user.
EIP-2771: Trusted Forwarder Standard
EIP-2771 standardizes the trusted forwarder pattern. It defines how forwarders structure calls and how recipient contracts extract the original sender from calldata.
The key innovation is elegant. Append the original sender address to the end of the calldata. When the forwarder calls the recipient, it encodes the user’s address in the last 20 bytes. The recipient contract checks if the caller is a trusted forwarder. If so, it reads the original sender from calldata instead of using msg.sender.
This is implemented through the _msgSender() pattern. Instead of using msg.sender directly, contracts use a helper function that checks if the call came from a trusted forwarder:
function _msgSender() internal view returns (address sender) {
// If called by trusted forwarder, extract original sender from calldata
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
// Original sender is appended as last 20 bytes
assembly {
// calldataload(pos) loads 32 bytes starting at pos
// We load from position (calldatasize - 20) to get the last 20 bytes
// Then shift right 96 bits (12 bytes) to get just the address
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
// Standard call, use msg.sender
sender = msg.sender;
}
}
The forwarder contract itself is relatively simple. It maintains nonces for each user, verifies signatures, and forwards calls with the sender appended. Here’s a minimal implementation based on OpenZeppelin’s pattern:
⚠️ Important: This MinimalForwarder implementation is from OpenZeppelin Contracts v4.x and is primarily for educational and testing purposes. OpenZeppelin explicitly states it’s “missing features to be a good production-ready forwarder.” In v5.x, MinimalForwarder was removed entirely. For production deployments, use:
- OpenZeppelin’s
ERC2771Forwarder(v5.x) with deadline enforcement, batch processing, and additional security features - Gas Station Network (GSN) or other established relayer services like Biconomy or Gelato
Click to expand: Complete MinimalForwarder Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
/**
* @title MinimalForwarder
* @notice EIP-2771 trusted forwarder for meta-transactions
* @dev Verifies signatures and forwards calls with original sender appended
*/
contract MinimalForwarder is EIP712 {
using ECDSA for bytes32;
// Type hash for ForwardRequest struct (EIP-712)
bytes32 public constant FORWARD_REQUEST_TYPEHASH =
keccak256(
"ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"
);
struct ForwardRequest {
address from; // Original signer (user)
address to; // Recipient contract
uint256 value; // ETH value to send
uint256 gas; // Gas limit for forwarded call
uint256 nonce; // Replay protection
bytes data; // Function calldata
}
// PROTECTION: Per-user nonces prevent replay attacks
mapping(address => uint256) private _nonces;
event MetaTransactionExecuted(
address indexed from,
address indexed to,
bytes data,
bool success
);
constructor() EIP712("MinimalForwarder", "0.0.1") {}
/**
* @notice Get current nonce for an address
* @param from Address to query
* @return Current nonce value
*/
function getNonce(address from) public view returns (uint256) {
return _nonces[from];
}
/**
* @notice Verify a forward request signature
* @param req The forward request struct
* @param signature The signature to verify
* @return True if signature is valid
*/
function verify(
ForwardRequest calldata req,
bytes calldata signature
) public view returns (bool) {
// VALIDATION: Check nonce matches current state
if (_nonces[req.from] != req.nonce) {
return false;
}
// Build EIP-712 typed data hash
bytes32 structHash = keccak256(
abi.encode(
FORWARD_REQUEST_TYPEHASH,
req.from,
req.to,
req.value,
req.gas,
req.nonce,
keccak256(req.data)
)
);
bytes32 digest = _hashTypedDataV4(structHash);
// SECURITY: Recover signer and verify it matches claimed sender
address signer = digest.recover(signature);
return signer == req.from;
}
/**
* @notice Execute a forward request
* @param req The forward request to execute
* @param signature Signature from original sender
* @return success Whether the forwarded call succeeded
* @return returndata Data returned by forwarded call
*/
function execute(
ForwardRequest calldata req,
bytes calldata signature
) public payable returns (bool success, bytes memory returndata) {
// SECURITY: Verify signature and request validity
require(verify(req, signature), "MinimalForwarder: signature invalid");
// DEFENSE: Increment nonce before external call (checks-effects-interactions)
_nonces[req.from]++;
// EIP-2771: Append original sender to calldata
bytes memory callData = abi.encodePacked(req.data, req.from);
// Execute forwarded call with specified gas limit
// NOTE: Nonce increments regardless of call success (similar to Ethereum account nonces)
// This prevents nonce blocking if a call fails
(success, returndata) = req.to.call{gas: req.gas, value: req.value}(
callData
);
// SECURITY: Post-execution gas check (EIP-150 63/64 rule)
// Ensures the parent call retained at least 1/64 of the forwarded gas
// Note: This check occurs AFTER the call executes and nonce increments
// It prevents gas griefing but cannot prevent nonce consumption if gas runs out
require(
gasleft() > req.gas / 63,
"MinimalForwarder: insufficient gas forwarded"
);
emit MetaTransactionExecuted(req.from, req.to, req.data, success);
return (success, returndata);
}
}
The recipient contract must be aware of the trusted forwarder. Here’s a simple example that demonstrates the pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RecipientContract {
address public immutable trustedForwarder;
mapping(address => uint256) public balances;
constructor(address _forwarder) {
trustedForwarder = _forwarder;
}
/**
* @notice Extract the original sender from calldata
* @dev Implements EIP-2771 pattern
*
* Calldata structure when called via forwarder:
* [function selector (4 bytes)][function args (n bytes)][sender address (20 bytes)]
*
* Assembly breakdown:
* 1. calldatasize() - returns total calldata length in bytes
* 2. sub(calldatasize(), 20) - calculates position of last 20 bytes
* 3. calldataload(pos) - loads 32 bytes starting at position
* 4. shr(96, value) - shifts right 96 bits (12 bytes) to extract address
*
* Example: If calldata is 100 bytes total:
* - Last 20 bytes (80-99) contain the address
* - calldataload(80) loads bytes 80-111 (32 bytes)
* - shr(96, ...) discards the extra 12 bytes, keeping only the address
*/
function _msgSender() internal view returns (address sender) {
// Check if call came from trusted forwarder
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
// Extract original sender from last 20 bytes of calldata
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
// Standard call, use msg.sender
sender = msg.sender;
}
}
/**
* @notice Deposit funds for a user
* @dev Works with both direct calls and meta-transactions
*/
function deposit() external payable {
// Use _msgSender() instead of msg.sender for compatibility
address user = _msgSender();
balances[user] += msg.value;
}
/**
* @notice Withdraw funds
* @dev Only the original user can withdraw their balance
*/
function withdraw(uint256 amount) external {
address user = _msgSender();
require(balances[user] >= amount, "Insufficient balance");
balances[user] -= amount;
payable(user).transfer(amount);
}
}
This pattern is elegant but requires discipline. Every contract function that cares about the caller must use _msgSender() instead of msg.sender. Miss one, and you introduce a vulnerability where the forwarder address is treated as the user.
Complete Testing Flow with Foundry
Let’s build a complete end-to-end testing environment so you can see meta-transactions in action. We’ll use Foundry for contract deployment and JavaScript for the client-side signing and relayer logic.
Project Setup
First, create a new Foundry project:
# Create project directory
mkdir meta-transaction-demo
cd meta-transaction-demo
# Initialize Foundry project
forge init --no-commit
# Install OpenZeppelin contracts (v4.9.0 - contains MinimalForwarder)
# Note: v5.x removed MinimalForwarder in favor of ERC2771Forwarder
forge install OpenZeppelin/[email protected] --no-git
# Initialize npm for JavaScript dependencies
npm init -y
npm install ethers@6
Update foundry.toml to configure remappings:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/"
]
# See more config options https://github.com/foundry-rs/foundry/tree/master/crates/config
Smart Contracts
Create the contracts in the src/ directory:
src/MinimalForwarder.sol: (Use the complete implementation from the earlier section)
src/RecipientContract.sol:
Click to expand: Complete RecipientContract Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title RecipientContract
* @notice Example contract that supports meta-transactions via EIP-2771
*/
contract RecipientContract {
address public immutable trustedForwarder;
mapping(address => uint256) public balances;
mapping(address => string) public messages;
event Deposited(address indexed user, uint256 amount);
event MessageSet(address indexed user, string message);
constructor(address _forwarder) {
trustedForwarder = _forwarder;
}
/**
* @notice Extract the original sender from calldata
* @dev Implements EIP-2771 pattern
*/
function _msgSender() internal view returns (address sender) {
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
sender = msg.sender;
}
}
/**
* @notice Deposit funds - can be called via meta-transaction
*/
function deposit() external payable {
address user = _msgSender();
balances[user] += msg.value;
emit Deposited(user, msg.value);
}
/**
* @notice Set a message - demonstrates gasless interaction
*/
function setMessage(string calldata _message) external {
address user = _msgSender();
messages[user] = _message;
emit MessageSet(user, _message);
}
/**
* @notice Withdraw funds
*/
function withdraw(uint256 amount) external {
address user = _msgSender();
require(balances[user] >= amount, "Insufficient balance");
balances[user] -= amount;
payable(user).transfer(amount);
}
/**
* @notice Check if forwarder is trusted
*/
function isTrustedForwarder(address forwarder) public view returns (bool) {
return forwarder == trustedForwarder;
}
}
Deployment Script
Click to expand: Complete Deployment Script
Create script/Deploy.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/MinimalForwarder.sol";
import "../src/RecipientContract.sol";
contract DeployScript is Script {
function run() external {
// Get deployer private key from environment
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Deploy MinimalForwarder
MinimalForwarder forwarder = new MinimalForwarder();
console.log("MinimalForwarder deployed at:", address(forwarder));
// Deploy RecipientContract with forwarder address
RecipientContract recipient = new RecipientContract(address(forwarder));
console.log("RecipientContract deployed at:", address(recipient));
vm.stopBroadcast();
// Print addresses in JSON format for easy copying
console.log("\n=== Deployment Complete ===");
console.log("Copy the following to deployed-addresses.json:\n");
console.log("{");
console.log(' "forwarder": "%s",', address(forwarder));
console.log(' "recipient": "%s"', address(recipient));
console.log("}");
}
}
Complete End-to-End Test Script
Click to expand: Complete End-to-End Test Script (test-meta-tx.js)
Create test-meta-tx.js in the project root:
const { ethers } = require('ethers');
const fs = require('fs');
/**
* Complete end-to-end meta-transaction demonstration
*
* Requirements:
* - ethers.js v6.x (this code uses v6 API)
* - Node.js v16+
* - Foundry/Anvil for local blockchain
*
* Note: For ethers v5.x, adjust imports to use:
* const { ethers } = require('ethers');
* const provider = new ethers.providers.JsonRpcProvider(...)
*
* Flow: User signs → Relayer submits → Contract executes
*/
async function main() {
console.log('=== Meta-Transaction End-to-End Test ===\n');
// Connect to local Anvil instance
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
// Get network info
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
console.log('Connected to network:', network.name);
console.log('Chain ID:', chainId);
// Load deployed contract addresses
const addresses = JSON.parse(fs.readFileSync('deployed-addresses.json', 'utf8'));
console.log('\nContract Addresses:');
console.log('Forwarder:', addresses.forwarder);
console.log('Recipient:', addresses.recipient);
// Setup wallets
// In Anvil, these are pre-funded test accounts
const userWallet = new ethers.Wallet(
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', // Anvil account 0
provider
);
const relayerWallet = new ethers.Wallet(
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', // Anvil account 1
provider
);
console.log('\nWallets:');
console.log('User:', userWallet.address);
console.log('Relayer:', relayerWallet.address);
// Load contract ABIs
const forwarderArtifact = JSON.parse(
fs.readFileSync('out/MinimalForwarder.sol/MinimalForwarder.json', 'utf8')
);
const recipientArtifact = JSON.parse(
fs.readFileSync('out/RecipientContract.sol/RecipientContract.json', 'utf8')
);
// Create contract instances
const forwarder = new ethers.Contract(
addresses.forwarder,
forwarderArtifact.abi,
provider
);
const recipient = new ethers.Contract(
addresses.recipient,
recipientArtifact.abi,
provider
);
// === STEP 1: User signs meta-transaction (off-chain, free) ===
console.log('\n=== STEP 1: User Signs Meta-Transaction (Off-Chain) ===');
// Get current nonce for user
const nonce = await forwarder.getNonce(userWallet.address);
console.log('Current nonce:', nonce.toString());
// Encode the function call we want to execute
const recipientInterface = new ethers.Interface(recipientArtifact.abi);
const functionData = recipientInterface.encodeFunctionData('setMessage', [
'Hello from meta-transaction!'
]);
// Build ForwardRequest
const request = {
from: userWallet.address,
to: addresses.recipient,
value: 0,
gas: 500000,
nonce: Number(nonce),
data: functionData
};
// EIP-712 domain
const domain = {
name: 'MinimalForwarder',
version: '0.0.1',
chainId: chainId,
verifyingContract: addresses.forwarder
};
// EIP-712 types
const types = {
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' }
]
};
// User signs the typed data
const signature = await userWallet.signTypedData(domain, types, request);
console.log('User signed meta-transaction:');
console.log(' Function: setMessage("Hello from meta-transaction!")');
console.log(' Signature:', signature);
console.log(' Gas required: 0 ETH (off-chain signature)');
// === STEP 2: Verify signature (off-chain validation) ===
console.log('\n=== STEP 2: Relayer Verifies Signature (Off-Chain) ===');
const isValid = await forwarder.verify(request, signature);
console.log('Signature valid:', isValid);
if (!isValid) {
console.error('ERROR: Signature verification failed!');
process.exit(1);
}
// === STEP 3: Relayer submits transaction (on-chain, pays gas) ===
console.log('\n=== STEP 3: Relayer Submits Transaction (On-Chain) ===');
// Check relayer balance before
const relayerBalanceBefore = await provider.getBalance(relayerWallet.address);
console.log('Relayer balance before:', ethers.formatEther(relayerBalanceBefore), 'ETH');
// Relayer submits the transaction
const forwarderWithRelayer = forwarder.connect(relayerWallet);
const tx = await forwarderWithRelayer.execute(request, signature, {
gasLimit: 600000 // Request gas + overhead
});
console.log('Transaction submitted by relayer');
console.log(' TX hash:', tx.hash);
console.log(' Waiting for confirmation...');
const receipt = await tx.wait();
console.log(' ✓ Transaction confirmed!');
console.log(' Block:', receipt.blockNumber);
console.log(' Gas used:', receipt.gasUsed.toString());
// Check relayer balance after (they paid the gas)
const relayerBalanceAfter = await provider.getBalance(relayerWallet.address);
const gasCost = relayerBalanceBefore - relayerBalanceAfter;
console.log('Relayer balance after:', ethers.formatEther(relayerBalanceAfter), 'ETH');
console.log('Gas cost paid by relayer:', ethers.formatEther(gasCost), 'ETH');
// === STEP 4: Verify execution ===
console.log('\n=== STEP 4: Verify Contract State ===');
const storedMessage = await recipient.messages(userWallet.address);
console.log('Message stored for user:', storedMessage);
console.log('Expected message: "Hello from meta-transaction!"');
console.log('Match:', storedMessage === 'Hello from meta-transaction!' ? '✓ YES' : '✗ NO');
// Check nonce was incremented
const newNonce = await forwarder.getNonce(userWallet.address);
console.log('\nNonce before:', nonce.toString());
console.log('Nonce after:', newNonce.toString());
console.log('Incremented:', newNonce > nonce ? '✓ YES' : '✗ NO');
// === STEP 5: Test replay protection ===
console.log('\n=== STEP 5: Test Replay Protection ===');
console.log('Attempting to replay the same signature...');
try {
const replayTx = await forwarderWithRelayer.execute(request, signature);
await replayTx.wait();
console.log('✗ VULNERABILITY: Replay succeeded! (This should not happen)');
} catch (error) {
console.log('✓ Replay prevented:', error.message.includes('signature invalid') ?
'Invalid signature (nonce mismatch)' : error.message);
}
console.log('\n=== Test Complete ===');
console.log('\nSummary:');
console.log(' • User signed message off-chain (no gas cost)');
console.log(' • Relayer submitted transaction (paid gas)');
console.log(' • Contract executed as if user called directly');
console.log(' • Nonce incremented to prevent replay');
console.log(' • User never needed ETH for gas!');
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Step-by-Step Execution
Follow these steps to test the complete meta-transaction flow:
1. Start local Anvil node (Foundry’s local Ethereum node):
# Start Anvil in a separate terminal
anvil
This starts a local blockchain at http://localhost:8545 with pre-funded test accounts.
2. Deploy contracts:
# Set deployer private key (Anvil account 0)
export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# Deploy contracts to local Anvil
forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast
# Alternatively, use the shorthand:
forge script script/Deploy.s.sol --rpc-url localhost --broadcast
You should see output like:
MinimalForwarder deployed at: 0x5FbDB2315678afecb367f032d93F642f64180aa3
RecipientContract deployed at: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
=== Deployment Complete ===
Copy the following to deployed-addresses.json:
{
"forwarder": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"recipient": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
}
Create deployed-addresses.json with the addresses from the output:
# Create the file with your deployed addresses
cat > deployed-addresses.json << 'EOF'
{
"forwarder": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"recipient": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
}
EOF
Replace the addresses with the actual ones from your deployment output.
3. Build contract artifacts (required for JavaScript to load ABIs):
forge build
4. Run the end-to-end test:
node test-meta-tx.js
You should see output showing each step of the meta-transaction flow:
rsgbengi@beru ~/P/w/meta-transaction-demo (main)> node test-meta-tx.js
=== Meta-Transaction End-to-End Test ===
Connected to network: unknown
Chain ID: 31337
Contract Addresses:
Forwarder: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Recipient: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Wallets:
User: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Relayer: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
=== STEP 1: User Signs Meta-Transaction (Off-Chain) ===
Current nonce: 0
User signed meta-transaction:
Function: setMessage("Hello from meta-transaction!")
Signature: 0xfe316f9bc4b72934c2e62c86998b8c93941d38992e38a7d5be45ab9ca102cce23bb271ac848b69223cb821a427cd51aeb00ff1a9821892cbff32d8f77b1b8b591b
Gas required: 0 ETH (off-chain signature)
=== STEP 2: Relayer Verifies Signature (Off-Chain) ===
Signature valid: true
=== STEP 3: Relayer Submits Transaction (On-Chain) ===
Relayer balance before: 10000.0 ETH
Transaction submitted by relayer
TX hash: 0xbcaaa64bb45073eb51d82efb395ef8d639b04c24f42daa306d308efecb8658fe
Waiting for confirmation...
✓ Transaction confirmed!
Block: 3
Gas used: 87140
Relayer balance after: 10000.0 ETH
Gas cost paid by relayer: 0.0 ETH
Note: Anvil uses gas-price=0 by default for easier local testing.
The relayer still submitted the transaction (you can see 87,140 gas was used).
In production networks, this would cost real ETH (~$2-5 depending on gas prices).
=== STEP 4: Verify Contract State ===
Message stored for user: Hello from meta-transaction!
Expected message: "Hello from meta-transaction!"
Match: ✓ YES
Nonce before: 0
Nonce after: 1
Incremented: ✓ YES
=== STEP 5: Test Replay Protection ===
Attempting to replay the same signature...
✓ Replay prevented: Invalid signature (nonce mismatch)
=== Test Complete ===
Summary:
• User signed message off-chain (no gas cost)
• Relayer submitted transaction (paid gas)
• Contract executed as if user called directly
• Nonce incremented to prevent replay
• User never needed ETH for gas!
Understanding the Flow
Let’s break down what just happened.
Off-Chain (Free):
- User creates a
ForwardRequestwith the function they want to call - User signs the request using EIP-712 typed data signing
- No blockchain interaction, no gas fees, no ETH required
Relayer Validation:
4. Relayer receives the signed request
5. Relayer calls forwarder.verify() to check signature validity (view function, no gas)
6. Relayer decides whether to submit (could check rate limits, whitelists, etc.)
On-Chain (Relayer Pays):
7. Relayer calls forwarder.execute(request, signature) and pays gas (87,140 gas units)
8. Forwarder verifies signature again (on-chain this time)
9. Forwarder increments user’s nonce (prevents replay)
10. Forwarder forwards call to recipient contract with user’s address appended
Note: In Anvil, gas-price=0 so cost shows as 0 ETH. In production (mainnet/testnets), this would cost real ETH.
Contract Execution:
11. Recipient contract receives call from forwarder
12. _msgSender() extracts original user address from calldata
13. Function executes as if user called it directly
14. State updates (message stored) are attributed to user, not relayer
Security Verification: 15. Nonce incremented, preventing replay attacks 16. Attempting to replay same signature fails with “signature invalid”
This demonstrates the complete meta-transaction pattern. User signs off-chain. Relayer pays gas. Contract executes as user.
Testing Different Scenarios
You can create additional test scripts to explore different meta-transaction scenarios. Here are complete, ready-to-run examples.
Feel free to skip these if you want—they’re here for completeness. The one scenario worth understanding is the front-running attack, which demonstrates a real security risk in meta-transaction systems.
Scenario 1: Test Deposit Function
Click to expand: Complete Deposit Test Script (test-deposit.js)
Create test-deposit.js:
const { ethers } = require('ethers');
const fs = require('fs');
async function main() {
console.log('=== Testing Deposit via Meta-Transaction ===\n');
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const addresses = JSON.parse(fs.readFileSync('deployed-addresses.json', 'utf8'));
// Setup wallets
const userWallet = new ethers.Wallet(
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
provider
);
const relayerWallet = new ethers.Wallet(
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
provider
);
// Load contracts
const forwarderArtifact = JSON.parse(
fs.readFileSync('out/MinimalForwarder.sol/MinimalForwarder.json', 'utf8')
);
const recipientArtifact = JSON.parse(
fs.readFileSync('out/RecipientContract.sol/RecipientContract.json', 'utf8')
);
const forwarder = new ethers.Contract(addresses.forwarder, forwarderArtifact.abi, provider);
const recipient = new ethers.Contract(addresses.recipient, recipientArtifact.abi, provider);
// Build deposit request
const nonce = await forwarder.getNonce(userWallet.address);
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
const recipientInterface = new ethers.Interface(recipientArtifact.abi);
const functionData = recipientInterface.encodeFunctionData('deposit');
const request = {
from: userWallet.address,
to: addresses.recipient,
value: ethers.parseEther('0.1'), // Deposit 0.1 ETH
gas: 500000,
nonce: Number(nonce),
data: functionData
};
const domain = {
name: 'MinimalForwarder',
version: '0.0.1',
chainId: chainId,
verifyingContract: addresses.forwarder
};
const types = {
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' }
]
};
console.log('User signs deposit of 0.1 ETH...');
const signature = await userWallet.signTypedData(domain, types, request);
console.log('Relayer submits transaction with value...');
const forwarderWithRelayer = forwarder.connect(relayerWallet);
const tx = await forwarderWithRelayer.execute(request, signature, {
gasLimit: 600000,
value: ethers.parseEther('0.1') // Relayer sends ETH to forwarder
});
await tx.wait();
console.log('✓ Transaction confirmed!');
// Check user's balance in contract
const balance = await recipient.balances(userWallet.address);
console.log('\nUser balance in contract:', ethers.formatEther(balance), 'ETH');
console.log('Expected: 0.1 ETH');
console.log('Match:', balance === ethers.parseEther('0.1') ? '✓ YES' : '✗ NO');
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Scenario 2: Test Multiple Sequential Transactions
This scenario demonstrates an important edge case. Rapid sequential meta-transactions expose a nonce management issue with ethers.js.
The Two-Nonce Problem: Meta-transaction systems actually deal with two different nonces:
- Contract nonce (in
MinimalForwarder): Prevents replay attacks on meta-transactions. Managed per-user by the forwarder contract. - Relayer account nonce (Ethereum protocol): Orders the relayer’s on-chain transactions. Managed per-account by the Ethereum network.
When you submit multiple meta-transactions rapidly in the same script, ethers.js’s internal nonce cache doesn’t update fast enough. The library caches the relayer’s account nonce and doesn’t refresh it between transactions, causing “nonce too low” errors on the second transaction.
The Solution: Manually manage the relayer’s account nonce outside of ethers.js’s automatic management. Fetch it once at the start, then increment it manually for each transaction.
Click to expand: Complete Sequential Transactions Test (test-sequential.js)
Create test-sequential.js:
const { ethers } = require('ethers');
const fs = require('fs');
async function main() {
console.log('=== Testing Multiple Sequential Meta-Transactions ===\n');
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const addresses = JSON.parse(fs.readFileSync('deployed-addresses.json', 'utf8'));
const userWallet = new ethers.Wallet(
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
provider
);
const relayerWallet = new ethers.Wallet(
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
provider
);
const forwarderArtifact = JSON.parse(
fs.readFileSync('out/MinimalForwarder.sol/MinimalForwarder.json', 'utf8')
);
const recipientArtifact = JSON.parse(
fs.readFileSync('out/RecipientContract.sol/RecipientContract.json', 'utf8')
);
const forwarder = new ethers.Contract(addresses.forwarder, forwarderArtifact.abi, provider);
const recipient = new ethers.Contract(addresses.recipient, recipientArtifact.abi, provider);
const recipientInterface = new ethers.Interface(recipientArtifact.abi);
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
const domain = {
name: 'MinimalForwarder',
version: '0.0.1',
chainId: chainId,
verifyingContract: addresses.forwarder
};
const types = {
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' }
]
};
const forwarderWithRelayer = forwarder.connect(relayerWallet);
// Send 3 sequential messages
const messages = ['Message 1', 'Message 2', 'Message 3'];
// SECURITY: Get initial relayer nonce once and manage it manually
// This prevents ethers.js nonce caching issues in rapid sequential transactions
let relayerNonce = await provider.getTransactionCount(relayerWallet.address, 'latest');
for (let i = 0; i < messages.length; i++) {
console.log(`\n--- Transaction ${i + 1}/3 ---`);
// Get current contract nonce (increases each iteration)
const nonce = await forwarder.getNonce(userWallet.address);
console.log('Current contract nonce:', nonce.toString());
console.log('Current relayer nonce:', relayerNonce);
// Build request
const functionData = recipientInterface.encodeFunctionData('setMessage', [messages[i]]);
const request = {
from: userWallet.address,
to: addresses.recipient,
value: 0,
gas: 500000,
nonce: Number(nonce),
data: functionData
};
// User signs
const signature = await userWallet.signTypedData(domain, types, request);
console.log('User signed:', messages[i]);
// Relayer submits with manually managed nonce
const tx = await forwarderWithRelayer.execute(request, signature, {
gasLimit: 600000,
nonce: relayerNonce // Use manually tracked nonce
});
// Wait for confirmation
await tx.wait();
console.log('✓ Transaction confirmed');
// Increment nonce for next iteration
relayerNonce++;
}
// Verify final message
const finalMessage = await recipient.messages(userWallet.address);
console.log('\n=== Results ===');
console.log('Final message:', finalMessage);
console.log('Expected: "Message 3"');
console.log('Match:', finalMessage === 'Message 3' ? '✓ YES' : '✗ NO');
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Key Implementation Details:
// Fetch relayer's account nonce once
let relayerNonce = await provider.getTransactionCount(relayerWallet.address, 'latest');
// Use it explicitly in each transaction
const tx = await forwarderWithRelayer.execute(request, signature, {
gasLimit: 600000,
nonce: relayerNonce // Manual control
});
await tx.wait();
relayerNonce++; // Manual increment
This pattern is necessary when:
- Submitting multiple transactions from the same relayer rapidly
- Building batch transaction systems
- Testing meta-transaction flows programmatically
In production relayer services, this is typically handled by a transaction queue that manages nonces across concurrent requests.
Scenario 3: Test Front-Running Protection
This test demonstrates an important security property. ERC-2771 protects against identity-based front-running. The relayer can control transaction ordering but cannot impersonate the user.
What This Test Shows:
The malicious relayer attempts to:
- Intercept the user’s signed meta-transaction
- Send their own message first to “claim” the action
- Then (maybe) submit the user’s transaction
Expected Result: The user’s message should still be correctly attributed to them, not affected by the relayer’s front-running attempt. This is because _msgSender() correctly extracts the original user’s address from the meta-transaction calldata, ensuring the relayer writes to messages[relayerAddress] while the user writes to messages[userAddress]. Completely separate storage slots.
Click to expand: Complete Front-Running Test (test-front-running.js)
Create test-front-running.js:
const { ethers } = require('ethers');
const fs = require('fs');
async function main() {
console.log('=== Testing Front-Running Protection ===\n');
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const addresses = JSON.parse(fs.readFileSync('deployed-addresses.json', 'utf8'));
const userWallet = new ethers.Wallet(
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
provider
);
const relayerWallet = new ethers.Wallet(
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
provider
);
const forwarderArtifact = JSON.parse(
fs.readFileSync('out/MinimalForwarder.sol/MinimalForwarder.json', 'utf8')
);
const recipientArtifact = JSON.parse(
fs.readFileSync('out/RecipientContract.sol/RecipientContract.json', 'utf8')
);
const forwarder = new ethers.Contract(addresses.forwarder, forwarderArtifact.abi, provider);
const recipient = new ethers.Contract(addresses.recipient, recipientArtifact.abi, provider);
const recipientInterface = new ethers.Interface(recipientArtifact.abi);
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
// User wants to set their message
const userMessage = 'User message - this is mine!';
console.log('User wants to set:', userMessage);
// Build and sign meta-transaction
const nonce = await forwarder.getNonce(userWallet.address);
const functionData = recipientInterface.encodeFunctionData('setMessage', [userMessage]);
const request = {
from: userWallet.address,
to: addresses.recipient,
value: 0,
gas: 500000,
nonce: Number(nonce),
data: functionData
};
const domain = {
name: 'MinimalForwarder',
version: '0.0.1',
chainId: chainId,
verifyingContract: addresses.forwarder
};
const types = {
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' }
]
};
const signature = await userWallet.signTypedData(domain, types, request);
console.log('✓ User signed meta-transaction\n');
// SECURITY: Get initial relayer nonce for manual management
// This script sends 2 transactions from relayer (front-run attempt + meta-tx)
let relayerNonce = await provider.getTransactionCount(relayerWallet.address, 'latest');
// ATTACK: Malicious relayer tries to front-run
console.log('⚠️ ATTACK: Relayer tries to front-run by setting their own message first');
const maliciousMessage = 'Relayer was here - hacked!';
try {
const maliciousTx = await recipient.connect(relayerWallet).setMessage(maliciousMessage, {
nonce: relayerNonce
});
await maliciousTx.wait();
console.log(' Relayer set their message:', maliciousMessage);
relayerNonce++; // Increment after first transaction
} catch (error) {
console.log(' Front-run attempt failed:', error.message);
}
// Relayer submits user's meta-transaction
console.log('\nRelayer submits user meta-transaction...');
const forwarderWithRelayer = forwarder.connect(relayerWallet);
const tx = await forwarderWithRelayer.execute(request, signature, {
gasLimit: 600000,
nonce: relayerNonce // Use manually managed nonce
});
await tx.wait();
console.log('✓ User meta-transaction confirmed\n');
// Check final state
const finalMessage = await recipient.messages(userWallet.address);
console.log('=== Results ===');
console.log('Final message:', finalMessage);
console.log('Expected:', userMessage);
console.log('\nFront-run protection:', finalMessage === userMessage ? '✓ WORKS' : '✗ FAILED');
console.log('\nExplanation:');
console.log('The relayer wrote to messages[relayerAddress] when calling setMessage() directly.');
console.log('The user meta-transaction wrote to messages[userAddress] via _msgSender().');
console.log('These are DIFFERENT storage slots, so no interference occurred.');
console.log('ERC-2771 prevents identity impersonation, not transaction reordering.');
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Expected Output:
=== Testing Front-Running Protection ===
User wants to set: User message - this is mine!
✓ User signed meta-transaction
⚠️ ATTACK: Relayer tries to front-run by setting their own message first
Relayer set their message: Relayer was here - hacked!
Relayer submits user meta-transaction...
✓ User meta-transaction confirmed
=== Results ===
Final message: User message - this is mine!
Expected: User message - this is mine!
Front-run protection: ✓ WORKS
Explanation:
The relayer wrote to messages[relayerAddress] when calling setMessage() directly.
The user meta-transaction wrote to messages[userAddress] via _msgSender().
These are DIFFERENT storage slots, so no interference occurred.
ERC-2771 prevents identity impersonation, not transaction reordering.
Key Insight: ERC-2771 protects against identity-based front-running where the relayer tries to impersonate the user. However, it does NOT protect against:
- Timing-based front-running: Relayer can still delay or reorder transactions
- Censorship: Relayer can refuse to submit certain transactions
- Global state front-running: If a function operates on global state (like “first to claim wins”), the relayer can front-run by submitting their own transaction first
For global state operations, you need additional application-level protections like merkle proofs that bind actions to specific addresses, or commit-reveal schemes.
These complete scripts can be run directly after deploying contracts. Each demonstrates a different aspect of meta-transaction behavior and security.
Security Considerations
Meta-transactions introduce several attack surfaces that don’t exist in standard transactions. Let’s examine each one.
Nonce Management
Nonces prevent replay attacks. Without them, an attacker could capture a signed meta-transaction and replay it multiple times.
The forwarder maintains a separate nonce counter for each user address.
Here’s how nonce verification works:
Nonce management diagram
The nonce must be incremented before the external call (checks-effects-interactions pattern). If incremented after, a reentrant call could replay the transaction:
// VULNERABILITY: Nonce incremented after external call
function execute(ForwardRequest calldata req, bytes calldata sig) external {
require(verify(req, sig), "Invalid signature");
// External call happens first
(bool success, ) = req.to.call(req.data);
// VULNERABILITY: Nonce incremented after
// If req.to is malicious and re-enters, nonce is still the same
_nonces[req.from]++;
}
// FIX: Increment nonce before external call
function execute(ForwardRequest calldata req, bytes calldata sig) external {
require(verify(req, sig), "Invalid signature");
// FIX: Increment nonce first
_nonces[req.from]++;
// Now external call is safe from replay
(bool success, ) = req.to.call(req.data);
}
Nonce Management on Failure: If a meta-transaction’s forwarded call reverts, should the nonce still increment?
OpenZeppelin’s MinimalForwarder approach (lines 217-218): Yes, always increment. The nonce increments before the external call, regardless of whether that call succeeds or fails. This prevents a griefing attack where a malicious recipient contract could intentionally revert to block the user’s nonce forever.
This mirrors Ethereum’s account nonce behavior. Once a transaction is included in a block, the nonce increments even if the transaction reverts. The user can then sign a new meta-transaction with the next nonce.
Alternative approaches (not recommended): Some implementations allow nonce reuse on failure, but this creates a denial-of-service vector where attackers can block specific users by causing their transactions to fail repeatedly.
Best Practice: Always increment nonces before external calls (checks-effects-interactions pattern), regardless of call outcome. This is what our MinimalForwarder implementation does.
Replay Across Chains and Forwarders
What if the same forwarder contract is deployed at the same address on multiple chains? An attacker could capture a signed meta-transaction on one chain and replay it on another.
This is why including the chainId in the EIP-712 domain separator is mandatory for production applications. While technically optional according to the EIP-712 specification, omitting chainId creates a cross-chain replay vulnerability.
Always include chainId to bind the signature to a specific chain:
// SECURITY: chainId is REQUIRED to prevent cross-chain replay attacks
// Never omit chainId in production applications
const domain = {
name: 'MinimalForwarder',
version: '1.0.0',
chainId: 1, // REQUIRED: Ethereum mainnet
verifyingContract: forwarderAddress
};
If an attacker tries to replay the signature on a different chain (e.g., Polygon with chainId 137), the signature verification will fail because the domain separator won’t match.
But what about multiple forwarder contracts on the same chain? If you deploy two forwarder instances, each maintains independent nonces. An attacker could potentially replay a signature across forwarders if the recipient contract trusts both.
The solution is to limit the trusted forwarder to a single address per recipient contract:
// SECURE: Only one trusted forwarder
address public immutable trustedForwarder;
// VULNERABILITY: Multiple trusted forwarders create replay risk
mapping(address => bool) public trustedForwarders;
Front-Running and MEV
As demonstrated in the front-running test scenario, ERC-2771 prevents identity impersonation but doesn’t eliminate all front-running risks.
Key Design Principle: Avoid global state operations in meta-transaction recipients. Instead, bind actions to specific addresses:
// ❌ VULNERABLE: Global state allows relayer front-running
uint256 public itemsRemaining = 100; // First-come-first-served
// ✅ SECURE: User-specific eligibility
mapping(address => bool) public canClaim; // Reserved per address
For operations requiring shared state, implement additional protections:
- Merkle proofs binding actions to specific addresses
- Commit-reveal schemes for sensitive operations
- Deadline enforcement to limit timing manipulation windows
Malicious Relayer Scenarios
A malicious relayer has several options for misbehavior.
Censorship: Refuse to submit transactions from certain users. This is hard to prevent without decentralized relayer networks or fallback mechanisms.
Delay: Submit transactions only when it benefits the relayer (e.g., wait for gas prices to spike so users see the cost).
MEV Extraction: Reorder transactions to extract maximum value, similar to how block builders operate.
Griefing: Submit transactions that are designed to fail, wasting the user’s nonce and forcing them to re-sign.
Additional protection can include deadline parameters in custom implementations. However, the standard OpenZeppelin MinimalForwarder doesn’t include deadline checking. Applications can add this as an enhancement or implement off-chain validation to detect and reject stale requests.
Attack Patterns
Let’s examine specific exploit scenarios that target meta-transaction implementations.
Attack: Replay Across Relayers
Imagine two relayer services both operating with the same forwarder contract. A user signs a meta-transaction and sends it to Relayer A. An attacker intercepts the signed message and sends it to Relayer B.
Click to expand: Replay Attack Demonstration
const { ethers } = require('ethers');
/**
* Demonstration of cross-relayer replay attack
* @notice This shows why nonces are needed
*/
async function replayAttack() {
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
// User signs meta-transaction for RelayerA
const userWallet = new ethers.Wallet('USER_PRIVATE_KEY', provider);
const forwarderAddress = '0x...';
// User creates a signed request
// Encode deposit() function call
const recipientInterface = new ethers.Interface(['function deposit() payable']);
const depositData = recipientInterface.encodeFunctionData('deposit');
const request = {
from: userWallet.address,
to: recipientAddress, // Recipient contract
value: 0, // No ETH transferred in the meta-tx itself
gas: 500000,
nonce: 5, // Current nonce
data: depositData // Encoded deposit() call
};
const domain = {
name: 'MinimalForwarder',
version: '0.0.1',
chainId: 31337,
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: 'data', type: 'bytes' }
]
};
const signature = await userWallet.signTypedData(domain, types, request);
console.log('User signed meta-transaction');
console.log('Request:', request);
console.log('Signature:', signature);
// Attacker intercepts the signed message
// ATTACK: Try to submit via different relayer
const relayerAWallet = new ethers.Wallet('RELAYER_A_KEY', provider);
const relayerBWallet = new ethers.Wallet('RELAYER_B_KEY', provider);
const forwarderABI = [
'function execute((address,address,uint256,uint256,uint256,uint256,bytes),bytes) returns (bool, bytes)'
];
const forwarderA = new ethers.Contract(forwarderAddress, forwarderABI, relayerAWallet);
const forwarderB = new ethers.Contract(forwarderAddress, forwarderABI, relayerBWallet);
// RelayerA submits first
console.log('\nRelayerA submitting...');
const txA = await forwarderA.execute(request, signature);
await txA.wait();
console.log('RelayerA succeeded:', txA.hash);
// ATTACK: RelayerB tries to replay the same signature
console.log('\nAttacker (RelayerB) trying to replay...');
try {
const txB = await forwarderB.execute(request, signature);
await txB.wait();
console.log('VULNERABILITY: Replay succeeded!');
} catch (error) {
// FIX: With proper nonce management, this should fail
console.log('FIX: Replay prevented:', error.message);
console.log('Reason: Nonce was incremented after first execution');
}
}
The forwarder’s nonce management prevents this attack. After Relayer A submits the transaction, the nonce increments from 5 to 6. When Relayer B tries to submit the same signature (with nonce 5), verification fails because the expected nonce is now 6.
Attack: Nonce Manipulation
What if an attacker can get a user to sign multiple meta-transactions with non-sequential nonces? They could selectively submit them out of order.
// User signs three meta-transactions
const tx1 = { ...request, nonce: 10 }; // Deposit 1 ETH
const tx2 = { ...request, nonce: 11 }; // Withdraw 0.5 ETH
const tx3 = { ...request, nonce: 12 }; // Withdraw 0.5 ETH
// User expects order: deposit, withdraw, withdraw
// Attacker submits only tx1, skipping tx2 and tx3
// Or attacker delays tx1 and user's balance isn't updated when tx2 executes
The forwarder enforces sequential nonces, but the attacker can still cause denial of service by not submitting transactions. The user must trust the relayer to submit transactions in the order they were signed.
More sophisticated systems use a “queue” nonce system where multiple transactions can be submitted in parallel with dependency chains, but this increases complexity.
Attack: Recipient Contract Confusion
Using msg.sender instead of _msgSender() in recipient contracts creates critical vulnerabilities:
// ❌ VULNERABLE: Checks relayer's balance, not user's
require(balances[msg.sender] >= amount, "Insufficient balance");
// ✅ SECURE: Checks original user's balance
require(balances[_msgSender()] >= amount, "Insufficient balance");
Impact: User funds become inaccessible, or worse, relayer can access user balances. Audit every function in recipient contracts when retrofitting for meta-transaction support.
Attack: ERC-2771 + Delegatecall Vulnerability
In December 2023, OpenZeppelin disclosed a vulnerability affecting recipient contracts that combine ERC-2771 with Multicall patterns using delegatecall. The vulnerability exists entirely in the recipient contract, not the forwarder. An attacker simply sends their own valid meta-transaction with malicious calldata to impersonate any address. No signature interception required.
Why It Works: The root cause is a mismatch in how delegatecall handles msg.sender vs msg.data:
| Property | Behavior in delegatecall |
|---|---|
msg.sender | Preserved (still the forwarder) |
msg.data | Changed to the provided calldata |
The _msgSender() function checks msg.sender to verify the call came from the trusted forwarder, but then reads the address from msg.data. Since delegatecall preserves msg.sender but changes msg.data, an attacker can pass the security check while providing a forged address.
// VULNERABILITY: ERC-2771 + Multicall with delegatecall
contract VulnerableContract is ERC2771Context {
// Standard ERC-2771 _msgSender() implementation
function _msgSender() internal view override returns (address) {
// ① This check passes because delegatecall preserves msg.sender
if (isTrustedForwarder(msg.sender)) {
// ② But msg.data is now the attacker-controlled subcall data
return address(bytes20(msg.data[msg.data.length - 20:]));
}
return msg.sender;
}
// VULNERABILITY: Multicall with delegatecall
function multicall(bytes[] calldata data) external {
for (uint i = 0; i < data.length; i++) {
// delegatecall changes msg.data to data[i]
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Multicall failed");
}
}
}
The Attack Step-by-Step:
- Attacker creates their own valid meta-transaction (no signature interception needed)
- The meta-transaction calls
multicall()with malicious subcall data - The malicious subcall has a victim’s address appended at the end
Original calldata from forwarder:
┌────────────────────────────────────────────────────┐
│ multicall(data[]) │ ... │ ATTACKER_ADDR (20 bytes)│
└────────────────────────────────────────────────────┘
Inside delegatecall, msg.data becomes data[i]:
┌─────────────────────────────────────────────────┐
│ transfer(to, amount) │ VICTIM_ADDR (20 bytes) │
└─────────────────────────────────────────────────┘
↑ _msgSender() reads THIS
Concrete Example:
// Attacker wants to steal tokens from victim
const victimAddress = "0xVICTIM...";
const attackerAddress = "0xATTACKER...";
// Craft malicious subcall with victim address appended
const maliciousCall = ethers.solidityPacked(
['bytes', 'address'],
[
// Function call: transfer tokens to attacker
contract.interface.encodeFunctionData('transfer', [attackerAddress, 1000]),
// Forged sender address
victimAddress
]
);
// Attacker signs their OWN meta-transaction
const request = {
from: attackerAddress,
to: vulnerableContract,
data: contract.interface.encodeFunctionData('multicall', [[maliciousCall]])
};
// Result: Contract transfers tokens FROM victim TO attacker
// The victim never signed anything
This vulnerability demonstrates why production systems should use battle-tested forwarder implementations like ERC2771Forwarder or GSN rather than minimal custom implementations.
Key Takeaways
Meta-transactions enable gasless UX but introduce significant security complexity. Here’s a comparison of the trade-offs:
| Aspect | Standard Transaction | Meta-Transaction |
|---|---|---|
| Gas Payment | User pays | Relayer pays |
| UX Barrier | Requires ETH | No ETH needed |
| Signature Type | Transaction signature | Message signature (EIP-712) |
| Replay Protection | Account nonce (protocol-level) | Contract nonce (application-level) |
| Trust Model | Trustless | Trust relayer for submission |
| Front-Running Risk | Public mempool | Relayer can front-run |
| Censorship Risk | Network-level only | Relayer can censor |
| Implementation Complexity | Simple | Complex (forwarder + recipient) |
| Audit Surface | Standard | Larger (nonce, signature, forwarding) |
Security Checklist for Meta-Transaction Implementations:
- Forwarder uses per-user nonces for replay protection
- Nonces incremented before external calls (checks-effects-interactions)
- EIP-712 domain includes
chainIdto prevent cross-chain replay (best practice) - Deadline parameter enforced to prevent indefinite delays
- Recipient contracts use
_msgSender()notmsg.sender - All recipient functions audited for proper sender extraction
- Signature verification uses EIP-712 typed data (not raw message signing)
- Gas limits specified in forward requests to prevent griefing
- Production-ready forwarder used (e.g., OpenZeppelin ERC2771Forwarder, not MinimalForwarder)
- Single trusted forwarder per recipient (not multiple forwarders)
- No
delegatecallin ERC-2771 contracts or carefully audited if required - Multicall patterns don’t use
delegatecallwith user-provided data - Application-level checks prevent relayer front-running
- Relayer service has rate limiting and abuse prevention
- Users have fallback mechanism if relayer censors them
When to Use Meta-Transactions:
Use meta-transactions when onboarding UX is more important than trustlessness. They work well for:
- New user onboarding (free first transactions)
- Sponsored actions (protocol pays gas for specific operations)
- Mobile dApps where users don’t want to manage gas
- Enterprise applications with predictable usage patterns
Avoid meta-transactions when:
- Users are already crypto-native (they have ETH and wallets)
- Trust in relayer is a concern (adversarial environments)
- Transaction ordering is mission-critical (DeFi trading)
- You can’t audit recipient contracts for proper
_msgSender()usage
Relayer Centralization Concerns:
A single relayer service is a point of failure and trust. Decentralized alternatives include:
- Gas Station Network (GSN): Decentralized relayer network with economic incentives
- Biconomy: Multi-relayer infrastructure with SLAs
- Fallback mechanisms: Allow users to submit transactions directly if relayer fails
The relayer must be monitored for availability, performance, and honest behavior. Service level agreements (SLAs) can help, but ultimately the relayer has significant power over user experience.
Additional Resources
Standards and Specifications:
- EIP-2771: Secure Protocol for Native Meta-Transactions
- EIP-712: Typed Structured Data Hashing and Signing
- EIP-2612: Permit Extension for ERC-20
Implementation Libraries:
- OpenZeppelin ERC2771Forwarder - Production-ready forwarder with deadline enforcement and batch processing (v5.x)
- OpenZeppelin MinimalForwarder - Minimal implementation for testing (v4.x, deprecated in v5)
- OpenZeppelin ERC2771Context - Helper for recipient contracts
Meta-Transaction Infrastructure:
- Biconomy Documentation - Production meta-transaction relayer service
- Gas Station Network (GSN) - Decentralized relayer network
- Gelato Relay - Another relayer service option
Security Research:
- Meta-Transaction Security Considerations - OpenZeppelin security docs (v5.x)
- EIP-2771 Security Audit - Audit findings and recommendations
- Arbitrary Address Spoofing: ERC2771Context Multicall Vulnerability - Critical vulnerability disclosure (December 2023)
- ERC-2771 Delegatecall Vulnerability - Gelato’s security considerations
Tools:
- Foundry - For testing meta-transaction flows locally
- Tenderly - Transaction simulation and debugging
- Ethers.js v6 EIP-712 Utilities - For signing typed data
Chapters
Previous chapter