EIP-712: подпись действия пользователя Web3
Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 10 из 30.
Иногда сайт просит подписать не вход, а конкретное разрешение: например, экспортировать прогресс курса17 для указанного получателя. Такое действие нельзя незаметно включить в обычную подпись SIWE. EIP-712 задаёт формат структурированных данных, который помогает показать поля человеку. В этом уроке построим отдельное разрешение и добавим собственную защиту повторного использования.
Предполагаются серверная сессия и понятная модель challenge. Используем тот же viem2.37.9, но новый тип сообщения имеет самостоятельный смысл. Подписи и программы не запускались. Базовый CourseCatalog не проверяет EIP-712-разрешения, поэтому адрес реестра не выдаётся за исполняющий контракт этого примера.
Структура вместо свободной строки
Тип ExportProgress содержит адрес читателя, номер курса, адрес назначения данных как строковое обозначение и одноразовую величину. Сервер заранее создаёт ожидаемый объект. Человек видит, кому и какие сведения разрешает передать. Изменение одного поля даёт другое подписываемое содержание.
Домен EIP-712 отделяет этот формат от похожего сообщения другого приложения. Имя, версия и сеть выбираются согласно назначению. Если подпись проверяет сервер, а не конкретный контракт, не нужно указывать произвольный verifyingContract ради заполнения знакомого шаблона. В нашем серверном домене используется соль, полученная из фиксированного origin приложения. Это собственное правило библиотеки, которое проверяется сервером.
EIP-712 сам по себе не включает защиту повторов. Правильно структурированное сообщение можно предъявить несколько раз, если принимающая система не хранит состояние его использования. Поэтому в тип добавляем nonce и срок, а сервер применяет одноразовый переход точно так же осознанно, как при входе. Разница состоит в назначении разрешения, а не в исчезновении серверного состояния.
Подготовленный объект разрешения
Файл export-permit.js создаёт данные из серверной конфигурации и выбранного читателя. nonce генерируется отдельно сервером как32 случайных байта. Код не содержит настоящий адрес или используемое разрешение.
import { keccak256, toHex } from 'viem';
export function makeExportPermit({ reader, nonce, expiresAt }) {
return {
domain: {
name: 'Library progress export',
version: '1',
chainId: 31337,
salt: keccak256(toHex('https://library.example')),
},
types: {
ExportProgress: [
{ name: 'reader', type: 'address' },
{ name: 'courseId', type: 'uint256' },
{ name: 'destination', type: 'string' },
{ name: 'nonce', type: 'bytes32' },
{ name: 'expiresAt', type: 'uint64' },
],
},
primaryType: 'ExportProgress',
message: {
reader,
courseId: 17n,
destination: 'reader-download',
nonce,
expiresAt,
},
};
}
expiresAt выражается в заранее выбранной целой единице времени, например секундах Unix, и сервер использует то же правило. Он не сравнивается случайно с миллисекундами Date.now. destination в этом примере является известным назначением выгрузки, а не произвольным URL внешнего сервера. Если продукт разрешает несколько получателей, их политика и точное представление объявляются отдельно.
Соль связывает домен с нашим фиксированным origin по соглашению приложения. Она не проверяет, что браузер действительно открыт на этом сайте, и не заменяет безопасный HTTP-маршрут. Сервер всё равно проверяет сессию, origin/CSRF и ожидаемые данные. Криптографический домен и веб-политика имеют разные границы.
Подпись и проверка
Выбранный Wallet Client может подписать подготовленные typed data по явному действию. Сервер получает подпись и собственный ожидаемый объект, а не доверяет версии полей из клиентского JSON. Для обычного аккаунта применяется соответствующая проверка typed data; для контрактного аккаунта адаптер учитывает его модель, как в предыдущем уроке.
const signature = await walletClient.signTypedData({
account: selectedAccount,
...permit,
});
const valid = await serverClient.verifyTypedData({
address: expectedReader,
...expectedPermit,
signature,
});
Это фрагменты двух разных сред: первая строка относится к браузеру и кошельку, вторая — к серверу. expectedPermit приходит из серверного хранилища. Не следует копировать их в один файл и считать, что сервер теперь располагает клиентским кошельком. Пример показывает границы, а полная HTTP-оболочка остаётся отдельным адаптером.
Сначала проверяются поля и срок, затем подпись, затем атомарно потребляется nonce разрешения. После этого сервер выполняет конкретный экспорт. Если операция завершилась технической ошибкой, политика повторного получения результата определяется отдельно. Старую подпись нельзя просто оживить в свободное разрешение без контроля состояния.
Отдельная область полномочий
Подпись ExportProgress не позволяет вызвать publish и не создаёт allowance токена. В базовом контракте нет функции, принимающей этот формат. Разрешение имеет смысл только для нашего серверного действия. Нельзя переименовать его в «подтверждение» и скрыть, что в другом проекте похожие поля могут управлять движением активов.
Сервер также проверяет, что субъект сессии соответствует читателю разрешения. Иначе браузер с сессией A мог бы предъявить чужое подписанное разрешение B и получить неожиданный результат. Конкретная политика допуска может разрешать делегирование, но тогда это вводится новым явно названным полем и правилом, а не случайным побочным эффектом.
Изменение схемы
Если появляется новый получатель или другой состав экспортируемых данных, меняется версия домена или тип сообщения. Старые подписи не должны автоматически расширять полномочия на новые поля. Сервер сохраняет версию выданного разрешения и принимает только известные форматы с точным смыслом.
Поля типа EIP-712 имеют объявленные типы. Не следует заменять uint256 на общее uint или превращать bytes32 в короткую строку ради удобства. Кошелёк и сервер должны хешировать одинаковую схему. Большие числа в JSON-маршруте сериализуются по известному правилу, как уже обсуждалось в курсе интерфейса.
Для упражнения измените получателя, номер курса, nonce и версию домена по одному полю. Ожидается несовпадение с ожидаемым разрешением. Затем повторно предъявите точную правильную подпись: криптографическая проверка может быть успешной, но одноразовый переход уже отказал. Эти критерии не выполнялись программой. Итог — структурированная подпись улучшает описание действия, а защиту повторов обеспечивает конкретная система. Стандарт приведён в EIP-712.
Разрешение и повтор результата
Если экспорт уже выполнен, сервер может разрешить повторно скачать готовый результат в пределах своей сессии и срока. Это не повтор потребления подписи, а обычная операция чтения существующего артефакта. Идентификатор экспорта связывает результат с разрешением, но не становится вечным публичным URL без политики доступа.
Такое разделение особенно полезно при потерянном HTTP-ответе. Пользователь не подписывает одинаковое разрешение снова ради получения уже созданного файла. Он запрашивает известный результат по серверной модели. Если результат не был создан, новый challenge или отдельный контролируемый повтор определяется политикой, сохраняя одноразовую историю и область полномочий.
Подтверждение конкретного назначения
Поле destination ограничено выбранным сценарием скачивания. Если позднее появится отправка прогресса стороннему сервису, потребуются новая область разрешения и отдельное описание передачи данных. Нельзя расширить значение прежней подписи просто добавлением нового обработчика на сервере.