🎓 A1Learner
🔍
0 of 45 lessons completed

Solidity Replay Protection

Replay protection is the rule that a signed message may be used only once. A nonce per signer is checked and bumped on each use, so a second use fails.

The first use succeeds and bumps the nonce

A fresh signature matches the current nonce. The claim lands and the nonce moves from 0 to 1.

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

contract ReplayGuard {
    mapping(address => uint256) public nonces;
    mapping(address => uint256) public claims;

    function signedDigest(address signer, uint256 nonce) public view returns (bytes32) {
        bytes32 dataHash = keccak256(abi.encodePacked(address(this), signer, nonce));
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
    }

    function claim(address signer, uint256 nonce, uint8 v, bytes32 r, bytes32 s) public {
        require(nonce == nonces[signer], "bad nonce");
        require(ecrecover(signedDigest(signer, nonce), v, r, s) == signer, "bad signature");
        nonces[signer] += 1;
        claims[signer] += 1;
    }
}

The second use of the same signature reverts

The nonce has moved on, so the old signature no longer matches the current nonce. The replay reverts.

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

contract ReplayGuard {
    mapping(address => uint256) public nonces;
    mapping(address => uint256) public claims;

    function signedDigest(address signer, uint256 nonce) public view returns (bytes32) {
        bytes32 dataHash = keccak256(abi.encodePacked(address(this), signer, nonce));
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
    }

    function claim(address signer, uint256 nonce, uint8 v, bytes32 r, bytes32 s) public {
        require(nonce == nonces[signer], "bad nonce");
        require(ecrecover(signedDigest(signer, nonce), v, r, s) == signer, "bad signature");
        nonces[signer] += 1;
        claims[signer] += 1;
    }
}

A signature for another contract does not run here

The signed data includes this contract address. A signature made for a different contract recovers a different digest and fails here.

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

contract ReplayGuard {
    mapping(address => uint256) public nonces;
    mapping(address => uint256) public claims;

    function signedDigest(address signer, uint256 nonce) public view returns (bytes32) {
        bytes32 dataHash = keccak256(abi.encodePacked(address(this), signer, nonce));
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
    }

    function claim(address signer, uint256 nonce, uint8 v, bytes32 r, bytes32 s) public {
        require(nonce == nonces[signer], "bad nonce");
        require(ecrecover(signedDigest(signer, nonce), v, r, s) == signer, "bad signature");
        nonces[signer] += 1;
        claims[signer] += 1;
    }
}

Example

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

contract SignedClaim {
    mapping(address => uint256) public nonces;
    mapping(address => uint256) public claimedAmount;

    function signedDigest(address signer, uint256 amount, uint256 nonce) public view returns (bytes32) {
        bytes32 dataHash = keccak256(abi.encodePacked(address(this), signer, amount, nonce));
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
    }

    function claim(address signer, uint256 amount, uint256 nonce, uint8 v, bytes32 r, bytes32 s) public {
        require(nonce == nonces[signer], "bad nonce");
        require(ecrecover(signedDigest(signer, amount, nonce), v, r, s) == signer, "bad signature");
        nonces[signer] += 1;
        claimedAmount[signer] += amount;
    }
}

Key points

  • Replay: reusing a valid signed message a second time to repeat its effect.
  • Nonce: a counter per signer that marks which signed message is current.
  • Use a nonce to make each signature valid for exactly one use.
  • Domain separation: binding signed data to one contract, so a signature for one contract cannot run on another.
  • Use the contract address inside the signed data to bind a signature to that contract.

In this example, a signed claim for 50 lands once, its replay reverts, and a fresh signature with nonce 1 claims 30 more, for a total of 80.

Note

the nonce must be bumped before any outside call, so a reentrant replay cannot slip through.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

A signature was already used once. Its nonce has been bumped. What happens if the same signature is submitted again?