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 chainId or verifyingContract
  • 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:

  1. Build a typed or prefixed message hash.
  2. Recover the signer from the signature.
  3. Compare the recovered address to the expected signer.
  4. 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 caseWhat it provesCommon bug caught
Valid signature succeedsThe hashing and recovery pipeline is correctIncorrect struct encoding
Wrong signer failsAuthorization is enforcedMissing signer check
Wrong nonce failsReplay protection worksNonce not included in hash
Wrong chain/domain failsEIP-712 separation is correctReused signatures across deployments
Tampered payload failsSignature binds to exact dataParameter mismatch
Malformed signature failsRecovery rejects invalid bytesUnsafe 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 verify on contract A and expect true
  • call verify on contract B with the same signature and expect false

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:

  1. the first use of a signature is accepted
  2. the nonce changes after consumption
  3. 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 v fails
  • signature with altered r or s fails
  • 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.encodePacked where 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.

ApproachStrengthsWeaknesses
Prefixed message (toEthSignedMessageHash)Simple, widely supportedLess structured, easier to misuse
EIP-712 typed dataStrong domain separation, explicit fieldsMore 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:

  1. Implement the contract with a single verification path.
  2. Write one happy-path test with a known signer.
  3. Add one test per field mutation.
  4. Add a nonce replay test.
  5. Add a domain mismatch test using a second deployment.
  6. Add malformed signature tests for invalid length and invalid recovery data.
  7. 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.

Learn more with useful resources