Интеграция EIP-191 (подписи сообщений)
Пользователь подключил MetaMask, вы хотите убедиться что он — владелец адреса, без отправки транзакции. Или нужно реализовать gasless whitelist: backend выдаёт подписанное разрешение, контракт верифицирует его on-chain. Оба кейса — EIP-191.
Что делает EIP-191
EIP-191 стандартизирует формат подписываемых сообщений в Ethereum. Без стандарта подпись произвольных байтов совпадает с подписью транзакции — теоретический вектор фишинга. EIP-191 добавляет префикс \x19Ethereum Signed Message:\n{length} перед хешированием, делая подписи специфичными для Ethereum и нечитаемыми как транзакции.
Версии EIP-191:
-
0x45—personal_sign(добавляет текстовый префикс, человекочитаемо в MetaMask) -
0x01— structured data (это EIP-712, расширение EIP-191) -
0x00— validator data (менее распространён)
Большинство кейсов — версия 0x45: personal_sign в MetaMask показывает пользователю читаемый текст, пользователь понимает что подписывает.
Верификация on-chain
В Solidity верификация через 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 — правильный способ: обрабатывает v = 27/28, защищён от signature malleability (проверяет, что s находится в нижней половине кривой, согласно EIP-2).
Типичная ошибка: хешировать строку напрямую через keccak256(abi.encodePacked(message)) без EIP-191 префикса. Подпись с MetaMask (personal_sign) содержит префикс — верификация без него даст неверный signer.
Кейс: gasless whitelist через backend-подпись
Backend хранит whitelist адресов. Вместо хранения whitelist on-chain (дорого, требует транзакции для добавления), backend подписывает разрешение для конкретного адреса. Пользователь предъявляет подпись в контракт при 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++);
}
Важно включить address(this) в хеш — защита от replay между контрактами. Включить block.chainid или chainId — защита от replay между сетями. Для одноразовых разрешений — nonce пользователя в хеше.
Для более структурированных данных (сумма, deadline, конкретный токен) лучше EIP-712 — пользователь видит каждое поле в MetaMask, не просто хеш.
Frontend-интеграция
С viem:
const signature = await walletClient.signMessage({ message: "Verify ownership" });
С ethers.js:
const signature = await signer.signMessage("Verify ownership");
Оба возвращают 65-байтовую подпись (r + s + v). Передаётся в контракт как bytes.
Сроки
Интеграция EIP-191 в существующий контракт (верификация подписи, protection от replay) + frontend-код — 1 рабочий день. С backend-сервисом для выдачи подписей — 2-3 дня.







