
Testing Solidity Signature Verification with ECDSA and EIP-712 Assertions
Why signature verification tests matter
Signature-based authorization is attractive because it reduces on-chain storage and enables flexible workflows. But it also introduces several failure points:
- incorrect message hashing
- wrong domain separator configuration
- signature malleability issues
- mismatched
chainIdorverifyingContract - accidental acceptance of signatures from the wrong signer
These bugs are hard to spot by inspection alone. A contract may appear correct while still accepting a signature that was produced for another contract, another chain, or another payload. Tests should therefore verify both the happy path and the exact failure conditions.
The core pattern: hash, sign, recover, assert
Most Solidity signature verification follows this flow:
- Build a typed or prefixed message hash.
- Recover the signer from the signature.
- Compare the recovered address to the expected signer.
- Revert if the signer is not authorized.
For ECDSA, the key test objective is not just “does this signature pass?” but “does the contract reject every signature that should not pass?”
A minimal verifier might look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract VoucherVerifier is EIP712 {
using ECDSA for bytes32;
bytes32 private constant VOUCHER_TYPEHASH =
keccak256("Voucher(address account,uint256 amount,uint256 nonce)");
mapping(address => uint256) public nonces;
constructor() EIP712("VoucherVerifier", "1") {}
function verify(
address account,
uint256 amount,
bytes calldata signature
) external view returns (bool) {
bytes32 structHash = keccak256(
abi.encode(VOUCHER_TYPEHASH, account, amount, nonces[account])
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = digest.recover(signature);
return signer == account;
}
}This example uses the account itself as the signer, which is common in claim or authorization flows. In real systems, the signer may be a backend key, a role admin, or a delegated authority.
What to test
A good test suite should cover the following categories.
| Test case | What it proves | Common bug caught |
|---|---|---|
| Valid signature succeeds | The hashing and recovery pipeline is correct | Incorrect struct encoding |
| Wrong signer fails | Authorization is enforced | Missing signer check |
| Wrong nonce fails | Replay protection works | Nonce not included in hash |
| Wrong chain/domain fails | EIP-712 separation is correct | Reused signatures across deployments |
| Tampered payload fails | Signature binds to exact data | Parameter mismatch |
| Malformed signature fails | Recovery rejects invalid bytes | Unsafe signature parsing |
The most important principle is that each assertion should isolate one variable. If a test fails, you want to know whether the problem is the signer, the nonce, the domain, or the payload.
Testing EIP-712 domain separation
EIP-712 signatures are only valid for a specific domain. That domain usually includes:
- contract name
- version
- chain ID
- verifying contract address
If any of these values change, the digest changes. This is exactly what you want in production, and exactly what you should test.
Example test strategy
Write one test that signs a message using the contract’s current domain and confirms success. Then deploy a second contract instance and confirm that the same signature does not verify there.
This catches a common mistake: using a hardcoded domain separator or omitting the contract address from the hash.
Practical assertion pattern
- generate a signature for contract A
- call
verifyon contract A and expecttrue - call
verifyon contract B with the same signature and expectfalse
If your implementation uses a custom EIP-712 domain, also test that changing the version invalidates the signature. Version bumps are often used during upgrades or protocol migrations.
Testing nonce-based replay protection
Replay protection is one of the most important reasons to include a nonce in the signed payload. Without it, a signature can be reused indefinitely.
A nonce test should verify three behaviors:
- the first use of a signature is accepted
- the nonce changes after consumption
- the same signature is rejected after the nonce changes
A simple pattern is to include nonces[account] in the struct hash and increment it after successful execution. Your test should assert both the state transition and the signature invalidation.
Example test checklist
- sign with nonce
0 - submit transaction and expect success
- confirm nonce becomes
1 - submit the same signature again and expect revert or
false
If your contract exposes a verify function, test both the pure verification path and the state-changing execution path. A signature may verify correctly but still fail during execution if the nonce update is missing or ordered incorrectly.
Handling signature formats safely
Solidity contracts commonly receive signatures in one of three forms:
- standard 65-byte signatures
(r, s, v) - compact 64-byte signatures per EIP-2098
- raw bytes from external libraries or wallets
Your tests should confirm that the contract accepts only the formats it explicitly supports. If the contract uses OpenZeppelin’s ECDSA.recover, malformed signatures should revert rather than produce a misleading address.
Recommended assertions
- valid 65-byte signature succeeds
- invalid length reverts
- signature with altered
vfails - signature with altered
rorsfails - compact signatures are accepted only if implemented intentionally
This matters because signature parsing bugs can create false positives. A test suite should not merely check that “some signature” works; it should check that the contract rejects malformed inputs deterministically.
Example test with Foundry
The following example demonstrates a practical Foundry test for an EIP-712 voucher verifier. It signs the digest off-chain in the test, then checks both success and failure cases.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {VoucherVerifier} from "../src/VoucherVerifier.sol";
contract VoucherVerifierTest is Test {
VoucherVerifier verifier;
uint256 signerPk = 0xA11CE;
address signer;
function setUp() public {
signer = vm.addr(signerPk);
verifier = new VoucherVerifier();
}
function _signVoucher(
address account,
uint256 amount,
uint256 nonce
) internal view returns (bytes memory) {
bytes32 typeHash = keccak256("Voucher(address account,uint256 amount,uint256 nonce)");
bytes32 structHash = keccak256(abi.encode(typeHash, account, amount, nonce));
bytes32 digest = verifier.hashTypedDataV4(structHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest);
return abi.encodePacked(r, s, v);
}
function testValidSignatureVerifies() public {
bytes memory sig = _signVoucher(signer, 100, 0);
bool ok = verifier.verify(signer, 100, sig);
assertTrue(ok);
}
function testTamperedAmountFails() public {
bytes memory sig = _signVoucher(signer, 100, 0);
bool ok = verifier.verify(signer, 101, sig);
assertFalse(ok);
}
function testWrongNonceFails() public {
bytes memory sig = _signVoucher(signer, 100, 0);
// simulate nonce change in a stateful version of the contract
bool ok = verifier.verify(signer, 100, sig);
assertTrue(ok);
// same signature should not be reusable once nonce changes in execution flow
// in a stateful contract, this would be a revert after nonce increment
}
}Two notes are important here:
- The test uses the same digest construction as the contract. If the test hashes differently, it can produce false confidence.
- In a real execution function, you should test nonce consumption by calling the state-changing method, not just a view verifier.
Best practices for robust signature tests
1. Test the exact digest, not only the result
If possible, assert the digest used in the test matches the expected EIP-712 hash. This helps catch encoding mistakes such as:
- wrong field order
- missing
abi.encode - using
abi.encodePackedwhere structured encoding is required - incorrect type string
2. Separate signer identity from payload identity
A signature should bind to the payload, not to assumptions in the test. Use one account to sign and another to submit when appropriate, especially for delegated flows. This helps verify that the contract checks the recovered signer rather than the transaction sender.
3. Include negative tests for every field
If the signed struct has four fields, change each one in a dedicated test. This is the fastest way to prove that every field is actually part of the hash.
4. Test domain changes explicitly
Deploy a second instance, change the version, or alter the chain context in your test harness. If the same signature still verifies, your domain separation is probably incomplete.
5. Treat malleability as a security concern
Modern ECDSA libraries reject malleable signatures by enforcing canonical s values. Your tests should confirm that invalid signatures do not silently pass through custom recovery code.
When to prefer EIP-712 over prefixed messages
Many projects start with eth_sign-style personal message signatures and later migrate to EIP-712. The difference matters in testing.
| Approach | Strengths | Weaknesses |
|---|---|---|
Prefixed message (toEthSignedMessageHash) | Simple, widely supported | Less structured, easier to misuse |
| EIP-712 typed data | Strong domain separation, explicit fields | More setup, more moving parts |
For production authorization flows, EIP-712 is usually the better choice because it makes the signed intent explicit. Tests are also easier to reason about because each field is encoded in a predictable way.
Common failure modes to watch for
Incorrect type string
The type hash must match the exact struct definition. Even a small mismatch in field names or order changes the digest.
Using the wrong contract address
If the verifying contract is omitted from the domain, signatures may be replayed across deployments.
Forgetting to increment the nonce
A signature that verifies once but can be reused later is a replay vulnerability.
Comparing against msg.sender instead of recovered signer
In delegated flows, the caller and the signer are often different. Tests should ensure the contract checks the recovered address, not the transaction origin.
Accepting signatures for stale data
If a signature authorizes a price, amount, or recipient, any change to those values must invalidate the signature. Dedicated tamper tests are essential.
A practical testing workflow
A reliable workflow for signature verification tests is:
- Implement the contract with a single verification path.
- Write one happy-path test with a known signer.
- Add one test per field mutation.
- Add a nonce replay test.
- Add a domain mismatch test using a second deployment.
- Add malformed signature tests for invalid length and invalid recovery data.
- Refactor only after the suite proves the behavior is stable.
This sequence keeps the test suite focused and prevents accidental overfitting to one specific signature fixture.
Conclusion
Testing Solidity signature verification is about more than checking whether a signature recovers an address. You need to prove that the recovered signer, the payload, the nonce, and the domain are all bound together correctly. When those assertions are in place, your contract becomes much safer against replay, cross-contract reuse, and subtle hashing mistakes.
A strong signature test suite gives you confidence that off-chain authorization behaves exactly as intended on-chain.
