What is Signature Replay?

A signature replay attack happens when a valid signature is reused multiple times, even though the signer only intended to approve ONE transaction.

Think of it like this:

You sign a check for $100 to your friend
Your friend photocopies the check
Your friend cashes it 10 times
You lose $1,000 instead of $100! 💸

Simple Example: Bank Withdrawal

The Vulnerable Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

/*
Alice signs a message off-chain allowing Bob to withdraw 1 ETH
But Bob can reuse the same signature multiple times to drain Alice's account!
*/

contract VulnerableBank {
    mapping(address => uint) public balances;

    constructor() payable {}

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // ❌ VULNERABLE: No replay protection
    function withdraw(
        address _from,
        address _to,
        uint _amount,
        bytes memory _signature
    ) public {
        // Verify signature
        bytes32 messageHash = getMessageHash(_from, _to, _amount);
        bytes32 ethSignedHash = getEthSignedMessageHash(messageHash);

        require(recoverSigner(ethSignedHash, _signature) == _from, "Invalid signature");
        require(balances[_from] >= _amount, "Insufficient balance");

        // Transfer funds
        balances[_from] -= _amount;
        balances[_to] += _amount;
    }

    function getMessageHash(address _from, address _to, uint _amount)
        public pure returns (bytes32)
    {
        return keccak256(abi.encodePacked(_from, _to, _amount));
    }

    function getEthSignedMessageHash(bytes32 _messageHash)
        public pure returns (bytes32)
    {
        return keccak256(abi.encodePacked("\\\\x19Ethereum Signed Message:\\\\n32", _messageHash));
    }

    function recoverSigner(bytes32 _ethSignedHash, bytes memory _signature)
        public pure returns (address)
    {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
        return ecrecover(_ethSignedHash, v, r, s);
    }

    function splitSignature(bytes memory sig)
        public pure returns (bytes32 r, bytes32 s, uint8 v)
    {
        require(sig.length == 65, "Invalid signature length");

        assembly {
            r := mload(add(sig, 32))
            s := mload(add(sig, 64))
            v := byte(0, mload(add(sig, 96)))
        }
    }
}

Step-by-Step Attack

STEP 1: Alice Signs a Message (Off-Chain)

// Alice wants to allow Bob to withdraw 1 ETH ONCE

const message = ethers.utils.solidityKeccak256(
    ['address', 'address', 'uint256'],
    [alice.address, bob.address, ethers.utils.parseEther('1')]
);

// Alice signs the message
const signature = await alice.signMessage(ethers.utils.arrayify(message));

/*
Alice's signature:
0x1a2b3c4d5e6f... (65 bytes)

Alice thinks: "This allows Bob to withdraw 1 ETH one time"
*/

STEP 2: Alice Gives Signature to Bob

Alice → Bob: "Here's my signature, withdraw 1 ETH"

Signature contents:
├─ From: Alice (0xAlice...)
├─ To: Bob (0xBob...)
├─ Amount: 1 ETH
└─ Valid: ✅ Alice signed it

Alice expects: Bob withdraws 1 ETH, done!

STEP 3: Bob Withdraws 1 ETH (Legitimate)

// Bob uses the signature
await bank.withdraw(
    alice.address,    // from
    bob.address,      // to
    parseEther('1'),  // amount
    signature         // Alice's signature
);

// ✅ Success! Bob gets 1 ETH

Alice's Balance:
Before: 10 ETH
After:   9 ETH ✅ (Expected)

STEP 4: Bob Realizes He Can Reuse the Signature! 🚨

// Bob calls the SAME function with the SAME signature again!

await bank.withdraw(
    alice.address,    // from (same)
    bob.address,      // to (same)
    parseEther('1'),  // amount (same)
    signature         // SAME SIGNATURE! ❌
);

// ✅ Success AGAIN! Bob gets another 1 ETH!

Alice's Balance:
Before: 9 ETH
After:  8 ETH ❌ (Not expected!)

STEP 5: Bob Drains Alice's Account