Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Signatures in Ethereum - ECDSA, ecrecover, personal_sign and EIP-712

How a signature (r, s, v) is built, how a contract verifies it via ecrecover, how personal_sign and EIP-712 differ, and what permit and EIP-1271 are.

4 min readUpdated:
In this article

In the article public-key cryptography we learned that a signature proves authorship. Now let us see how it is built in Ethereum, how a contract verifies it and what traps wait for developers and users.

The signature format

An ECDSA signature on the secp256k1 curve consists of three values:

  • r - 32 bytes;
  • s - 32 bytes;
  • v - 1 byte (27 or 28; sometimes 0 or 1) - a "hint" about which of two possible public keys is meant.

65 bytes in all. What is always signed is the hash of the message (32 bytes), not the message itself.

ecrecover: recovering the signer

The EVM has a built-in function (a precompile at address 0x01) ecrecover(hash, v, r, s) that returns, from the hash and signature, the signer's address:

solidity
address signer = ecrecover(digest, v, r, s);
require(signer != address(0) && signer == expectedSigner, "bad signature");

Important: for an invalid signature ecrecover returns the zero address, not an error. If you do not check != address(0) and the expected signer is zero by mistake, the signature will "pass". That is why real contracts use proven libraries (for example ECDSA from OpenZeppelin).

A trap: signature malleability

For every signature (r, s) there is a "mirror" (r, n - s) which is also valid. This makes a signature malleable: an attacker can get another valid signature without knowing the key. If a contract uses the signature's hash as an identifier ("this signature was already used"), the trick bypasses the protection. The fix: accept only the "lower" half of s (the EIP-2 requirement) and guard against replay by nonce, not by signature hash.

personal_sign: a safe wrapper for messages

If arbitrary 32 bytes were signed, they could be passed off as a transaction hash and the user would unknowingly sign a transaction. So for text messages wallets add a prefix:

text
"\x19Ethereum Signed Message:\n" + length + message

The hash of this string is signed. The prefix guarantees the signature cannot be used as a transaction signature. This is how wallet login ("Sign-In with Ethereum") works.

The downside: the user sees a string of characters or plain text and cannot see the data structure.

EIP-712: structured data

EIP-712 solves the "blind signing" problem. The structure being signed has a type and fields, and the wallet shows them readably: recipient, amount, deadline.

The hash to be signed is built like this:

text
digest = keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))

The components:

  • hashStruct - the hash of the data taking the struct type into account.
  • domainSeparator - the hash of the "domain": name, version, chainId, verifyingContract.

The domain is needed so the signature is bound to a specific application and network. A signature made for contract A on network 1 will not pass for contract B or for the same copy on network 8453. This protects against cross-chain and cross-contract replay (why chainId matters).

Replay protection inside a contract

Even with a domain the same signature could be submitted twice. So the signed data usually includes:

  • nonce - a sequence number the contract increases after use;
  • deadline - the signature's expiry.

The contract checks them, and replay is impossible.

Example: permit (EIP-2612)

Normally transferring tokens to a contract takes two transactions: approve, then the call (see approve). Tokens with the permit standard allow doing it in one: the owner signs an EIP-712 message Permit(owner, spender, value, nonce, deadline), the contract passes the signature to the token, and the permission is granted without a separate transaction from the owner.

solidity
token.permit(owner, spender, value, deadline, v, r, s);
token.transferFrom(owner, address(this), value);

Convenient but dangerous: a scammer may talk you into signing a Permit, and then tokens can be pulled without a single transaction from you (attacks). Look in the wallet window at who is allowed how much.

EIP-1271: contract wallets' signatures

A smart wallet (for example a multisig) has no private key, so ecrecover does not work for it. The EIP-1271 standard introduces a function isValidSignature(hash, signature) that is called on the contract address: if it returns the magic value 0x1626ba7e, the signature is considered valid. Contracts that accept signatures should support both ways: ecrecover for ordinary addresses and EIP-1271 for contracts.

Meta-transactions

A signature lets one person (a relayer) send to the network a transaction on behalf of another and pay the gas. The user only signs an intent. This is the basis of "gasless" operations and account abstraction.

Key takeaways

  1. A signature always refers to a hash; what exactly is hashed matters.
  2. Use EIP-712: readable structure + binding to a domain.
  3. Always include nonce and deadline, and check chainId.
  4. Do not trust a bare ecrecover: check the zero address and the shape of s.
  5. The user must read what they sign.

We will see how all this mathematics is applied in a real scheme of controlled rights: access-control patterns. Or turn to the most frequent contract vulnerabilities: the catalogue of vulnerabilities.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.