EIP-2771 Vulnerability Patterns

December 20, 2025

EIP-2771 defines a standard for receiving meta-transactions through a trusted forwarder, enabling gasless transactions where a relayer pays the gas fees on behalf of users.

This article summarizes 1 EIP-2771 vulnerability pattern with real audit cases to help you understand the risks of improper nonce handling in meta-transaction implementations.

EIP-2771 Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                    EIP-2771: META-TRANSACTIONS                          │
└─────────────────────────────────────────────────────────────────────────┘

What is a Meta-Transaction?
→ User signs a message off-chain (no gas needed)
→ Relayer submits the transaction on-chain (pays gas)
→ Contract executes the action as if user sent it

┌─────────────────────────────────────────────────────────────────────────┐
│                    TRADITIONAL vs META-TRANSACTION                      │
└─────────────────────────────────────────────────────────────────────────┘

Traditional Transaction:
┌──────────────┐     sends tx + pays gas     ┌──────────────┐
│     User     │ ──────────────────────────► │   Contract   │
│  (has ETH)   │                             │              │
└──────────────┘                             └──────────────┘
       ↑
       │ Problem: User needs ETH for gas!
       │ Bad UX for new users, NFT minting, etc.

Meta-Transaction (EIP-2771):
┌──────────────┐    1. signs message         ┌──────────────┐
│     User     │ ───────────────────────────►│   Relayer    │
│  (no ETH!)   │    (off-chain, free)        │  (has ETH)   │
└──────────────┘                             └──────┬───────┘
                                                    │
                                    2. submits tx   │
                                       + pays gas   │
                                                    ▼
                                            ┌──────────────┐
                                            │   Contract   │
                                            │  (Trusted    │
                                            │   Forwarder) │
                                            └──────────────┘
                                                    │
                                    3. executes as  │
                                       if user sent │
                                                    ▼
                                            ┌──────────────┐
                                            │   Target     │
                                            │   Contract   │
                                            └──────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                    HOW META-TX SIGNATURE WORKS                          │
└─────────────────────────────────────────────────────────────────────────┘

Step 1: User constructs the meta-transaction data
┌────────────────────────────────────────────────────────────────────────┐
│  MetaTransaction {                                                      │
│      nonce: 5,                    // Current nonce for this user        │
│      from: 0xUser...,             // User's address                     │
│      functionSignature: 0x...     // Encoded function call              │
│  }                                 // e.g., transfer(to, 100)           │
└────────────────────────────────────────────────────────────────────────┘

Step 2: User computes the message hash (EIP-712 typed data)
┌────────────────────────────────────────────────────────────────────────┐
│  messageHash = keccak256(                                               │
│      abi.encodePacked(                                                  │
│          "\x19\x01",              // EIP-712 prefix                     │
│          DOMAIN_SEPARATOR,        // Chain-specific (chainId, contract) │
│          keccak256(abi.encode(    // Struct hash                        │
│              META_TRANSACTION_TYPEHASH,                                 │
│              metaTx.nonce,                                              │
│              metaTx.from,                                               │
│              keccak256(metaTx.functionSignature)                        │
│          ))                                                             │
│      )                                                                  │
│  )                                                                      │
└────────────────────────────────────────────────────────────────────────┘

Step 3: User signs with private key (off-chain, no gas!)
┌────────────────────────────────────────────────────────────────────────┐
│  (v, r, s) = ecdsaSign(messageHash, userPrivateKey)                     │
│                                                                        │
│  This produces:                                                         │
│  • sigR: 32 bytes                                                       │
│  • sigS: 32 bytes                                                       │
│  • sigV: 1 byte (27 or 28)                                              │
└────────────────────────────────────────────────────────────────────────┘

Step 4: Contract verifies signature on-chain
┌────────────────────────────────────────────────────────────────────────┐
│  function verify(user, metaTx, r, s, v) returns (bool) {                │
│      // Reconstruct the message hash                                    │
│      bytes32 hash = hashMetaTransaction(metaTx);                        │
│                                                                        │
│      // Recover signer from signature                                   │
│      address signer = ecrecover(hash, v, r, s);                         │
│                                                                        │
│      // Check: recovered signer == claimed user?                        │
│      return signer == user && signer != address(0);                     │
│  }                                                                      │
└────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                    KEY COMPONENTS                                       │
└─────────────────────────────────────────────────────────────────────────┘

1. User Signature (EIP-712)
   • User signs: hash(DOMAIN_SEPARATOR, nonce, from, functionSignature)
   • EIP-712 provides human-readable signing in wallets
   • DOMAIN_SEPARATOR prevents cross-chain/cross-contract replay
   • No gas required to sign (off-chain operation)

2. Nonce
   • Unique number per user, increments each tx
   • CRITICAL: Prevents replay attacks
   • Must be consumed even if tx fails!

3. Trusted Forwarder
   • Verifies signature using ecrecover()
   • Appends user address to calldata (last 20 bytes)
   • Target contract uses _msgSender() to extract real user

4. Relayer
   • Submits transaction on-chain
   • Pays gas fees
   • May be incentivized via fees or protocol rewards

┌─────────────────────────────────────────────────────────────────────────┐
│                    NONCE: THE CRITICAL COMPONENT                        │
└─────────────────────────────────────────────────────────────────────────┘

Why Nonces Matter:

Without nonce:
┌──────────────┐     signs "transfer 100 USDC"     ┌──────────────┐
│     User     │ ─────────────────────────────────►│   Relayer    │
└──────────────┘                                   └──────┬───────┘
                                                          │
                     ┌────────────────────────────────────┤
                     │                                    │
                     ▼                                    ▼
              Submit once                          Submit AGAIN!
              (intended)                           (replay attack!)
                     │                                    │
                     ▼                                    ▼
              -100 USDC                            -100 USDC
                                                   (stolen!)

With nonce:
┌──────────────┐     signs "nonce=5, transfer 100"     ┌──────────────┐
│     User     │ ─────────────────────────────────────►│   Relayer    │
└──────────────┘                                       └──────┬───────┘
                                                              │
                       ┌──────────────────────────────────────┤
                       │                                      │
                       ▼                                      ▼
                Submit once                            Submit AGAIN!
                nonce 5 → 6 ✓                          nonce=5 but
                                                       expected=6 ✗
                       │                                      │
                       ▼                                      ▼
                -100 USDC                              REJECTED!
                (intended)                             (replay blocked)

┌─────────────────────────────────────────────────────────────────────────┐
│                    THE NONCE INVARIANT                                  │
└─────────────────────────────────────────────────────────────────────────┘

CRITICAL RULE: Nonce must be incremented for EVERY execution attempt,
               regardless of whether the call succeeds or fails!

Why? If nonce only increments on success:

1. User signs tx with nonce=5
2. Tx fails (e.g., insufficient balance)
3. Nonce stays at 5 (not incremented!)
4. Later, user gets more balance
5. Attacker replays the SAME signed tx
6. Now it succeeds! User didn't want this!

Correct implementation:
→ Increment nonce BEFORE the call
→ Use try/catch or don't revert on call failure
→ Nonce consumed = signature invalidated

Vulnerability Patterns

1. Meta-Transaction Replay on Failed Execution

Prerequisite Knowledge:

EIP-2771 meta-transaction pattern requires a nonce mechanism to prevent replay attacks. The nonce must be incremented for every execution attempt, regardless of whether the underlying call succeeds or fails. The signature is generated over the message hash which includes the nonce, so reusing a nonce allows the same signature to be valid again.

Root Cause:

The contract increments the user’s nonce only after the low-level call succeeds. If the call fails and reverts, the entire transaction is reverted, including the nonce increment. This violates the fundamental requirement that nonces must be consumed to prevent signature reuse.

Attack Vector:

An attacker monitors for failed meta-transactions (e.g., due to insufficient balance, time-sensitive conditions, or failed external calls). After the initial transaction fails and reverts (leaving the nonce unchanged), the attacker waits for conditions to change (e.g., user receives more funds) and then resubmits the exact same meta-transaction with the original signature. Since the nonce was never incremented, the signature remains valid, and the transaction now succeeds, executing an action against the user’s will.

Source:

  • [codehawks/2024-11-one-world] NativeMetaTransaction.sol :: executeMetaTransaction() failed txs are open to replay attacks (M-01.md)
    • contracts/meta-transaction/NativeMetaTransaction.sol

      Business Context: One World Gasless NFT Minting

      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    WHAT IS ONE WORLD?                                   │
      └─────────────────────────────────────────────────────────────────────────┘
      
      One World is an NFT platform that uses meta-transactions to enable
      gasless minting and trading for users without ETH.
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    META-TX FLOW IN ONE WORLD                            │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Normal flow:
      ┌──────────────┐     1. Sign "buy NFT #123"     ┌──────────────┐
      │     User     │ ──────────────────────────────►│   Relayer    │
      │  (no ETH)    │        nonce = 5               │              │
      └──────────────┘                                └──────┬───────┘
                                                             │
                                             2. Submit tx    │
                                                             ▼
                                                     ┌──────────────┐
                                                     │  Contract    │
                                                     │  nonce: 5→6  │
                                                     │  buy NFT ✓   │
                                                     └──────────────┘
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    THE VULNERABILITY                                    │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Attack scenario:
      
      Step 1: User signs meta-tx
      ┌──────────────┐     signs "sell NFT for 1 ETH"     ┌──────────────┐
      │     User     │ ──────────────────────────────────►│   Relayer    │
      │  nonce = 5   │                                    │              │
      └──────────────┘                                    └──────────────┘
      
      Step 2: First attempt FAILS (e.g., buyer has no funds)
      ┌──────────────┐
      │  Contract    │
      │  Execute...  │
      │  REVERT!     │ ← Call fails, entire tx reverts
      │  nonce = 5   │ ← Nonce NOT incremented!
      └──────────────┘
      
      Step 3: Time passes, conditions change
      → NFT price increases to 10 ETH
      → User no longer wants to sell for 1 ETH
      → User thinks the old signature is "expired"
      
      Step 4: Attacker replays the old signature!
      ┌──────────────┐     replays old signature     ┌──────────────┐
      │   Attacker   │ ─────────────────────────────►│  Contract    │
      │              │     nonce = 5 (still valid!)  │              │
      └──────────────┘                               └──────┬───────┘
                                                            │
                                                            ▼
                                                     ┌──────────────┐
                                                     │  Sells NFT   │
                                                     │  for 1 ETH   │
                                                     │  (worth 10!) │
                                                     └──────────────┘
      
      User loses 9 ETH worth of value!
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    THE BUG IN CODE                                      │
      └─────────────────────────────────────────────────────────────────────────┘
      
      function executeMetaTransaction(...) {
          // 1. Verify signature
          require(verify(userAddress, metaTx, sigR, sigS, sigV), "...");
      
          // 2. Increment nonce
          nonces[userAddress] = nonces[userAddress] + 1;  // ← Happens here
      
          // 3. Execute call
          (bool success, bytes memory returnData) = address(this).call(...);
          require(success, "Function call not successful");  // ← REVERT here!
                      ↑
                      │
          If this fails, the ENTIRE transaction reverts,
          including the nonce increment from step 2!
      
          return returnData;
      }
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    FIX                                                  │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Option 1: Don't revert on failure, just return success status
      ┌────────────────────────────────────────────────────────────┐
      │ nonces[userAddress]++;                                     │
      │ (bool success, bytes memory data) = address(this).call(...);│
      │ // Don't require(success) - let caller handle failure      │
      │ return (success, data);                                    │
      └────────────────────────────────────────────────────────────┘
      
      Option 2: Use try/catch to ensure nonce is always consumed
      ┌────────────────────────────────────────────────────────────┐
      │ nonces[userAddress]++;  // Always increment first          │
      │ try this.executeCall(...) returns (bytes memory data) {    │
      │     return data;                                           │
      │ } catch {                                                  │
      │     // Nonce already incremented, signature invalidated    │
      │     revert("Execution failed");                            │
      │ }                                                          │
      └────────────────────────────────────────────────────────────┘

      Vulnerable Code:

      function executeMetaTransaction(
          address userAddress,
          bytes memory functionSignature,
          bytes32 sigR,
          bytes32 sigS,
          uint8 sigV
      ) public payable returns (bytes memory) {
          MetaTransaction memory metaTx = MetaTransaction({
              nonce: nonces[userAddress],
              from: userAddress,
              functionSignature: functionSignature
          });
      
          require(
              verify(userAddress, metaTx, sigR, sigS, sigV),
              "Signer and signature do not match"
          );
      
          // increase nonce for user (to avoid re-use)
          nonces[userAddress] = nonces[userAddress] + 1; // VULNERABILITY: Nonce increment gets reverted if call fails
      
          emit MetaTransactionExecuted(
              userAddress,
              msg.sender,
              functionSignature,
              hashMetaTransaction(metaTx)
          );
      
          // Append userAddress and relayer address at the end to extract it from calling context
          (bool success, bytes memory returnData) = address(this).call{value: msg.value}(
              abi.encodePacked(functionSignature, userAddress)
          );
          require(success, "Function call not successful"); // VULNERABILITY: Reverts entire tx, rolling back nonce increment
      
          return returnData;
      }

EIP-2771 Audit Checklist

When auditing contracts that implement meta-transactions, check for these issues:

# Check Item Related Pattern
Does failed call cause full tx revert? require(success) reverts entire tx, rolling back nonce increment Pattern 1
Is nonce consumed even on call failure? Use try/catch or return failure status instead of reverting Pattern 1
Signature includes expiration timestamp? Without expiry, signatures valid forever for replay Pattern 1
Trusted forwarder properly validates? Malicious forwarder can spoof _msgSender() Pattern 1