Development of Meta-Transaction System (EIP-2771)
User installed the application, received NFT or tokens, wants to do something — and hits "need ETH for gas". At this step, 30 to 60% of new users are lost depending on audience. Meta-transactions are a mechanism that removes this barrier: user signs an intention, application pays for gas.
EIP-2771 standardized the architecture: trusted forwarder — a contract that the target contract trusts to relay calls while preserving the original msg.sender.
How EIP-2771 Works
Without meta-transactions: user → (directly) → Contract. msg.sender in the contract is the user's address.
With meta-transactions: user → (signed request) → Relayer → Forwarder → Contract. msg.sender in the contract is the Forwarder's address. The contract doesn't know the real sender.
Solution — contract checks that msg.sender is a trusted forwarder, then reads the real address from the last 20 bytes of calldata:
// OpenZeppelin ERC2771Context
function _msgSender() internal view virtual override returns (address) {
if (isTrustedForwarder(msg.sender) && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
}
return super._msgSender();
}
All msg.sender in contract business logic must be replaced with _msgSender(). This is the only change to an existing contract — if it inherits ERC2771Context from OpenZeppelin.
System Components
Trusted Forwarder
Validates user signatures (EIP-712 typed data), checks nonce (replay protection), relays call to target contract, appending the user's address to the end of calldata.
OpenZeppelin MinimalForwarder — simple implementation, good to start. For production we recommend OpenGSN Forwarder or custom with additional checks: deadline, domain separator, address whitelisting.
struct ForwardRequest {
address from; // user
address to; // target contract
uint256 value; // ETH (usually 0)
uint256 gas; // gas limit
uint256 nonce; // replay protection
bytes data; // calldata
}
EIP-712 Signature
User signs structured data, not a raw hash. This allows MetaMask and other wallets to show human-readable request content before signing.
// Client: prepare signature
const domain = {
name: "MyForwarder",
version: "1",
chainId: await signer.getChainId(),
verifyingContract: forwarderAddress,
};
const signature = await signer.signTypedData(domain, types, request);
Relayer
Accepts signed request, validates it, sends transaction on behalf of user, paying for gas. Can be:
- Centralized — own backend, simplest option
- OpenGSN — decentralized network of relayers
- Biconomy — managed service with dashboard and analytics
- Gelato Network — for automation and conditional calls
For most projects at start — centralized relayer on own backend. Simpler, faster, and cheaper while TPS is low. Decentralization is needed when centralized relayer becomes a single point of failure with real consequences.
Key Vulnerabilities and Protections
Replay attack. Signed request without nonce or with predictable nonce can be executed multiple times. Forwarder must store nonce per-user and increment after each successful call.
Gas griefing. User specifies minimum gas in request, relayer sends transaction with that limit — contract fails with out-of-gas, but gas is spent. Solution: relayer checks it has enough gas for execution + overhead for forwarder logic.
Forwarder spoofing. If contract accepts any forwarder as trusted — attacker can forge msg.sender. List of trusted forwarders should be fixed or changeable only via multisig.
_msgSender() vs msg.sender. Most common error when integrating EIP-2771 is using msg.sender where _msgSender() should be. Static analysis via Slither catches some of these, but not all.
Integration with Existing Contract
If contract is already in production without EIP-2771 support — it can't be changed (without upgrade proxy). There's a workaround: meta-transactions via EIP-1271 (contract signatures), where user deploys their own account contract. But this is more complex and expensive for user.
Conclusion: if meta-transactions are needed, support for ERC2771Context should be built in during initial development, not after.
Process and Timeline
| Component | Timeline |
|---|---|
| Integrate ERC2771Context into contract + tests | 1 day |
| MinimalForwarder deployment + configuration | 0.5 day |
| Backend relayer (Node.js + ethers.js) | 1-2 days |
| Frontend integration (wagmi + signTypedData) | 1 day |
| End-to-end scenario tests | 1 day |
Total for complete system with centralized relayer: 3-5 working days. With Biconomy or OpenGSN instead of custom backend: 2-3 days (less backend work, more configuration). Cost depends on existing contract condition and relayer infrastructure choice.







