DEV Community

metadevdigital
metadevdigital

Posted on

Signature Replay Attacks in Solidity: Why Your Metatransactions Are Vulnerable by Default

Signature Replay Attacks in Solidity: Why Your Metatransactions Are Vulnerable by Default

cover

If you've ever built a REST API in Java or Go, you know about idempotency keys. You append a unique identifier to a request so that if a client accidentally sends it twice (network hiccup, retry logic gone wrong), your server processes it exactly once. The database rejects the duplicate. Problem solved.

Then you move to Web3 and realize: there is no database rejecting your duplicate. And worse, anyone can replay your transaction.

This is the signature replay attack, and it's been responsible for real money losses. The MEV crisis that hit protocols throughout 2021-2023 included replay vulnerabilities. Curve Finance's governance vote in 2023 highlighted this exact problem when signatures were replayed across different chains, creating unintended voting power.

The Web2 Comparison: Signed Requests Without a Boss

In traditional systems, you authenticate an action by validating the signature, checking the timestamp, and querying your database to see if you've already processed this exact request. Rejection happens server-side because you own the ledger and can say "no, I've already seen that."

// Web2: A user signs a request to your API
const request = {
  userId: 123,
  action: "transfer",
  amount: 100,
  timestamp: Date.now()
};

// Your API server validates:
// 1. Is this signature valid?
// 2. Is this timestamp recent?
// 3. Have I seen this exact request before?

// Rejection happens server-side because YOU own the database.
if (requestCache.has(requestHash)) {
  return 403; // Already processed
}
Enter fullscreen mode Exit fullscreen mode

In Solidity? You don't control anything. The signature lives on a public blockchain, forever. Any node, any user, anyone can grab that signature and replay it.

How Signature Replay Works (The Vulnerable Pattern)

Here's what developers new to Web3 often write:

pragma solidity ^0.8.0;

contract VulnerableMetaTx {
    mapping(address => uint256) public balances;

    function executeTransfer(
        address from,
        address to,
        uint256 amount,
        bytes calldata signature
    ) public {
        // Recover who signed this
        bytes32 messageHash = keccak256(abi.encodePacked(from, to, amount));
        bytes32 ethSignedMessageHash = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
        );
        address signer = recoverSigner(ethSignedMessageHash, signature);

        require(signer == from, "Invalid signature");
        require(balances[from] >= amount, "Insufficient balance");

        balances[from] -= amount;
        balances[to] += amount;

        emit Transfer(from, to, amount);
    }

    function recoverSigner(bytes32 hash, bytes calldata sig) 
        internal pure returns (address) {
        // ECDSA recovery logic
        (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
        return ecrecover(hash, v, r, s);
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks reasonable? It's not.

User Alice signs {from: Alice, to: Bob, amount: 100} and broadcasts it on mainnet. The transaction executes. Three months later, someone extracts that same transaction data and calls executeTransfer() again—same signature, same parameters. The contract can't tell the difference because there's nothing stopping it. Alice loses another 100 tokens. Or someone takes that transaction and replays it on Polygon. Alice has now lost tokens on two chains from signing once.

The Fix: Nonces and Chain Context

Here's what actually works:

pragma solidity ^0.8.0;

contract SecureMetaTx {
    mapping(address => uint256) public nonces;
    uint256 public chainId;

    constructor() {
        chainId = block.chainid;
    }

    function executeTransfer(
        address from,
        address to,
        uint256 amount,
        uint256 nonce,
        bytes calldata signature
    ) public {
        // CRITICAL: Include chain ID and nonce in the signed message
        bytes32 messageHash = keccak256(
            abi.encodePacked(
                chainId,            // Chain-specific
                address(this),      // Contract-specific
                from,
                to,
                amount,
                nonce               // Nonce - must be incremented
            )
        );

        bytes32 ethSignedMessageHash = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
        );

        address signer = recoverSigner(ethSignedMessageHash, signature);
        require(signer == from, "Invalid signature");
        require(nonce == nonces[from], "Invalid nonce");

        nonces[from]++;
        require(balances[from] >= amount, "Insufficient balance");

        balances[from] -= amount;
        balances[to] += amount;

        emit Transfer(from, to, amount, nonce);
    }
}
Enter fullscreen mode Exit fullscreen mode

Three critical changes here. Chain ID makes the signature invalid on other chains—someone can't copy your Ethereum transaction to Polygon. Nonce ensures every signature includes the next expected nonce for that user, and you increment it after execution. Replay the same signature and the nonce won't match, so it gets rejected. Contract Address prevents a signature meant for Contract A from accidentally working on Contract B. The nonce acts like your idempotency key: in Web2, the server rejects duplicates; in Solidity, the contract state does.

Real-World Reference

EIP-712 formalizes this approach and most wallet libraries support it now. Uniswap's permit() function (based on EIP-2612) uses this exact pattern:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external
Enter fullscreen mode Exit fullscreen mode

The deadline parameter prevents replay attacks from old transactions. It's another layer of the puzzle.

What You Need to Do Right Now

If you're maintaining a contract with signature verification, start here. Audit your message hash construction. Does it include block.chainid? If not, add it. Your contract probably doesn't have a nonce system, so implement one—map each signer to an incrementing counter. Add a deadline or expiry parameter to time-limit how long a signature is valid. Use EIP-712 instead of \x19Ethereum Signed Message if you're building new code. Deploy a test version to Sepolia, have a friend try to replay a transaction, and watch it fail. That feeling is what security actually feels like.


Top comments (0)