ECDSA (Elliptic Curve Digital Signature Algorithm) is the core cryptographic algorithm used in Ethereum for verifying transaction and message signatures. Implementing signature verification in Solidity is crucial for implementing meta-transactions, gasless transactions, permission verification, and other scenarios.
1. Basic ECDSA Principles
Ethereum uses the secp256k1 elliptic curve for signatures. A signature consists of three parts:
- r: The x-coordinate of the signature
- s: The proof of the signature
- v: The recovery identifier (27 or 28, or 0/1)
solidity// Signature structure struct Signature { bytes32 r; bytes32 s; uint8 v; }
2. Basic Signature Verification
Using OpenZeppelin's ECDSA Library
solidityimport "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract SignatureVerification { using ECDSA for bytes32; // Verify signer address function verifySignature( bytes32 messageHash, bytes memory signature ) public pure returns (address signer) { // Recover signer address signer = messageHash.recover(signature); return signer; } // Verify if signature is valid function isValidSignature( bytes32 messageHash, bytes memory signature, address expectedSigner ) public pure returns (bool) { address recoveredSigner = messageHash.recover(signature); return recoveredSigner == expectedSigner; } }
3. Message Hash Processing
Ethereum Standard Message Format
soliditycontract MessageHashing { // Method 1: Direct keccak256 hash function getMessageHash( address _to, uint256 _amount, uint256 _nonce ) public pure returns (bytes32) { return keccak256(abi.encodePacked(_to, _amount, _nonce)); } // Method 2: Standard Ethereum message format (recommended) function getEthSignedMessageHash(bytes32 _messageHash) public pure returns (bytes32) { // Add prefix according to Ethereum standard // "\x19Ethereum Signed Message:\n32" + messageHash return keccak256(abi.encodePacked( "\x19Ethereum Signed Message:\n32", _messageHash )); } // Complete signature verification process function verify( address _signer, address _to, uint256 _amount, uint256 _nonce, bytes memory signature ) public pure returns (bool) { // 1. Build message hash bytes32 messageHash = getMessageHash(_to, _amount, _nonce); // 2. Add Ethereum message prefix bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash); // 3. Recover signer address address recoveredSigner = recoverSigner(ethSignedMessageHash, signature); // 4. Verify signer return recoveredSigner == _signer; } function recoverSigner( bytes32 _ethSignedMessageHash, bytes memory _signature ) public pure returns (address) { (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature); return ecrecover(_ethSignedMessageHash, v, r, s); } function splitSignature(bytes memory sig) public pure returns (bytes32 r, bytes32 s, uint8 v) { require(sig.length == 65, "Invalid signature length"); assembly { r := mload(add(sig, 32)) s := mload(add(sig, 64)) v := byte(0, mload(add(sig, 96))) } // Adjust v value (MetaMask and other wallets usually return 27/28) if (v < 27) { v += 27; } } }
4. Meta-Transaction Implementation
Meta-transactions allow users to execute transactions without paying Gas.
solidityimport "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract MetaTransaction is ReentrancyGuard { using ECDSA for bytes32; // Nonce to prevent replay attacks mapping(address => uint256) public nonces; // Address that can execute meta-transactions (can pay Gas) address public relayer; // Domain separator (EIP-712) bytes32 public DOMAIN_SEPARATOR; // EIP-712 type hash bytes32 public constant META_TRANSACTION_TYPEHASH = keccak256("MetaTransaction(address from,address to,uint256 value,uint256 nonce,uint256 data)"); event MetaTransactionExecuted( address indexed from, address indexed to, bytes functionSignature, uint256 nonce ); constructor() { relayer = msg.sender; // Build domain separator DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes("MetaTransaction")), keccak256(bytes("1")), block.chainid, address(this) )); } // Execute meta-transaction function executeMetaTransaction( address from, address to, bytes memory functionSignature, bytes32 sigR, bytes32 sigS, uint8 sigV ) external payable nonReentrant returns (bytes memory) { require(msg.sender == relayer, "Only relayer can execute"); // Build EIP-712 structured data hash bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, keccak256(abi.encode( META_TRANSACTION_TYPEHASH, from, to, msg.value, nonces[from], keccak256(functionSignature) )) )); // Recover signer address signer = ecrecover(digest, sigV, sigR, sigS); require(signer == from, "Invalid signature"); require(signer != address(0), "Zero address signer"); // Increment nonce to prevent replay nonces[from]++; // Execute target call (bool success, bytes memory returnData) = to.call{value: msg.value}(functionSignature); require(success, "Meta transaction failed"); emit MetaTransactionExecuted(from, to, functionSignature, nonces[from] - 1); return returnData; } function getNonce(address from) external view returns (uint256) { return nonces[from]; } }
5. EIP-712 Structured Data Signing
EIP-712 provides a more user-friendly signing experience where users can see structured data when signing.
solidityimport "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract EIP712Example is EIP712 { using ECDSA for bytes32; // Define Permit type hash bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); mapping(address => uint256) public nonces; constructor() EIP712("MyToken", "1") {} // Build domain separator function DOMAIN_SEPARATOR() external view returns (bytes32) { return _domainSeparatorV4(); } // Verify Permit signature function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { require(block.timestamp <= deadline, "Permit expired"); bytes32 structHash = keccak256(abi.encode( PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline )); bytes32 hash = _hashTypedDataV4(structHash); address signer = hash.recover(v, r, s); require(signer == owner, "Invalid signature"); // Execute approval logic _approve(owner, spender, value); } function _approve(address owner, address spender, uint256 value) internal { // Implement approval logic } }
6. Multi-Signature Wallet Implementation
soliditycontract MultiSigWallet { using ECDSA for bytes32; address[] public owners; mapping(address => bool) public isOwner; uint256 public requiredSignatures; struct Transaction { address to; uint256 value; bytes data; bool executed; uint256 signatureCount; } Transaction[] public transactions; mapping(uint256 => mapping(address => bool)) public signatures; event TransactionSubmitted(uint256 indexed txId, address indexed to, uint256 value); event TransactionSigned(uint256 indexed txId, address indexed signer); event TransactionExecuted(uint256 indexed txId); modifier onlyOwner() { require(isOwner[msg.sender], "Not an owner"); _; } constructor(address[] memory _owners, uint256 _required) { require(_owners.length > 0, "Owners required"); require(_required > 0 && _required <= _owners.length, "Invalid required"); for (uint i = 0; i < _owners.length; i++) { address owner = _owners[i]; require(owner != address(0), "Invalid owner"); require(!isOwner[owner], "Owner not unique"); isOwner[owner] = true; owners.push(owner); } requiredSignatures = _required; } // Submit transaction and verify signatures function submitTransaction( address _to, uint256 _value, bytes memory _data, bytes[] memory _signatures ) external onlyOwner returns (uint256 txId) { require(_signatures.length >= requiredSignatures, "Insufficient signatures"); // Build transaction hash bytes32 txHash = keccak256(abi.encodePacked( address(this), _to, _value, keccak256(_data), transactions.length )); bytes32 ethSignedHash = keccak256(abi.encodePacked( "\x19Ethereum Signed Message:\n32", txHash )); // Verify each signature address[] memory signers = new address[](_signatures.length); for (uint i = 0; i < _signatures.length; i++) { address signer = recoverSigner(ethSignedHash, _signatures[i]); require(isOwner[signer], "Signer not an owner"); // Check for duplicate signatures for (uint j = 0; j < i; j++) { require(signers[j] != signer, "Duplicate signature"); } signers[i] = signer; } txId = transactions.length; transactions.push(Transaction({ to: _to, value: _value, data: _data, executed: false, signatureCount: _signatures.length })); emit TransactionSubmitted(txId, _to, _value); // Auto-execute executeTransaction(txId); } function executeTransaction(uint256 _txId) internal { Transaction storage transaction = transactions[_txId]; require(!transaction.executed, "Already executed"); require(transaction.signatureCount >= requiredSignatures, "Insufficient signatures"); transaction.executed = true; (bool success, ) = transaction.to.call{value: transaction.value}(transaction.data); require(success, "Transaction failed"); emit TransactionExecuted(_txId); } function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature) internal pure returns (address) { return _ethSignedMessageHash.recover(_signature); } }
7. Signature Replay Attack Protection
soliditycontract ReplayProtection { using ECDSA for bytes32; // Record of used signatures mapping(bytes32 => bool) public usedSignatures; // User nonces mapping(address => uint256) public nonces; // Chain ID uint256 public chainId; constructor() { chainId = block.chainid; } // Secure signature verification (includes chain ID and contract address) function verifyWithReplayProtection( address _signer, bytes memory _signature, bytes memory _data ) external returns (bool) { uint256 nonce = nonces[_signer]; // Build message containing replay protection info bytes32 messageHash = keccak256(abi.encodePacked( chainId, address(this), _signer, nonce, _data )); bytes32 ethSignedMessageHash = keccak256(abi.encodePacked( "\x19Ethereum Signed Message:\n32", messageHash )); // Check if signature has been used require(!usedSignatures[ethSignedMessageHash], "Signature already used"); // Recover signer address recoveredSigner = ethSignedMessageHash.recover(_signature); require(recoveredSigner == _signer, "Invalid signature"); // Mark signature as used usedSignatures[ethSignedMessageHash] = true; // Increment nonce nonces[_signer]++; return true; } }
8. Best Practices for Signature Verification
- Always use standard Ethereum message format: Add
\x19Ethereum Signed Message:\n32prefix - Prevent replay attacks: Use nonce and chain ID
- Verify signature length: Ensure signature length is 65 bytes
- Check signer address: Ensure recovered address is not zero address
- Use EIP-712: Provide better user experience and security
- Consider using OpenZeppelin: Audited standard implementation
solidity// Complete best practice signature verification contract BestPracticeSignature { using ECDSA for bytes32; bytes32 public constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); bytes32 public immutable DOMAIN_SEPARATOR; constructor() { DOMAIN_SEPARATOR = keccak256(abi.encode( EIP712_DOMAIN_TYPEHASH, keccak256(bytes("BestPractice")), keccak256(bytes("1")), block.chainid, address(this) )); } function verifyEIP712Signature( bytes32 structHash, bytes memory signature, address expectedSigner ) external view returns (bool) { bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, structHash )); address signer = digest.recover(signature); return signer == expectedSigner && signer != address(0); } }