ERC2612 и юзабилити Ethereum dApp

Бороздя просторы Твиттера/X, я увидел сервис smolrefuel.com, (тви), который решает проблему получения газового токена на Эфир-сетях, если у вас его нет, а есть выведенный, например, на кошелек с биржи, стейблкоин. Интересный кейс! — подумал я.

Начал разбираться, как это работает. Если высокоуровнево, то алгоритм такой:

  1. Пользователь с кошельком W выбирает токен T (например, USDC), за который хочет купить газовый (нативный) токен.

  2. Подписывает офф-чейн (газ не нужен) некоторый «документ» A. Документ представляет собой следующую доверенность (дальше буду использовать этот термин, как мне кажется, удачно отражающий суть подписанного сообщения):

    1. Какому смарт-контракту S (mart Contract) я разрешаю снять с кошелька W (allet) сумму V(alue) в токенах T(oken) до некоторой даты D (eadline). Разрешение — обычный механизм approve в ERC20.

    2. То есть, по сути, это аналог бумажной доверенности, которая позволяет доверенному лицу пойти и сделать что-то от имени доверителя.

    3. В качестве S выступает, естественно, смарт-контракт сервиса.

  3. Доверенность A передается на бекенд , где с ее помощью нужному контракту S дается разрешение на снятие определенной суммы, далее S эту сумму снимает с кошелька пользователя, меняет на DEXе на нативный токен, берет свою комиссию, перечисляет нативный токен на кошелек W. И все довольны.

ERC2612

Если углубиться в детали, то это работает на стандарте ERC2612. Можно почитать оригинал, но основа — функция permit, реализованная в токене T.

function permit(
  address owner, 
  address spender, 
  uint value, 
  uint deadline, 
  uint8 v, bytes32 r, bytes32 s)

Она говорит «разрешить смарт-контракту spender потратить value токенов с адреса owner», вот доверенность. Трио (v, r, s) представляет собой ту самую доверенность А от owner, и это, по сути, выходные данные алгоритма secp256k1. Сильно не вдаваясь в математику, у secp256k1 есть замечательное свойство — легко проверить, что доверенность действительно подписана адресом owner. Например, как это делается в реализации от OpenZeppelin. permit также проверяет, что дедлайн выданной доверенности не истек.

Для предотвращения replay атак, чтобы сгенерированная на одном из токенов или другой сети доверенность не смогла быть повторно использована, при ее создании используется уникальный ключ domain, в котором согласно EIP-712 кодируется имя токена, версия контракта, id сети, адрес контракта, при необходимости соль. Domain уникален для токена, и для него это константное значение. Для подписания доверенности также используется nonce на уровне контракта токена, то есть каждая следующая доверенность будет отличаться от предыдущей.

Запишу в псевдокоде, как выглядит подписание доверенности. Важна последовательность вызовов и какие данные используются:

// то самое значение domain для уникальности доверенности
domain = {
  name: "Token Name",
  version: "1",
  chainId: "137",
  verifyingContract: "0xAaAaa...."
}

// параметры для permit
values = {
  owner: W,         // адрес кошелька W
  spender: S,       // кому разрешим потратить
  value: V,         // сколько, например 100000000
  nonce: getNonce(T, W) // nonce (счетчик, увеличивающийся на единицу),
  deadline: D       // час с текущего момента
}

A = signTypedData(domain, values) // получили доверенность A

Теперь A вместе с values передается для вызова permit у контракта токена следующим образом (тоже псевдокод):

v, r, s = splitSignature(A) // получить компоненты доверенности v, r, s
T.permit(owner: W, spender: S, value: V, deadline: D, v, r, s)

Важно, что вызов может сделать любой контракт — permit не проверяет на то, какой аккаунт делает вызов; важно только, кто подписал доверенность — поэтому для подписавшего доверенность эта операция может быть абсолютно gasless (без оплаты за газ), т.к. в блокчейн транзакцию отправит другой кошелек, что и делает бекенд в случае со smolrefuel. Теперь давайте посмотрим на псевдокод permit (пример из реализации OpenZeppelin):

function permit(address owner, address spender, uint value, uint deadline, 
  uint8 v, bytes32 r, bytes32 s) {
  
    if (deadline > NOW())
      throw error("дедлайн просрочен")

    // восстановили "подписанта" доверенности
    address signer = recover(DOMAIN_SEPARATOR, v, r, s)
  
    // тот кто подписал не соответствует заданному owner
    if (signer != owner)
      throw error("неверная подпись")

    // все хорошо, дать аппрув spender на списание value с owner
    approve(owner, spender, value)
  }

DOMAIN_SEPARATOR — это константа, привязанная к токену, является производной от domain.

Чтобы на практике проверить, как работает ERC2612, я запилил прототип. Он состоит из двух компонентов:

  1. Фронтенд приложение (папка react), в котором пользователь с помощью Metamask или другого кошелька подписывает офф-чейн доверенность на снятие 0.1 USDC на сети Полигон с текущего адреса контракту (src/constants.ts, spenderAddress — установите его самостоятельно). Основной код в App.tsx, функция sendPermit.

  2. Бекенд приложение (папка console), принимает доверенность и параметры с фронтенда и вызывает permit. Чтобы оно работало, в .env (см. .env.example) файле нужно заполнить 2 важных параметра — RPC URL (взять на сервисах типа Alchemy, Infura и тд, их множество), и PK — приватный ключ адреса, с которого будет отправляться транзакция (не храните приватные ключи в продакшене — тут такой подход для простоты) — на нем должен быть газовый токен. Основной код в index.ts, POST-обработчике.

По умолчанию прототип работает с Polygon USDC, но в constants.ts можно указать любой tokenAddress.

Внедрение стандарта

Как мы видим, в целом, стандарты типа ERC2612 позволяют упростить жизнь пользователей криптокошельков, особенно начинающих. Мне стало интересно, насколько распространен этот стандарт среди токенов. Для этого я проанализировал топ 10 токенов на сетях Ethereum, Polygon, Optimism. Выяснились интересные вещи (ссылка на файл анализа).

d7c4774b3d4ef7b5ab8ef77130be5c89.png

Зеленым отмечены токены, которые правильно реализуют стандарты ERC2612 и EIP712. Для них реализовать выпуск доверенности — проще всего (*).

Желтым — токены, реализующие ERC2612 (то есть функцию permit), но не поддерживающие EIP712. Это сложный кейс, потому что signTypedData, реализованный в ethers.js, для создания доверенности, в точности реализует EIP712.

a06499b2bb4128dd231bd6f2895cc6cc.png

Более того, в браузере, когда Metamask подписывает типизированные данные, то eth_signTypedData_v4 принимает объект domain, и вычисляет domain separator внутри себя, который не совпадет с domain separator токена. Можно частично решить проблему, но только в случае, если у вас есть приватный ключ (например, на бекенде). Дернуть значение DOMAIN_SEPARATOR с контракта и использовать его в signTypedData, для этого потребовалось модифицировать ethers.js (сделал PR; посмотрим, включат или нет). Навскидку сложно сказать, что это могли бы быть за кейсы, т.к. невозможно составить корректную доверенность на UI (через Метамаск). В прототип включать не стал для простоты, можно потренироваться самостоятельно, если забрать модифицированную версию ethers.

Красным отмечен один токен (DAI на Mainnet), у которого есть функция permit, но не по ERC2612 (sic!). Она позволяет два варианта создании доверенности на аппрув: (1) на максимально возможную сумму или (2) вообще отозвать его. Почему так сделано — загадка. permit принимает параметр allowed: bool.

1035766520d18c09e918a4123612cb56.png

Уфф… И это еще не все. Чтобы подписать доверенность, требуется сконструировать domain, и где-то взять значения полей для него. Все они тривиальны, кроме version. Помеченные звездочкой (*) контракты не имеют публичного метода version (), поэтому пришлось перебором искать конкретную версию, которая используется в domain этого токена — записал ее после звездочки. Долго перебирать не пришлось, она либо 1 или 2, но если разработчик токена решит версию поменять, то ваше приложение перестанет работать…

Отмечу, что version (), насколько знаю, не входит в стандарты, и его реализация — на усмотрение разработчика. Например, на Optimism DAI этот метод есть, а у OP — нету, что вносит некоторые неудобства. Для решение этой проблемы даже придумали EIP5267, но его из всего списка поддерживает stETH (**), см. таблицу.

Инструменты

Отдельно хотел поделиться классным инструментом. tenderly — отличный отладчик/дебаггер. Для Ethereum токенов при тестировании я использовал классный инструмент симуляции транзакции, чтобы не платить за дорогой газ.

dce43e3a1b331a2ca294b1fe38fb0c52.png

Если не проходила транзакция, у tenderly есть дебаггер, который позволяет по шагам транзакцию прогнать.

Выводы

Исследование оставило противоречивые чувства. С одной стороны, есть стандарты по улучшению юзабилити в Ethereum-сетях. С другой стороны, анализ показал, что из 30 рассмотренных токенов на популярных сетях, только на 8 можно выпускать доверенности на аппрув, из них для трех писать отдельные обработчики (два для version и один для нестандартного permit). Не могу назвать себя визионером, но как будто это очень мало для адопшена. Также, я бы закладывал значительное увеличение времени на разработку при добавлении токенов в ваш dApp в подобных случаях, если работаете с чем-то менее распространенным, чем ERC20.

Минутка PR. Веду тг‑канал Web3 разработчик. Пишу небольшие заметки (не часто) о задачах по блокчейну/крипте, которые решаю. Буду рад видеть среди подписчиков! Есть также твиттер.

Также всегда рад пообщаться с фаундерами/разработчиками криптопроектов.

© Habrahabr.ru