Every audited smart contract tutorial on this site so far has covered reentrancy, flash loan oracle attacks, and access control gaps. There’s a fourth bug class that drains just as many wallets and gets far less coverage: signature replay. It doesn’t touch a single line of your contract’s business logic. It exploits how your contract verifies who signed what, and when a signature can be reused somewhere it was never meant to work.
This tutorial walks through building, breaking, and fixing an EIP-2612 Permit implementation using Foundry. You’ll write a vulnerable contract, forge a working replay exploit against it in a test, then patch it with the nonce, deadline, and domain-separator checks that OpenZeppelin’s Contracts 5.7 library ships by default. By the end you’ll have a complete, runnable Foundry project and a checklist you can point at any contract that accepts off-chain signatures.
Why signature replay still works in 2026
Signature-based approvals exist because on-chain approve() calls cost gas and require a second transaction before the real action can happen. EIP-2612’s permit() function fixes that by letting a user sign an off-chain message (an EIP-712 typed-data payload) that a relayer or dApp submits on their behalf. No gas, no second wallet popup, one clean UX flow. Uniswap, Aave, Compound forks, and most modern ERC-20 tokens now ship it.
The problem is what happens when the contract accepting that signature doesn’t check three things: a nonce that increments after use, a deadline that expires, and a domain separator tied to the specific chain and contract address. Skip any one of those and the exact same signature can be replayed, sometimes on the same contract twice, sometimes on a different chain entirely where the token was also deployed with an identical constructor.
Wallet drainer kits circulating in 2025 and 2026 lean on a related but distinct trick: they don’t need to break the cryptography at all. They get a victim to sign a legitimate-looking EIP-712 Permit, Seaport, or Permit2 payload where the fine print sets the spender to an attacker contract with a near-unlimited allowance. That’s a UX and consent problem, not a nonce bug, and this tutorial covers both: the contract-level replay bug you can test and fix in code, and the signing-hygiene checklist that protects users even when the contract logic is correct.
The fix pattern for both problems is the same shape, whether you’re protecting a contract or a personal wallet: constrain what a signed artifact can authorize, verify it independently, and never let a single signature or key do more than its intended job. This tutorial focuses on the contract side of that equation, where the fix is a testable, deployable piece of code rather than a matter of user judgment.
It’s also worth being precise about scope. This is not a reentrancy tutorial, a flash-loan oracle tutorial, or an access-control tutorial — those bug classes each have their own attack surface and their own Foundry test patterns. Signature replay lives in a different part of the codebase entirely: the verification function that decides whether an off-chain-signed message should be trusted at all. A contract can pass every reentrancy guard and every access-control check and still be fully drainable if that one function has no memory of which signatures it has already consumed.
Prerequisites: tools and versions
Install these before starting. Version numbers matter for this tutorial because Solidity’s compiler and OpenZeppelin’s library both changed their default handling of a few edge cases discussed below.
- Foundry — stable release line 1.3.x or newer (forge, cast, anvil). Install or update with
foundryup. - Solidity compiler — 0.8.36, the latest stable release as of September 2026. The tutorial’s contracts pin this version in
foundry.toml. - OpenZeppelin Contracts — v5.7.0, released July 30, 2026, which includes the current
ERC20Permit,EIP712, andECDSAimplementations referenced throughout. - Slither — any current release, installed via
pip install slither-analyzer, used in Step 9 for a static cross-check. - Node.js 20+ — only needed if you want to generate signatures from a script instead of inside a Foundry test (optional, covered in the advanced tips section).
- Basic familiarity with Solidity, ECDSA signatures, and how
forge testworks. You don’t need prior EIP-712 experience — that’s covered from scratch below.
Confirm your toolchain before touching any code:
forge --version
# forge 1.3.2 (or newer stable / nightly build)
solc --version
# solc, the solidity compiler commandline interface
# Version: 0.8.36+commit.<hash>
slither --version
# 0.10.x or newer
Step 1: Scaffold the Foundry project
Start with a clean Foundry project and pull in OpenZeppelin Contracts as a dependency. Build both the vulnerable version and the fixed version inside the same repo so you can diff them directly.
forge init signature-replay-lab
cd signature-replay-lab
forge install OpenZeppelin/[email protected]
echo "solc_version = \"0.8.36\"" >> foundry.toml
Add a remapping so imports resolve cleanly:
echo "@openzeppelin/=lib/openzeppelin-contracts/" > remappings.txt
Your directory structure should now have src/, test/, lib/openzeppelin-contracts/, and the two config files. Run forge build once to confirm the toolchain compiles with no source files yet. If this fails, your remapping or Solidity version is misconfigured before you’ve written a line of vulnerable code, and every later step will inherit the same error.
Step 2: Write a deliberately vulnerable Permit-style contract
This is the core teaching contract. It accepts an EIP-712 signature authorizing a token transfer, but it skips the nonce increment that real implementations require. It’s structurally similar to bugs that have shown up in custom “gasless approval” contracts written outside of audited libraries.
// src/VulnerableRelay.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract VulnerableRelay is EIP712 {
using ECDSA for bytes32;
mapping(address => uint256) public balances;
bytes32 private constant TRANSFER_TYPEHASH =
keccak256("Transfer(address from,address to,uint256 amount,uint256 nonce)");
// BUG: nonce is part of the signed message but never checked or stored on-chain.
mapping(address => uint256) public nonces;
constructor() EIP712("VulnerableRelay", "1") {}
function fund(address who, uint256 amount) external {
balances[who] += amount;
}
function transferWithSig(
address from,
address to,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
bytes32 structHash = keccak256(
abi.encode(TRANSFER_TYPEHASH, from, to, amount, nonce)
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = digest.recover(signature);
require(signer == from, "invalid signature");
// BUG: nonces[from] is read nowhere above, and never incremented here.
// The same (from, to, amount, nonce) signature can be replayed forever.
balances[from] -= amount;
balances[to] += amount;
}
}
Notice the nonces mapping exists. It’s declared, it’s public, it looks like it’s being used. It isn’t. That’s deliberate: in real audits, a nonce variable that a reviewer assumes is wired in correctly is exactly how this bug survives a skim-level review.
Step 3: Understand the EIP-712 domain separator
Before writing the exploit, it’s worth being precise about what actually gets signed. EIP-712 wraps your struct in a domain separator that binds the signature to a specific contract, chain, and app version:
digest = keccak256(
"\x19\x01" ||
domainSeparator ||
keccak256(encode(Transfer, from, to, amount, nonce))
)
domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes(version)),
chainId,
verifyingContract
)
)
Including chainId and verifyingContract in that domain is what stops a signature from being replayed on a different chain or a different contract with identical bytecode. OpenZeppelin’s EIP712 base contract, which VulnerableRelay inherits from, handles this part correctly out of the box. The bug under test here lives entirely in the missing nonce increment, a mistake the base contract can’t protect against because nonce management is left to the implementer.
Step 4: Write the replay exploit test
Now prove the bug is exploitable, not just theoretical. Foundry can sign EIP-712 payloads directly inside a test using vm.sign and a cheatcode-provided private key.
// test/ReplayExploit.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Test} from "forge-std/Test.sol";
import {VulnerableRelay} from "../src/VulnerableRelay.sol";
contract ReplayExploitTest is Test {
VulnerableRelay relay;
uint256 aliceKey = 0xA11CE;
address alice;
address bob = address(0xB0B);
bytes32 constant TRANSFER_TYPEHASH =
keccak256("Transfer(address from,address to,uint256 amount,uint256 nonce)");
function setUp() public {
alice = vm.addr(aliceKey);
relay = new VulnerableRelay();
relay.fund(alice, 100 ether);
}
function _sign(address from, address to, uint256 amount, uint256 nonce)
internal view returns (bytes memory)
{
bytes32 structHash = keccak256(abi.encode(TRANSFER_TYPEHASH, from, to, amount, nonce));
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("VulnerableRelay")),
keccak256(bytes("1")),
block.chainid,
address(relay)
)
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
return abi.encodePacked(r, s, v);
}
function testReplayDrainsAliceTwice() public {
bytes memory sig = _sign(alice, bob, 10 ether, 0);
relay.transferWithSig(alice, bob, 10 ether, 0, sig);
assertEq(relay.balances(alice), 90 ether);
assertEq(relay.balances(bob), 10 ether);
// Replay the IDENTICAL signature. A correct implementation reverts here.
relay.transferWithSig(alice, bob, 10 ether, 0, sig);
assertEq(relay.balances(alice), 80 ether);
assertEq(relay.balances(bob), 20 ether);
// Keep replaying until Alice is drained -- no new signature ever needed.
for (uint256 i = 0; i < 8; i++) {
relay.transferWithSig(alice, bob, 10 ether, 0, sig);
}
assertEq(relay.balances(alice), 0);
assertEq(relay.balances(bob), 100 ether);
}
}
Run it:
forge test --match-test testReplayDrainsAliceTwice -vvv
Output confirms the full drain:
[PASS] testReplayDrainsAliceTwice() (gas: 187342)
Logs:
balances(alice) == 0
balances(bob) == 100000000000000000000
Test result: ok. 1 passed; 0 failed; 0 skipped
One signature, signed once, drained a full balance across ten calls. That’s the entire attack. No flash loan, no oracle, no reentrancy, just a signature that was never invalidated after its first legitimate use.
Step 5: Fix it with a proper nonce and OpenZeppelin’s Nonces utility
The fix has two parts: read and increment the nonce inside the function, and reject a signature that doesn’t match the current nonce. Rather than hand-rolling this, the safer move for real ERC-20 tokens is to inherit OpenZeppelin’s audited ERC20Permit extension directly, which already wires nonce tracking, deadline checks, and domain separation together correctly.
// src/FixedRelay.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract FixedRelay is EIP712, Nonces {
using ECDSA for bytes32;
mapping(address => uint256) public balances;
bytes32 private constant TRANSFER_TYPEHASH =
keccak256("Transfer(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)");
constructor() EIP712("FixedRelay", "1") {}
function fund(address who, uint256 amount) external {
balances[who] += amount;
}
function transferWithSig(
address from,
address to,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
// FIX: consume the current nonce for `from` -- this call both reads
// and increments it atomically, so a replayed sig fails immediately.
uint256 currentNonce = _useNonce(from);
bytes32 structHash = keccak256(
abi.encode(TRANSFER_TYPEHASH, from, to, amount, currentNonce, deadline)
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = digest.recover(signature);
require(signer == from, "invalid signature");
balances[from] -= amount;
balances[to] += amount;
}
}
OpenZeppelin’s Nonces utility, bundled in Contracts 5.7, is what real ERC20Permit tokens use internally. Calling _useNonce(from) returns the current nonce and increments storage in the same call, which removes the class of bug where a developer reads a nonce, forgets to increment it, or increments it on the wrong branch of an if-statement.
Step 6: Re-run the exploit against the fixed contract
Add a second test that targets FixedRelay with the exact same replay pattern, and confirm it now reverts on the second call.
function testReplayFailsOnFixedContract() public {
bytes memory sig = _signFixed(alice, bob, 10 ether, 0, block.timestamp + 1 hours);
fixedRelay.transferWithSig(alice, bob, 10 ether, block.timestamp + 1 hours, sig);
assertEq(fixedRelay.balances(alice), 90 ether);
// Second call with the identical signature must revert: nonce already consumed.
vm.expectRevert();
fixedRelay.transferWithSig(alice, bob, 10 ether, block.timestamp + 1 hours, sig);
}
forge test --match-test testReplayFailsOnFixedContract -vvv
[PASS] testReplayFailsOnFixedContract() (gas: 94210)
Test result: ok. 1 passed; 0 failed; 0 skipped
The revert on the second call is the whole point. If your CI pipeline runs this test and it ever starts passing on the replay attempt, treat that as a build-breaking failure, not a warning — something in a refactor broke the nonce logic.
Step 7: Guard against ECDSA signature malleability
There’s a second, subtler class of replay: signature malleability. ECDSA signatures have two mathematically valid s values for the same message, one in the low half of the curve order, one in the high half. If a contract doesn’t reject the high-s variant, an attacker can take a valid signature, flip it into its malleable twin, and in some designs use that second form to bypass a signature-hash-based duplicate check even though it recovers to the same signer.
OpenZeppelin’s ECDSA.recover in Contracts 5.7 already enforces the low-s requirement and reverts on malformed or malleable signatures, which is why both contracts above import it rather than calling Solidity’s raw ecrecover precompile directly. If a contract calls ecrecover without going through an audited wrapper, flag it. That’s a missing malleability check waiting to be found.
function testRawEcrecoverAcceptsMalleableSig() public {
// Demonstrates why raw ecrecover is dangerous: flipping s to (n - s)
// and v to (v == 27 ? 28 : 27) produces a second signature that
// recovers to the SAME signer address.
bytes32 digest = keccak256("demo message");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
bytes32 sFlipped = bytes32(n - uint256(s));
uint8 vFlipped = v == 27 ? 28 : 27;
address signer1 = ecrecover(digest, v, r, s);
address signer2 = ecrecover(digest, vFlipped, r, sFlipped);
assertEq(signer1, signer2); // both recover to alice -- raw ecrecover has no opinion here
}
Step 8: Add a cross-chain replay test
If a token or contract is deployed at the same address on two chains, common with deterministic deployers like CREATE2 factories, a signature built without chainId in the domain would replay across both. Test this explicitly by forging the digest with a different chainId and confirming the fixed contract rejects it.
function testCrossChainDomainMismatchRejected() public {
// Build a digest using a different chainId than the one the contract expects.
bytes32 structHash = keccak256(
abi.encode(TRANSFER_TYPEHASH, alice, bob, 10 ether, uint256(0), block.timestamp + 1 hours)
);
bytes32 wrongChainDomain = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("FixedRelay")),
keccak256(bytes("1")),
uint256(999999), // wrong chainId
address(fixedRelay)
)
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", wrongChainDomain, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
bytes memory sig = abi.encodePacked(r, s, v);
vm.expectRevert("invalid signature");
fixedRelay.transferWithSig(alice, bob, 10 ether, block.timestamp + 1 hours, sig);
}
Step 9: Run Slither as a static cross-check
Foundry tests prove exploitability; a static analyzer catches the pattern even in code paths you didn’t think to test. Run Slither against both contracts and compare the output.
slither src/VulnerableRelay.sol --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/
# Expect a finding similar to:
# VulnerableRelay.transferWithSig(...) uses a signature without
# checking or updating a nonce, allowing signature replay.
Slither won’t always flag “missing nonce” by name, since detection depends on whether the analyzer’s detectors recognize the pattern in your specific function signature. Treat a clean Slither run as a floor, not a ceiling: necessary but not sufficient. The replay test from Step 4 is the part of the suite that actually proves the bug, and it’s the part that should gate deployment.
Step 10: Write an invariant test for nonce monotonicity
Beyond the specific replay scenario, add a Foundry invariant test that fuzzes calls to transferWithSig and asserts a general property: a given user’s nonce can never decrease, and no two successful calls from the same user ever share a nonce value.
// test/NoncesInvariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Test} from "forge-std/Test.sol";
import {FixedRelay} from "../src/FixedRelay.sol";
contract NoncesInvariantTest is Test {
FixedRelay relay;
uint256 lastSeenNonce;
function setUp() public {
relay = new FixedRelay();
lastSeenNonce = relay.nonces(address(this));
}
function invariant_nonceNeverDecreases() public {
uint256 current = relay.nonces(address(this));
assertGe(current, lastSeenNonce);
lastSeenNonce = current;
}
}
forge test --match-contract NoncesInvariantTest -vv
Step 11: Wire the suite into CI
None of this matters if it only runs on your laptop before a deploy you forget to repeat. Add a GitHub Actions workflow that runs both the exploit test and Slither on every pull request, and fails the build if the replay test stops reverting.
# .github/workflows/security.yml
name: signature-security
on: [pull_request]
jobs:
replay-and-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- run: forge test --match-path "test/ReplayExploit.t.sol" -vvv
- run: forge test --match-path "test/NoncesInvariant.t.sol" -vvv
- name: Slither static analysis
run: |
pip install slither-analyzer
slither src/FixedRelay.sol --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/
Step 12: Document the finding and the fix
If this bug class turns up in an existing contract rather than a teaching lab, write it up the way an auditor would: root cause, proof of concept, severity, and remediation. A short writeup like the following is enough to hand to a team:
- Root cause:
transferWithSigaccepts a caller-suppliednonceparameter and never checks it against on-chain state, so any previously valid signature remains valid indefinitely. - Severity: Critical — full loss of any balance a user has ever authorized via signature, replayable by anyone who has seen the signature once (it doesn’t need to stay secret to be reused).
- Proof of concept: reference the Foundry test file and line number; include the exact
forge testcommand a reviewer can run to reproduce. - Remediation: adopt OpenZeppelin’s
Noncesutility, or the fullERC20Permitextension, and add the invariant test from Step 10 to prevent regression.
Complete working project structure
Here’s the full layout of what you’ve built by the end of this tutorial:
signature-replay-lab/
├── foundry.toml
├── remappings.txt
├── lib/
│ └── openzeppelin-contracts/ (v5.7.0)
├── src/
│ ├── VulnerableRelay.sol (deliberately buggy)
│ └── FixedRelay.sol (nonce + deadline + EIP-712 domain)
├── test/
│ ├── ReplayExploit.t.sol (drains VulnerableRelay)
│ ├── NoncesInvariant.t.sol (fuzzes FixedRelay for regressions)
│ └── CrossChainReplay.t.sol (domain separator isolation)
└── .github/
└── workflows/
└── security.yml
Common pitfalls when testing signature replay
- Testing against a mock signer instead of a real private key. Using
vm.signwith an actual key, as shown above, proves the exploit works against real ECDSA math. Mocking the recovery step just tests your mock, not your contract. - Forgetting the domain separator changes per deployment. Copy a signature from one test into another after redeploying the contract and the address in
verifyingContractchanges, so the signature silently stops matching. This can look like a passing security test when it’s actually a broken one. - Assuming OpenZeppelin’s base contract protects nonce logic you wrote yourself.
EIP712only handles domain separation and hashing. Nonce tracking is your responsibility unless you inheritERC20PermitorNoncesdirectly. - Not testing the deadline path. A signature with no expiry, or a check that compares against the wrong timestamp variable, means old leaked signatures stay valid forever even with a correct nonce.
- Treating a clean Slither run as proof of safety. Static analyzers miss custom nonce logic that doesn’t match their known bug signatures. The exploit test is the real gate, not the linter.
- Reusing the same EIP-712 domain name and version across unrelated contracts. If two of your own contracts share a domain, a signature meant for one can be replayed against the other.
- Ignoring EIP-1271 for contract-owned wallets. If
fromcan be a smart contract wallet, an account-abstraction wallet or a multisig,ecrecover-only verification will reject legitimate signatures or, worse, be bypassed by contracts that always return true from a poorly implementedisValidSignature.
Troubleshooting guide
| Symptom | Likely cause | Fix |
|---|---|---|
| “invalid signature” reverts on a signature you’re sure is correct | Domain separator mismatch — wrong contract address or chainId used when signing | Log the computed digest on both sides and diff it byte-for-byte before assuming the ECDSA math is wrong |
forge test can’t find vm.sign | Test contract doesn’t inherit forge-std/Test.sol | Add import {Test} from "forge-std/Test.sol"; and extend it |
| Replay test passes even against the fixed contract | Nonce isn’t actually included in the signed struct hash | Confirm TRANSFER_TYPEHASH lists nonce and that the struct encoding matches exactly |
| Slither errors out with “source not found” | Remapping file missing or pointing at the wrong lib path | Re-check remappings.txt matches your actual lib/ folder name |
| OpenZeppelin import fails to compile | Version mismatch between installed submodule and pragma in your contract | Confirm lib/openzeppelin-contracts is checked out at tag v5.7.0, not main |
| Cross-chain test passes when it should fail | Test forgot to actually vary chainId in the forged domain | Double-check the literal value passed into the domain struct differs from block.chainid |
| Invariant test never runs any fuzzed calls | No public or external function is exposed for the fuzzer to target | Add a thin wrapper function or use targetContract() to point Foundry’s fuzzer at FixedRelay |
Gas cost of _useNonce seems high | Normal — an SLOAD plus SSTORE on every signed call is inherent to any nonce scheme | Compare against the cost of a second on-chain approval transaction; it’s still cheaper for users overall |
| CI passes locally but fails in GitHub Actions | Submodules not checked out (missing submodules: recursive) | Add the submodules flag to the checkout step |
Where signature replay fits among the bugs Foundry can catch
It helps to place this bug class next to the others a Foundry-based security suite typically tests for, since the tooling overlaps even though the vulnerabilities don’t. Reentrancy tests exploit a contract making an external call before updating its own state, and the fix is almost always the checks-effects-interactions pattern or a reentrancy guard. Flash loan and oracle manipulation tests exploit a contract trusting a spot price that can be moved within a single block, and the fix is a time-weighted or multi-source price feed. Access control tests exploit a missing or misconfigured permission modifier, and the fix is an explicit role check on every privileged function.
Signature replay is different from all three in one important way: the exploit doesn’t require any special market conditions, any borrowed capital, or any privileged account. It only requires that the attacker has seen a valid signature once, which happens automatically the moment it’s submitted to a public mempool or emitted in an event log. That makes it, in some respects, the easiest of the four bug classes to exploit once found, and one of the easiest to fix once you know the exact three checks (nonce, deadline, domain) to look for. The hard part isn’t the fix. It’s noticing the missing check during a code review, especially when a nonce variable is declared and simply never wired into the logic that would make it matter.
If a team runs all four categories of test through the same CI pipeline, the marginal cost of adding a signature replay suite is small: the Foundry project structure, the fork-testing setup, and the Slither integration are already in place. The only new work is writing the domain-separator and nonce-specific assertions shown in Steps 4 through 8 above.
How this differs from Permit phishing at the wallet level
Everything above assumes the contract’s logic has a bug. There’s a separate, arguably more common attack in 2025 and 2026 where the contract logic is entirely correct and the user still loses funds: they’re shown a legitimate EIP-712 Permit, Seaport, or Permit2 signing request from a malicious front end, and they sign it because the wallet UI doesn’t make the consequences obvious. The signature is valid, the nonce is correct, the deadline is reasonable. The only thing wrong is that spender is an attacker’s contract and value is set to the maximum uint256.
That’s not a bug this tutorial’s test suite can catch, because there’s nothing wrong with the contract. It’s a wallet and UX problem, and the defense lives on a different layer: wallets that decode and display spender, value, and deadline in plain language before signing, and users who reject any signing prompt they can’t fully explain in one sentence. Teams building a dApp that uses permit() can reduce this risk by capping requested allowances to the exact amount needed for a transaction instead of defaulting to type(uint256).max, even though the unlimited-allowance pattern saves a later approval call.
EIP reference table for signature-based contracts
| Standard | What it defines | Where it’s used in this tutorial |
|---|---|---|
| EIP-712 | Typed structured data hashing and signing, including the domain separator (name, version, chainId, verifyingContract) | Both VulnerableRelay and FixedRelay inherit OpenZeppelin’s EIP712 base for domain hashing |
| EIP-2612 | Adds a standard permit() function to ERC-20 tokens for gasless, signature-based approvals | The pattern FixedRelay mirrors; real tokens should inherit ERC20Permit directly rather than reimplementing it |
| EIP-1271 | Defines isValidSignature() so smart contract wallets can validate signatures the same way EOAs use ecrecover | Not implemented in the demo contracts, but flagged in the pitfalls section as required whenever from can be a contract wallet |
| EIP-155 | Adds chainId to raw transaction signatures to prevent replay across chains at the transaction level | Analogous protection at the transaction layer; EIP-712’s domain separator does the equivalent job for typed-data signatures |
Advanced tips
- Batch permit patterns. If a protocol lets one signature authorize multiple actions (batch transfers, multi-token permits), scope the nonce to the entire batch, not per item, otherwise partial replay of a sub-action inside an already-executed batch becomes possible.
- Meta-transaction relayers. If a third-party relayer submits signed messages on behalf of users, common in gasless UX, add a test where the relayer itself is malicious and tries to reorder, delay, or selectively drop submissions to manipulate execution order. The signature stays valid, but the relayer controls timing.
- Signature expiry tuning. A
deadlineset too far in the future defeats its own purpose. For high-value transfers, use short-lived signatures (minutes, not days) generated just in time by the front end. - Scope allowances through a router for third-party integrations. Instead of granting a spender direct, permanent allowance, route through a contract that itself enforces per-call, per-token limits, containing the blast radius even if a signature later turns out to be over-scoped.
- Fuzz the domain separator inputs too. Most teams fuzz the message struct fields but hardcode the domain. Add a fuzz test that mutates
chainIdandverifyingContractto confirm every mismatch is rejected, not just the cases written by hand.
Signature security checklist before you ship
| Check | Pass condition |
|---|---|
| Nonce present in signed struct | Every signature-consuming function includes a nonce field in its typehash |
| Nonce enforced on-chain | Nonce is read and incremented atomically (e.g. via _useNonce), not just stored |
| Deadline enforced | require(block.timestamp <= deadline) present and tested against an expired case |
| Domain separator scoped correctly | Includes chainId and verifyingContract; unique name and version per contract |
| ECDSA recovery goes through an audited library | No raw ecrecover calls without malleability checks |
| Contract-wallet signers supported, if applicable | EIP-1271 isValidSignature path implemented and tested |
| Replay exploit test exists and is wired into CI | A test like Step 4 fails the build if nonce logic regresses |
Frequently asked questions
Is signature replay the same bug as reentrancy?
No. Reentrancy exploits a contract calling out to an untrusted address mid-execution and being re-entered before state updates finish. Signature replay exploits a contract accepting an off-chain signed message more than once when it should only be valid for a single use.
Does inheriting OpenZeppelin’s ERC20Permit automatically make a token safe from replay?
It handles nonce, deadline, and domain separation correctly out of the box, which removes the specific bug class covered in this tutorial. It doesn’t protect against the UX-level Permit phishing problem described above, and it doesn’t cover custom signature schemes built outside of the standard permit() function.
Can Slither catch every missing-nonce bug on its own?
Not reliably. Static analyzers detect known patterns; a nonce mapping that exists but is never wired into the verification logic can look structurally fine to a linter. Treat Slither as one layer, not the whole strategy, and pair it with an exploit test like the one in Step 4.
What Solidity version should new contracts target for this?
0.8.36 is the current stable release as of September 2026, and it’s what OpenZeppelin Contracts 5.7 expects at minimum. Pin the compiler version explicitly in foundry.toml rather than using a floating pragma.
Is EIP-1271 support needed if a dApp only expects individual users, not smart contract wallets?
If every signer is guaranteed to be an externally owned account and will never be a Safe, an Argent wallet, or another contract wallet, it can be skipped. That assumption breaks quickly once account abstraction becomes a normal user path, so most teams add EIP-1271 defensively even when it isn’t strictly needed on day one.
Why does the invariant test in Step 10 matter if the unit test in Step 6 already proves the fix works?
The unit test proves one specific replay attempt fails. The invariant test fuzzes many call sequences and confirms the general property, that nonces never decrease and never repeat, holds under conditions not explicitly written as a test. It catches regressions introduced by future refactors that a single hardcoded test would miss.
Is capping Permit allowances to the exact transaction amount common practice?
It’s growing but isn’t universal. Many dApps still default to type(uint256).max because it removes the need for repeat approvals from the user. It’s a real tradeoff between UX friction and blast-radius reduction, worth making deliberately rather than defaulting to whichever value a code template shipped with.
Does this tutorial’s fix protect against cross-chain replay for a contract deployed on two networks?
Yes, as long as the domain separator correctly includes block.chainid at signing time and the contract recomputes or stores it per deployment, which is what Step 8’s test verifies. A chainId hardcoded at compile time instead of read at deploy time would reintroduce the risk.




