Appearance
L2-9: Sign + ecrecover (Signature Verification)
1. Problem
On Ethereum, off-chain signing + on-chain verification is the core paradigm enabling "gasless transactions" and "meta-transactions." A user signs a message off-chain with their private key (zero gas cost), then submits the signature on-chain -- anyone can relay the transaction and pay the gas. But several critical issues lurk beneath: the different prefix standards for signature formats (eth_sign's "\x19Ethereum Signed Message:\n32" vs EIP-712's "\x19\x01") determine the security domain of the signature; the ecrecover precompile's handling of malformed signatures can result in a signer address of address(0); and the different encoding of the v value (27/28 vs 0/1) causes compatibility issues across libraries.
This challenge (refer to src/level2/SignatureVerifier.sol) requires implementing two signature verification approaches: standard eth_sign verification and EIP-712 Permit verification -- the cryptographic foundation of EIP-2612 Permit authorizations, gasless token transfers, and all meta-transaction patterns.
2. Why
Signature verification represents three leaps in Ethereum cryptography: from "I can call ethers.signMessage()" to "I understand the prefix magic of eth_sign," and finally to "I can implement EIP-712 typed signatures." Each stage corresponds to a different security level.
eth_sign's prefix "\x19Ethereum Signed Message:\n32" is not a random string -- it is specifically designed to prevent cross-protocol signature replay attacks. In Bitcoin's early days, it was discovered that a signature from one transaction could be copied into an entirely different transaction and still be valid. Ethereum solves this by prepending a prefix to all signed data -- without this prefix, a message you signed for one DApp could be replayed into another DApp's signature verification logic.
EIP-712 goes further: it not only adds a prefix but also appends complete domain information about "which contract (verifyingContract) on which chain (chainId) is this valid for." This means even if two DApps use the exact same Permit data structure, signatures cannot be replayed across them. The 0x01 in "\x19\x01" is EIP-712's version identifier -- if EIP-712 is upgraded in the future, this byte can change, ensuring backward compatibility.
Understanding how ecrecover works is key to grasping the underlying cryptography. ecrecover(messageHash, v, r, s) recovers the signer's public key (and hence address) from an ECDSA signature -- it does not "verify" the signature; it "recovers" it. Any valid (v, r, s) triplet can recover an address, but to confirm that address is the expected signer, you must compare after recovery.
3. Solution
The SignatureVerifier contract (refer to src/level2/SignatureVerifier.sol) provides two verification methods and complete EIP-712 infrastructure:
Method 1: eth_sign Standard Signature Verification
solidity
function verifyEthSign(
bytes32 message,
uint8 v,
bytes32 r,
bytes32 s
) public pure returns (address signer) {
// Construct the Ethereum signed message prefix
bytes32 ethSignedMessageHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
);
// Recover address from signature
signer = ecrecover(ethSignedMessageHash, v, r, s);
if (signer == address(0)) revert InvalidSignature();
return signer;
}
Key details:
- The prefix
"\x19Ethereum Signed Message:\n32"is compatible with thepersonal_signandeth_signstandards - The
messageparameter is expected to be a 32-byte hash ecrecoverreturns address(0) to indicate an invalid signature (e.g., v value not in the [27, 28] range or s value too high)
Method 2: EIP-712 Permit Typed Signature Verification
solidity
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public immutable PERMIT_TYPEHASH;
bytes32 private constant _PERMIT_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("SignatureVerifier")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
PERMIT_TYPEHASH = _PERMIT_TYPEHASH;
}
function verifyEIP712Permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) public returns (bool) {
if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);
// Construct the structured data hash
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH, owner, spender, value,
nonces[owner]++, // increment nonce to prevent replay
deadline
));
// Construct the EIP-712 domain-separated final digest
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address signer = ecrecover(digest, v, r, s);
if (signer == address(0) || signer != owner) {
revert WrongSigner(owner, signer);
}
emit PermitVerified(owner, spender, value);
return true;
}
EIP-712 Signature Construction Hierarchy
1. TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
2. structHash = keccak256(TYPEHASH || owner || spender || value || nonce || deadline)
3. DOMAIN_SEPARATOR = keccak256(EIP712Domain || name || version || chainId || verifyingContract)
4. digest = keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash)
5. ecrecover(digest, v, r, s) → signer
Off-Chain Signature Construction (JavaScript / ethers.js)
javascript
const domain = {
name: 'SignatureVerifier',
version: '1',
chainId: (await provider.getNetwork()).chainId,
verifyingContract: contractAddress,
};
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const nonce = await contract.nonces(wallet.address);
const message = { owner: wallet.address, spender, value, nonce, deadline };
const signature = await wallet.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);
4. Pitfalls Encountered
- ecrecover returns address(0): when the v value is not 27 or 28 (or the corresponding 0, 1), or the s value exceeds
secp256k1n / 2, ecrecover returns address(0) instead of reverting -- this can pass signature verification if not explicitly checked - Prefix confusion: confusing the
"\x19Ethereum Signed Message:\n32"(eth_sign) and"\x19\x01"(EIP-712) prefixes, causing signatures to never pass verification - Message hash length is not 32 bytes: the eth_sign prefix
\n32explicitly expects the message length to be exactly 32 bytes; passing an arbitrary-length raw message causes a signature mismatch - Improper nonce management: forgetting to increment the nonce in EIP-712 verification, or using the wrong nonce order -- leading to signature replay attacks
- v value encoding differences: ethers.js
Signature.from()returns v as 27/28, while some libraries return 0/1 -- hardcoding a check for 27/28 on-chain may reject valid signatures - Lax deadline checking: using
>instead of>=causes legitimate signatures submitted exactly at the deadline moment to be rejected
5. Why the Pitfalls Happen
ecrecover's silent failure is a design choice of its precompile implementation. The EVM precompile contract 0x01 (ecrecover) internally calls secp256k1 elliptic curve operations. When input parameters fall outside the valid range of the curve group (e.g., v is not 27/28, s exceeds n/2), it returns an empty result (64 zero bytes), which Solidity interprets as address(0). This behavior is extremely dangerous: if you only check signer == expectedOwner, address(0) != expectedOwner will correctly fail. But if you mistakenly check only signer != address(0) (thinking that is sufficient), a carefully crafted invalid signature could bypass it -- though in practice this requires additional programming error combinations. The more common issue is checking only signer != address(0) without comparing signer to expectedOwner.
The prefix system exists for signature domain separation. Without the "\x19" prefix and specific message format, a Permit signed for contract A could be directly copied and replayed in contract B. EIP-712's "\x19\x01" appends a DOMAIN_SEPARATOR (containing chainId + verifyingContract) to ensure a signature is valid only for one contract on one chain, even if two contracts have identical Permit structures.
The nonce is the key mechanism for defending against signature replay. Without a nonce (or a non-incrementing nonce), once a user signs a Permit, anyone can submit the same (v, r, s) triplet on-chain indefinitely. The incrementing nonce ensures each signature can only be used once.
6. How to Resolve the Pitfalls
Always check both conditions of the ecrecover return value: not address(0) AND equals the expected signer address:
solidity
address signer = ecrecover(digest, v, r, s);
if (signer == address(0) || signer != owner) {
revert WrongSigner(owner, signer);
}
Construct the DOMAIN_SEPARATOR correctly in the constructor -- use keccak256 to hash the string name and version, rather than passing the string directly as bytes. This ensures compatibility with the EIP-712 standard (which requires hashed values):
solidity
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("SignatureVerifier")), // hashed name
keccak256(bytes("1")), // hashed version
block.chainid,
address(this)
));
For nonce management, use nonces[owner]++ to increment in-place during verification -- this ensures each verification uses a different nonce, and any replay attempt will fail due to nonce mismatch. Provide a getNonce() query function so the frontend can retrieve the current nonce value.
For deadline checking, use strict greater-than > rather than >=, allowing signatures submitted exactly at the deadline block to pass. Also provide meaningful error messages that inform the user of the gap between the current time and the deadline.
solidity
if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);
7. Technical Highlights
| Key Point | Description |
|---|---|
| eth_sign prefix | "\x19Ethereum Signed Message:\n32" + keccak256(message) |
| EIP-712 prefix | "\x19\x01" + DOMAIN_SEPARATOR + structHash |
| DOMAIN_SEPARATOR | keccak256(name, version, chainId, verifyingContract) |
| ecrecover failure return | address(0) (does not revert; must be checked manually) |
| v value range | 27/28 (EIP-155 style) or 0/1; ecrecover is compatible with both |
| nonce replay prevention | increment nonces[owner]++ on each verification, ensuring signatures cannot be reused |
| Type hash | keccak256("Permit(address owner,...)") -- standardized representation of the struct definition |
| structHash | `keccak256(TYPEHASH |