
EIP-712 - это стандарт для хеширования и подписи типизированных данных. Основная цель заключается в улучшение опыта пользователя, позволяя кошелькам показывать "человекочитаемые" данные подписи.
Стандарт является разновидностью ERC-191. Согласно этому стандарту подпись формируется следующим образом:

0x19
- говорит о том, что подпись используется в сети Ethereum и не является совместимой с RLP кодировкой, которая применяется для кодирования данных транзакций.
1 byte version
- говорит о типе подписи: personal, EIP-712 и так далее.
Для EIP-712 поля описываются следующим образом:
<1 byte version>
=0x01
. Указывает на то, что в подписи будет использоваться стандарт EIP-712. Все возможные типы версий описаны в таблице стандарта ERC-191.<version specific data>
=domainSeparator
. ТерминdomainSeparator
вводится стандартом EIP-712 и служит для описания особенностей контекста подписи.<data to sign>
=hashStruct(message)
. Это хеш подписываемых пользователем данных.
Кодирование domainSeparator
DomainSeparator является уникальным идентификатором контекста подписи и имеет три глобальные цели:
Идентификация контекста – включает в себя данные, чтобы гарантировать уникальность подписи.
Защита от повторного использования – если пользователь подписал данные для конкретного протокола, то они не могут быть действительными для другого протокола или сети.
Оптимизация хеширования – domainSeparator позволяет заранее вычислить часть хэша всей подписи, что ускоряет проверку подписи.
Описывается domainSeparator
следующим образом:
domainSeparator = hashStruct(eip712Domain)
hashStruct(eip712Domain)
- это хеш структуры, которая содержит следующие поля:

string name
. Название протокола, в котором будет использоваться подписьstring version
. Текущая версия домена подписи. Подписи из разных версий несовместимы. По сути это инструмент для версионирования подписей.uint256 chainId
. Идентификатор цепочки. Используется EIP-155 для защиты от replay attack. Особенно необходимо, когда протокол работает в нескольких сетях.address verifyingContract
. Адрес контракта, который будет проверять подпись. Используется для того, чтобы ограничить список проверяющих подпись.bytes32 salt
. Соль для устранения неоднозначности протокола. Запасной вариант, который может использоваться для разграничения двух подписей с одинаковыми данными domainSeparator.
Важно! Все поля структуры eip712Domain
опциональны и должны быть описаны разработчиками в случае необходимости.
Кодирование данных подписи
Этот раздел описывает кодирование того, что в рамках EIP-191 мы определили, как <data to sign>
, а в рамках EIP-712 как hashStruct(message)
.
Но прежде, чем разбирать хеш структурированных данных необходимо понимать какие в принципе типы данных могут быть.
Типы данных
Выделяют всего три типа данных:
Атомарные типы. Это
bytes1
,bytes32
,uint8
,uint256
,int8
,int256
и так далее. Такжеbool
иaddress
. Важно, что не используются aliasesint
,uint
. Также стандарт оставляет возможность добавления новых типов в будущем.Динамические типы. Сюда относятся
bytes
иstring
.Ссылочные типы. Это массивы и структуры. Массивы с динамическим размером обозначаются, как
Type[]
, c фиксированным размеромType[n]
. Например,address[]
илиaddress[5]
.
Важно знать и учитывать типы данных, потому что для некоторых из них есть нюансы при кодировании и проверке подписи на смарт-контрактах.
Хеширование данных подписи
Посмотрим, что из себя представляет hashStruct(message)
.
hashStruct(message) = keccak256(typeHash ‖ encodeData(message))
Эту запись можно понять так, что хешируется при помощи keccak256 два объекта: typeHash
и encodeData(message)
.
typeHash
- это константа, которая описывает хеш типов данных из message. Описать математически это можно следующим образом typeHash = keccak256(encodeType(typeOf(message)))
.
encodeData(message)
- это кодирование полей структуры данных message
.
В коде мы будем описывать TYPE_HASH хешем строки, которая описывает типы поля user
для абстрактной структуры Order
:
bytes32 private constant TYPE_HASH = keccak256("Order(address user)");
В роли message
выступает значение адреса user, которое мы будем кодировать.
encodeData
Можно воспринимать это, как функцию, которая конкатенирует закодированные поля структуры message
в том порядке, в котором они объявлены.
Важно! Каждое закодированное значение имеет длину ровно 32 байта.
И вот здесь мы подошли к кодированию полей, которое зависит от типа самого поля.
Значения атомарного типа
Кодируются атомарные типы соответственно ABI v1 и v2. То есть bool
кодируется, как число uint256 в значениях 0 или 1. Адреса кодируются как uint160. И так далее. Больше подробностей в документации Solidity.
При проверке подписи на смарт-контрактах не требуется дополнительно кодировать эти типы данных.
Значения динамического типа
Кодируются, как хеш контента при помощи функции keccak256()
. keccak256
- принимает набор байт и это означает, что чтобы хешировать строку, необходимо сначала строку преобразовать в bytes
при помощи функции abi.encodePacked()
.
При проверки подписи на смарт-контрактах, нам придется дополнительно кодировать эти данные.
// string
string memory str = "test";
bytes32 encodedStr = keccak256(abi.encodePacked(str));
// bytes
bytes memory strInBytes = "test";
bytes32 encodedStrInBytes = keccak256(strInBytes);
Значения ссылочного типа
Массивы кодируются, как хеш конкатенированных значений массива. А структура, кодируется рекурсивно, как hashStruct(message)
. Сложно для понимания, но это тот случай когда в структуре данных на подпись есть дочерняя структура и дочерняя структура будет кодироваться по тем же правилам, что и родительская.
// array[2]
address[] memory addressArray = new address[](2);
addressArray[0] = address(0);
addressArray[1] = address(0);
bytes32 encodedAddressArray = keccak256(abi.encodePacked(addressArray));
// struct
bytes32 encodedStruct = keccak256(abi.encode(
PARENT_TYPE_HASH,
// nested structure
keccak256(abi.encode(
CHILD_TYPE_HASH,
// ... nested structure fields
)),
// ... parent fields
));
Проверка подписи
В этом разделе посмотрим на примеры обработки подписи на смарт-контракте в разных ситуациях.
Для этого напишем эталонный пример смарт-контракта с использованием OpenZeppelin.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SignatureChecker is EIP712 {
// encode typeHash
bytes32 private constant TYPE_HASH =
keccak256("Order(address user)");
struct Args {
bytes signature;
}
// Inherit by EIP712(name, version)
constructor() EIP712("EIP-712 based on OZ", "1") {}
function checkSignature(Args calldata args) public view returns (bool) {
// encode message
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPE_HASH,
msg.sender
)));
// recover signer and check
address signer = ECDSA.recover(digest, args.signature);
if (signer != msg.sender) {
return false;
}
return true;
}
}
Этот простой пример проверяет, что вызывающий функцию checkSignature()
, подписал собственный адрес акаунта, как структуру данных в сообщении.
Функция _hashTypedDataV4()
хеширует согласно EIP-712 encodedData (мы сами кодируем эти значения) и domainSeparator (устанавливается в конструкторе) вместе.
Дальше будем смотреть фишки при организации проверки подписи на базе эталонного смарт-контракта.
Повторное использование
Если подпись должна использоваться единоразово, то не должно быть возможности использовать ее повторно.
Самый распространенный пример такой подписи - это подпись, которая позволяет распоряжаться активами пользователя. Предположим пользователь создает ордер на покупку актива и подписывает сумму, которую он готов потратить на покупку. Подпись передается протоколу, протокол должен использовать подпись и списать сумму оплаты в момент передачи актива пользователю.
В этом примере важно, чтобы никто не смог воспользоваться подписью несколько раз и произвести списание указанной суммы. Для того, чтобы этого не произошло в тело подписи вводится одноразовый счетчик nonce
, который делает подпись уникальной. После использования подписи на смарт-контракте счетчик увеличивается.
contract SignatureChecker is EIP712 {
// Add nonce to TYPE_HASH
bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint256 nonce)");
struct Args {
bytes signature;
}
mapping(address user => uint256 nonce) public nonces;
constructor() EIP712("EIP-712 based on OZ", "1") {}
function checkSignature(Args calldata args) public returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPE_HASH,
msg.sender,
nonces[msg.sender] + 1 // Add next user nonce
)));
address signer = ECDSA.recover(digest, args.signature);
if (signer != msg.sender) {
return false;
}
// increase user nonce
nonces[msg.sender] += 1;
return true;
}
}
Смарт-контракт всегда ожидает, что nonce в подписи будет определен согласно счетчику, который увеличивается сразу после использования подписи. Такой нехитрый алгоритм не позволяет повторно использовать подпись и делает ее одноразовой.
Использование в разных сетях, протоколах, смарт-контрактах
Подпись, данная пользователем в одной сети, не должна быть использована в другой.
Подпись, данная пользователем для одного смарт-контракта, не должна быть использована в другом.
Часто протоколы работают в нескольких EVM совместимых сетях. Совместимость позволяет переиспользовать код смарт-контрактов. Для того, чтобы подпись не могла быть использована в другой сети в состав полей domainSeparator вводится поле chainId
.
Если мы используем смарт-контракт EIP-712 от OpenZeppelin, то нам не нужно переживать за chainId
, если мы используем собственное решение, то должны учитывать это поле. Помним, что поле опциональное, и если протокол работает в одной сети, то поле может быть опущено.
Однако чаще всего поле всегда добавляется, так как протокол может захотеть опубликоваться в новой сети позже, а обновить контракты в старой сети и добавить поле chainId
не всегда возможно из-за не изменяемости смарт-контрактов.
В смарт-контракте EIP-712 от OpenZeppelin видно, что в составе domain находится chainId по умолчанию.
abstract contract EIP712 is IERC5267 {
...
function _buildDomainSeparator() private view returns (bytes32) {
return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)));
}
...
}
Аналогично chainId
поля _hashedName
иaddress(this)
регламентируют использование подписи только в конкретном протоколе или смарт-контракте, где реализован алгоритм проверки подписи.
Использование в разных функциях одного смарт-контракта
Подпись, данная пользователем для проверки в одной функции смарт-контракта, не должна быть использована в другой, за исключением, когда это действительно необходимо.
Для регламентирования использования подписи в одной функции смарт-контракта лучший вариант делать уникальными подписываемые данные. Если поля данных одинаковые, то они могут быть разными семантически. Ниже пример формирования двух TYPE_HASH для функций deposit()
и withdraw()
.
contract SignatureChecker is EIP712 {
bytes32 private constant DEPOSIT_TYPE_HASH = keccak256("DEPOSIT(address user,uint256 nonce)");
bytes32 private constant WITHDRAW_TYPE_HASH = keccak256("WITHDRAW(address user,uint256 nonce)");
...
function deposit(Args calldata args) public returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
DEPOSIT_TYPE_HASH,
msg.sender,
nonces[msg.sender] + 1
)));
...
return true;
}
function withdraw(Args calldata args) public returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
WITHDRAW_TYPE_HASH,
msg.sender,
nonces[msg.sender] + 1
)));
...
return true;
}
}
Есть еще один вариант решения. В domain предусмотрено поле salt, которое может быть использована для уникальности домена подписи.
Однако смарт-контракт EIP-712 от OpenZeppelin не предоставляет возможность работать с этим полем из коробки. Для использования salt и OpenZeppelin вместе придется переопределять функции смарт-контракта EIP-712 вручную.
Использование времени жизни
Подпись, данная пользователем, может быть использована в рамках конкретного временного отрезка.
Иногда стоит задача реализовать время жизни подписи, чтобы по истечению времени она перестала быть валидной. Решается это путем добавления в тело подписи timestamp, который обозначает время окончания валидности подписи. При этом на смарт-контракте необходимо добавить проверку, что это время еще не наступило.
contract SignatureChecker is EIP712 {
// Add expiredTime to TYPE_HASH
bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint64 expiredTime,uint256 nonce)");
// Add new field expiredTime
struct Args {
bytes signature;
uint256 expiredTime;
}
...
// Add error
error ExpiredTime();
function checkSignature(Args calldata args) public returns (bool) {
// Check time
if (block.timestamp >= args.expiredTime) {
revert ExpiredTime();
}
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPE_HASH,
msg.sender,
args.expiredTime, // Add expiredTime
nonces[msg.sender] + 1
)));
...
return true;
}
}
Отмена подписи
Возможность отменить подпись.
Часто подпись используется в качестве разрешения действия от имени пользователя. Более того, пользователь подписывает сообщение и передает подпись на хранение протоколу. В таком случае он остается без возможности отменить действие.
Исправить это достаточно легко. На базе nonce
необходимо реализовать отмену подписи. Для этого достаточно увеличить nonce
. Если nonce
еще нет в составе подписи, тогда необходимо его туда добавить.
contract SignatureChecker is EIP712 {
mapping (address user => uint256 nonce) public nonces;
...
function cancelSignature() external {
nonces[msg.sender] += 1;
emit SignatureCanceled(msg.sender);
}
}
Пользователю достаточно самостоятельно вызвать функцию cancelSignature()
, что увеличит nonce
и сделает выданную ранее подпись автоматически невалидной.
Топ ошибок на смарт-контрактах
В этом разделе разберем типовые ошибки, которые возникают у разработчиков при разработке смарт-контрактов, реализующих проверку подписи. Ошибки взяты с ресурса Solodit.
Все эти проблемы могут быть не выявлены в ходе написания тестов с использованием Foundry. Так как тестирование подписей, как и другие тесты пишутся на solidity и результат может быть неосознанно подогнан под ошибку.
В этом плане Hardhat дает больше надежности, потому что там для тестирования подписи приходится написать максимально приближенный к реальности код на js.
Пропуск TYPE_HASH
Несоблюдение стандарта.
Иногда разработчики забывают указать TYPE_HASH для структуры данных или считают, что он не является важным.
contract SignatureChecker is EIP712 {
...
function checkSignature(Args calldata args) public returns (bool) {
if (block.timestamp >= args.expiredTime) {
revert ExpiredTime();
}
// There is no TYPE_HASH in the signature structure
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
msg.sender,
args.expiredTime, // Added expiredTime field
nonces[msg.sender] + 1
)));
...
return true;
}
Не использование поля TYPE_HASH приводит к несовместимости со стандартом EIP-712, что влечет за собой невозможность использовать классические инструменты для создания подписи (metamask sdk и так далее). Также не является безопасным.
Пропуск полей описанных в TYPE_HASH при кодировании
Любого рода ошибки при формировании TYPE_HASH или полей подписываемых данных.
В ходе разработки часто бывает много изменений, требующих особого внимания. Работа с подписью не является исключением. Типичная ошибка, когда структура данных разработчиком меняется, но он забывает исправить TYPE_HASH для этой структуры.
contract SignatureChecker is EIP712 {
// Forgot to correct TYPE_HASH. You need to add expiredTime field
bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint256 nonce)");
...
function checkSignature(Args calldata args) public returns (bool) {
...
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPE_HASH,
msg.sender,
args.expiredTime, // Added expiredTime field
nonces[msg.sender] + 1
)));
...
return true;
}
Было добавлено поле expiredTime
в тело подписи, но забыли добавить поле в TYPE_HASH. Такие ошибки часто встречаются. Может быть ситуация наоборот, когда TYPE_HASH обновлен, а структура данных нет.
В большинстве случаев эту проблему можно избежать качественно выполняя работу по тестированию смарт-контракта.
Ошибка кодирования динамических типов
Динамические типы должны кодироваться особым способом.
Часто новенькие в разработке смарт-контрактов, но возможно бывалые разработчики из других технологий упускают важность правильного кодирования динамических типов.
Динамические типы кодируются, как хеш значения при помощи keccak256()
.
contract SignatureChecker is EIP712 {
bytes32 private constant TYPE_HASH =
keccak256("Order(address operator,address token,uint256 amount,uint256 nonce,bytes data,string str)");
...
constructor() EIP712("EIP-712 based on OZ", "1") {}
function checkSignature(SigArgs calldata args) public view returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPE_HASH,
msg.sender, // operator
args.token,
args.amount,
_nonces[args.user] + 1,
// args.data и args.str - incorrect
// For encode dynamic types you need use keccak256
keccak256(args.data),
keccak256(abi.encodePacked(args.str))
)));
...
return true;
}
}
keccak256()
принимает на вход bytes, поэтому строку сначала необходимо перевести в bytes при помощи abi.encodePacked()
.
Ошибка кодирования ссылочных типов
Ссылочные типы должны кодироваться особым способом.
Это подобная ошибка, как и с динамическими типами, только касается массивов и структур данных.
В кодировании массивов разработчики часто забывают, что это должен быть хеш от набора байт всех элементов массива keccak256(abi.encodePacked(array))
. Забывают или keccak256
или abi.encodePacked
, или все вместе.
contract SignatureChecker is EIP712 {
bytes32 private constant ORDER_TYPE_HASH =
keccak256("Order(address operator,address[] tokens,uint256[] amounts,uint256 nonce)");
...
function checkSignature(SigArgs calldata args) public view returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
ORDER_TYPE_HASH,
msg.sender,
// Error using args.tokens without keccak256(abi.encodePacked())
keccak256(abi.encodePacked(args.tokens)),
keccak256(abi.encodePacked(args.amounts)),
_nonces[args.user] + 1
)));
...
return true;
}
}
Для вложенных друг в друга структур часто допускают ошибки в описании TYPE_HASH. В примере ниже показан правильный вариант описания структур OPERATOR_TYPE_HASH и ORDER_TYPE_HASH.
contract SignatureChecker is EIP712 {
bytes32 private constant OPERATOR_TYPE_HASH =
keccak256("Operator(address operator,string name)");
bytes32 private constant ORDER_TYPE_HASH =
keccak256("Order(Operator operator,address token,uint256 amount,uint256 nonce)Operator(address operator,string name)");
...
function checkSignature(SigArgs calldata args) public view returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
ORDER_TYPE_HASH,
keccak256(abi.encode( // We encode separately all fields of the nested Operator structure
OPERATOR_TYPE_HASH, // Don't forget about TYPE_HASH for the operator structure
msg.sender,
keccak256(abi.encodePacked(args.operatorName)) // Don't forget about dynamic types
)),
args.token,
args.amount,
_nonces[args.user] + 1
)));
...
}
}
Использование abi.encode вместо abi.encodePacked для генерации TYPE_HASH
Необходимо с осторожностью конкатенировать различные TYPE_HASH.
В примере с вложенными структурами можно заметить дублирование OPERATOR_TYPE_HASH внутри ORDER_TYPE_HASH.
Поэтому некоторые протоколы оптимизируют расчет TYPE_HASH, особенно, когда необходимо подписать вложенные структуры данных. Посмотрим на новом примере структур данных.
bytes memory itemTypeString = abi.encodePacked(
"Item(uint8 itemType,address token,uint256 identifier)"
);
bytes memory orderTypeString = abi.encodePacked(
"Order(Item item,address user)"
);
Для получения итогового TYPE_HASH нельзя использовать abi.encode
, необходимо использовать abi.encodePacked
.
bytes32 TYPE_HASH = keccak256(
- abi.encode(itemTypeString, orderTypeString)
+ abi.encodePacked(itemTypeString, orderTypeString)
);
+
В отличие от abi.encodePacked
, abi.encode
добавляет нулевые байты, чтобы всегда возвращать данные в формате 32-х байт, таким образом результирующие хеши не будут одинаковыми.
Для проверки я накидал пример, можно проверить его в remix.
contract Test {
function matchTypeHashes() external pure returns (bytes32, bytes32, bytes32) {
bytes memory itemTypeString = abi.encodePacked(
"Item(uint8 itemType,address token,uint256 identifier)"
);
bytes memory orderTypeString = abi.encodePacked(
"Order(Item item,address user)"
);
return (
keccak256(abi.encode(itemTypeString, orderTypeString)),
keccak256(abi.encodePacked(itemTypeString, orderTypeString)),
keccak256("Item(uint8 itemType,address token,uint256 identifier)Order(Item item,address user)")
);
}
}
Среди возвращаемых значений третий хеш будет совпадать со вторым, первый будет отличаться. Согласно EIP-712 второй и третий - это правильные варианты.
Подпись не защищена от повторного использования
Зачастую подпись не должна иметь возможность быть использованной повторно.
Мы уже разбирали эту ошибку косвенно в разделе от чего подпись должна быть защищена, но к сожалению, ошибка повторного использования продолжает нередко встречаться в смарт-контрактах.
Решается введением счетчика nonce
для каждой подписи или deadline
параметра, регламентирующего время валидности подписи.
Подпись может быть перехвачена и использована другим адресом
Нужно четко определять, кто может воспользоваться подписью.
Структура данных подписи не включает поле аккаунта, который может использовать эту подпись.
Атака может быть проведена по типу front-run. Слушается мемпул, как только оригинальная транзакция, подписанная пользователем, попадает в мемпул, злоумышленник копирует данные подписи и вызывает от своего имени.
contract SignatureChecker is EIP712 {
bytes32 private constant TYPE_HASH =
keccak256("Order(address[] tokens,uint256[] amounts,uint256 nonce)");
struct SigArgs {
address user;
address[] tokens;
uint256[] amounts;
bytes signature;
}
...
function checkSignature(SigArgs calldata args) public view returns (bool) {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
// There is no msg.sender in the signature structure
TYPE_HASH,
keccak256(abi.encodePacked(args.tokens)),
keccak256(abi.encodePacked(args.amounts)),
_nonces[args.user] + 1
)));
address signer = ECDSA.recover(digest, args.signature);
if (signer != args.user) {
return false;
}
// send tokens to msg.sender
return true;
}
}
Интересный факт
Даже у таких передовых специалистов из области безопасности, как разработчики из OpenZeppelin, бывают факапы.
До версии 4.7.3 в смарт-контрактах OpenZeppelin оставлена уязвимость связанная с обработкой подписи.
Функции ECDSA.recover()
и ECDSA.tryRecover()
. Затронуты были перегрузки, которые принимали набор bytes
за место v, r, s
параметров. Пользователь мог взять подпись, которая уже была отправлена, отправить ее снова в другой форме и обойти проверку.
Вывод
EIP-712 предоставляет мощный механизм защиты от атак повторного воспроизведения, делает подписи человекочитаемыми и безопасными. Однако реализация этого стандарта требует строгого соблюдения правил кодирования.
Основные выводы:
Использование domainSeparator регламентирует использование подписей в разных контекстах.
Кодирование данных требует особого внимания к данным динамических и ссылочных типов, чтобы избежать проблем с валидацией подписей.
Включение nonce предотвращает повторное использование подписей, что особенно важно для финансовых операций.
Отступление от стандарта может привести к уязвимостям, позволяющим злоумышленникам использовать подписи повторно или в нежелательных контекстах.
Разработчикам рекомендуется тщательно тестировать реализацию EIP-712 и использовать проверенные библиотеки (например, OpenZeppelin).
Links
Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.