EIP-191 Integration (Message Signatures)
User has connected MetaMask, you want to verify they own the address without sending a transaction. Or you need to implement gasless whitelist: backend issues a signed permission, contract verifies it on-chain. Both cases — EIP-191.
What EIP-191 Does
EIP-191 standardizes message signature format in Ethereum. Without standard, signature of arbitrary bytes matches transaction signature — theoretical phishing vector. EIP-191 adds prefix \x19Ethereum Signed Message:\n{length} before hashing, making signatures specific to Ethereum and unreadable as transactions.
EIP-191 versions:
-
0x45—personal_sign(adds text prefix, human-readable in MetaMask) -
0x01— structured data (this is EIP-712, EIP-191 extension) -
0x00— validator data (less common)
Most cases — version 0x45: personal_sign in MetaMask shows user readable text, user understands what they're signing.
On-Chain Verification
In Solidity, verification via ecrecover:
function verify(string calldata message, bytes calldata signature)
public pure returns (address signer)
{
bytes32 messageHash = keccak256(bytes(message));
bytes32 ethSignedHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
return ECDSA.recover(ethSignedHash, signature);
}
OpenZeppelin ECDSA.recover — correct way: handles v = 27/28, protected against signature malleability (verifies s is in lower half of curve, per EIP-2).
Typical mistake: hash string directly via keccak256(abi.encodePacked(message)) without EIP-191 prefix. Signature from MetaMask (personal_sign) contains prefix — verification without it gives wrong signer.
Use Case: Gasless Whitelist via Backend Signature
Backend stores whitelist addresses. Instead of storing whitelist on-chain (expensive, requires transaction to add), backend signs permission for specific address. User presents signature to contract on mint:
function mint(bytes calldata signature) external {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, address(this)));
bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(hash);
address signer = ECDSA.recover(ethHash, signature);
require(signer == trustedSigner, "Invalid signature");
_mint(msg.sender, nextTokenId++);
}
Important to include address(this) in hash — protection against replay between contracts. Include block.chainid or chainId — protection against replay between networks. For one-time permissions — nonce of user in hash.
For more structured data (amount, deadline, specific token) — better to use EIP-712 — user sees each field in MetaMask, not just hash.
Frontend Integration
With viem:
const signature = await walletClient.signMessage({ message: "Verify ownership" });
With ethers.js:
const signature = await signer.signMessage("Verify ownership");
Both return 65-byte signature (r + s + v). Transmitted to contract as bytes.
Timeline
EIP-191 integration into existing contract (signature verification, replay protection) + frontend code — 1 working day. With backend service for issuing signatures — 2-3 days.







