Introduction

The ecrecover function in the smart contract can be used to verify the signature of the wallet. The verifier can confirm the identity of the signer without the signer’s private key. It is often used for off-chain authorization, and then used on-chain by a third party or for simple identity verification.

Function

1
function ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
  • hash: The hash of the signed message.
  • v: v value of signature.
  • r: r value of signature.
  • s: s value of signature.
  • Return: The address of the signer.

Example

For example:

1
2
3
4
5
6
7
8
9
contract EcrecoverTest {
function test() external view returns(address) {
bytes32 hash = 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53;
uint8 v = 0x1b;
bytes32 r = 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3;
bytes32 s = 0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8;
return ecrecover(hash , v, r, s);
}
}

The signer’s wallet address 0xDD980c315dFA75682F04381E98ea38BD2A151540 will be returned, indicating that he signed the message 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53.

In normal use, parameters are passed in instead of hard-coded, and verify the signer. For example:

1
2
3
function verify(address owner, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external {
require(owner == ecrecover(hash , v, r, s), "invalid signature");
}

However, passing the hash directly cannot know what content the user authorized, so it will actually be some raw data and hashed on the chain:

1
2
3
4
5
function permit(address owner, uint value, uint8 v, bytes32 r, bytes32 s) external {
bytes32 hash = keccak256(abi.encodePacked(value));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
// For example, value is the amount of money that the user authorizes to use
}

EIP-191

EIP-191 defines that a prefix must be added when using a signature.

1
"\x19Ethereum Signed Message:\n" + Message bytes length + Message content

uint is 32 bytes, so the above example can be changed to:

1
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", value));

This is because the original signature method may produce a valid transaction signature.

Replay

This means that the same signature is used repeatedly. If the scope and validity status of the signature are not correctly handled, it may be used as a means of attack. It can be seen from the above example that there is unhandled, so the signature can be reused. Usually, we can add a nonce to determine whether the signature has been used:

1
2
3
4
5
6
7
8
9
10
11
contract EcrecoverTest {
mapping(uint => bool) usedNonces;

function permit(address owner, uint value, uint nonce, uint8 v, bytes32 r, bytes32 s) external {
require(!usedNonces[nonce], "used nonce");
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n64", value, nonce));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
usedNonces[nonce] = true;
// ...
}
}

The nonce can also be an incrementing value for each user on the chain. In addition to the nonce, a valid time is usually added. However, even if the nonce and valid time are added, the signature may still be reused. If the same verification method is used in different contracts, the same signature can be reused. At this time, the contract address can be added to limit the scope. After the above restrictions and processing, there is a situation that may replay. There happens to be the same contract address in different chains, although the probability is very low. We can add chain ID and contract address as restrictions to get the following example:

1
2
3
4
5
6
7
8
function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, "signature expired");
require(!usedNonces[nonce], "used nonce");
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n168", block.chainid, address(this), spender, value, nonce, deadline));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
usedNonces[nonce] = true;
// ...
}

Hash signed message

Since the prefix requires the input of the message length, it may be a bit troublesome to calculate the dynamic length of the message. Therefore, there is also a way to hash the signed message first:

1
2
bytes32 messageHash = abi.encodePacked(block.chainid, address(this), spender, value, nonce, deadline);
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));

After this, it can be a fixed value 32.

Split Signature

The signature format sometimes is not splitted.

1
0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a356320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f81b

vrs can be splitted on-chain in the following way:

1
2
3
4
5
6
7
8
9
function splitSignature(bytes memory signature) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
require(signature.length == 65, "invalid signature");
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return (v, r, s);
}

However, it is not recommended to split it on-chain. It will cause an increase in gas, so it should be splitted off-chain first.

Cancel Signature

Since once a signature is signed and sent, the holder has the authority to perform relevant operations before it expires. It may cause losses to the signer. For example, it is common to use signatures to place orders and quotes. Suppose an NFT holder signs to sell for 1 ETH, which is valid for one month. Later, the holder cancels the pending order on the web page. If there is no cancellation mechanism on-chain, people who know the signature can still buy it for 1 ETH within a month, even if the market price rises to 10 ETH. Related incidents have occurred in OpenSea.

In order to save costs and improve efficiency when placing orders, using an off-chain signature quotation mechanism actually causes the above problems. To solve this problem, you must use the on-chain signature cancellation mechanism and simply change the above example to invalidate the nonce:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract EcrecoverTest {
// First, change to manage its own nonce for each address.
mapping(address => mapping(uint => bool)) usedNonces;

function cancelNonce(uint nonce) external {
usedNonces[msg.sender][nonce] = true;
}

function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
// ...
require(!usedNonces[owner][nonce], "used nonce");
// ...
}
}

OpenZeppelin Library

We can also use OpenZeppelin MessageHashUtils to handle prefixes for signed messages. We can automatically add the EIP-191 prefix using the following overload method:

1
2
function toEthSignedMessageHash(bytes32 messageHash) internal pure returns (bytes32 digest);
function toEthSignedMessageHash(bytes memory message) internal pure returns (bytes32 digest);

Fixed length hash signature message

1
2
3
4
5
6
7
8
9
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

// bytes32 messageHash = ...;

// Original
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));

// Use OpenZeppelin
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(messageHash);

Dynamic length content

1
2
3
4
5
6
7
8
// uint value = ...;
// uint nonce = ...;

// Original
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n64", value, nonce));

// Use OpenZeppelin
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(abi.encodePacked(value, nonce));

Further Reading

Solidity - Signature Malleability
Using Web3.js to Sign and Recover
Using Ethers.js to Sign and Recover
Solidity - EIP-712 Typed Structured Data Hashing and Signing