Перейти к основному контенту

Спонсирование комиссий за газ: как покрыть транзакционные издержки для ваших пользователей

без газа
Solidity
EIP-712
мета-транзакции
Intermediate
Ори Померанц
27 февраля 2026 г.
10 минута прочтения

Введение

Если мы хотим, чтобы Эфириум обслуживал еще миллиард человек (opens in a new tab), нам нужно устранить препятствия и сделать его максимально простым в использовании. Одним из источников таких препятствий является необходимость наличия ETH для оплаты комиссий за газ.

Если у вас есть децентрализованное приложение (dapp), которое приносит доход от пользователей, возможно, имеет смысл позволить им отправлять транзакции через ваш сервер и оплачивать транзакционные комиссии самостоятельно. Поскольку пользователи по-прежнему подписывают сообщение авторизации EIP-712 (opens in a new tab) в своих кошельках, они сохраняют гарантии целостности Эфириума. Доступность зависит от сервера, который ретранслирует транзакции, поэтому она более ограничена. Однако вы можете все настроить так, чтобы пользователи также могли напрямую обращаться к смарт-контракту (если у них есть ETH), и позволить другим настраивать свои собственные серверы, если они хотят спонсировать транзакции.

Метод, описанный в этом руководстве, работает только тогда, когда вы контролируете смарт-контракт. Существуют и другие методы, включая абстракцию учетной записи (opens in a new tab), которые позволяют спонсировать транзакции к другим смарт-контрактам, и я надеюсь рассмотреть их в будущем руководстве.

Примечание: Это не код для рабочей среды (production). Он уязвим для серьезных атак и не имеет важных функций. Узнайте больше в разделе об уязвимостях этого руководства.

Предварительные требования

Для понимания этого руководства вам необходимо быть знакомым с:

Пример приложения

Представленный здесь пример приложения является вариантом контракта Greeter от Hardhat. Вы можете посмотреть его на GitHub (opens in a new tab). Смарт-контракт уже развернут в сети Sepolia (opens in a new tab) по адресу 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).

Чтобы увидеть его в действии, выполните следующие шаги.

  1. Клонируйте репозиторий и установите необходимое программное обеспечение.

    git clone https://github.com/qbzzt/260301-gasless.git
    cd 260301-gasless/server
    npm install
    
  2. Отредактируйте .env, чтобы установить для PRIVATE_KEY кошелек, на котором есть ETH в сети Sepolia. Если вам нужны Sepolia ETH, используйте кран. В идеале этот приватный ключ должен отличаться от того, который находится в вашем браузерном кошельке.

  3. Запустите сервер.

    npm run dev
    
  4. Перейдите к приложению по URL-адресу http://localhost:5173 (opens in a new tab).

  5. Нажмите Connect with Injected, чтобы подключиться к кошельку. Одобрите действие в кошельке и при необходимости одобрите переключение на сеть Sepolia.

  6. Напишите новое приветствие и нажмите Update greeting via sponsor.

  7. Подпишите сообщение.

  8. Подождите около 12 секунд (время блока в сети Sepolia). Во время ожидания вы можете посмотреть URL-адрес в консоли сервера, чтобы увидеть транзакцию.

  9. Убедитесь, что приветствие изменилось, и что значение адреса последнего обновившего теперь является адресом вашего браузерного кошелька.

Чтобы понять, как это работает, нам нужно рассмотреть, как сообщение создается в пользовательском интерфейсе, как оно ретранслируется сервером и как смарт-контракт его обрабатывает.

Пользовательский интерфейс

Пользовательский интерфейс основан на WAGMI (opens in a new tab); вы можете прочитать об этом в этом руководстве.

Вот как мы подписываем сообщение:

const signGreeting = useCallback(

Хук React useCallback (opens in a new tab) позволяет нам повысить производительность за счет повторного использования одной и той же функции при перерисовке компонента.

    async (greeting) => {
        if (!account) throw new Error("Wallet not connected")

Если аккаунт отсутствует, вызывается ошибка. Этого никогда не должно происходить, потому что кнопка пользовательского интерфейса, запускающая процесс, который вызывает signGreeting, в этом случае отключена. Однако будущие программисты могут удалить эту защиту, поэтому хорошей идеей будет проверить это условие и здесь.

        const domain = {
            name: "Greeter",
            version: "1",
            chainId,
            verifyingContract: contractAddr,
        }

Параметры для разделителя домена (opens in a new tab). Это значение является константой, поэтому в более оптимизированной реализации мы могли бы вычислить его один раз, а не пересчитывать каждый раз при вызове функции.

  • name — это удобочитаемое имя, например, название dapp, для которого мы создаем подписи.
  • version — это версия. Разные версии несовместимы.
  • chainId — это цепь, которую мы используем, как предоставлено WAGMI (opens in a new tab).
  • verifyingContract — это адрес контракта, который будет проверять эту подпись. Мы не хотим, чтобы одна и та же подпись применялась к нескольким контрактам, на случай, если существует несколько контрактов Greeter и мы хотим, чтобы у них были разные приветствия.

        const types = {
            GreetingRequest: [
                { name: "greeting", type: "string" },
            ],
        }

Тип данных, который мы подписываем. Здесь у нас есть единственный параметр, greeting, но в реальных системах их обычно больше.

        const message = { greeting }

Фактическое сообщение, которое мы хотим подписать и отправить. greeting — это одновременно имя поля и имя переменной, которая его заполняет.

        const signature = await signTypedDataAsync({
            domain,
            types,
            primaryType: "GreetingRequest",
            message,
        })

Фактическое получение подписи. Эта функция асинхронна, потому что пользователям требуется много времени (с точки зрения компьютера) для подписания данных.

Функция возвращает одно шестнадцатеричное значение. Здесь мы разделяем его на поля.

    [account, chainId, contractAddr, signTypedDataAsync],
)

Если какая-либо из этих переменных изменится, создайте новый экземпляр функции. Параметры account и chainId могут быть изменены пользователем в кошельке. contractAddr является функцией идентификатора цепи. signTypedDataAsync не должен меняться, но мы импортируем его из хука (opens in a new tab), поэтому мы не можем быть уверены, и лучше добавить его сюда.

Теперь, когда новое приветствие подписано, нам нужно отправить его на сервер.

  const sponsoredGreeting = async () => {
    try {

Эта функция принимает подпись и отправляет ее на сервер.

      const signedMessage = await signGreeting(newGreeting)
      const response = await fetch("/server/sponsor", {

Отправка по пути /server/sponsor на сервер, с которого мы пришли.

        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(signedMessage),
      })

Используйте POST для отправки информации в формате JSON.

      const data = await response.json()
      console.log("Server response:", data)
    } catch (err) {
      console.error("Error:", err)
    }
  }

Вывод ответа. В рабочей системе мы бы также показали ответ пользователю.

Сервер

Мне нравится использовать Vite (opens in a new tab) для фронтенда. Он автоматически обслуживает библиотеки React и обновляет браузер при изменении кода фронтенда. Однако Vite не включает инструменты для бэкенда.

Решение находится в index.js (opens in a new tab).

Сначала мы регистрируем обработчик для запросов, которые обрабатываем сами (POST к /server/sponsor). Затем мы создаем и используем сервер Vite для обработки всех остальных URL-адресов.

Это просто стандартный вызов блокчейна через viem (opens in a new tab).

Смарт-контракт

Наконец, Greeter.sol (opens in a new tab) должен проверить подпись.

Конструктор создает разделитель домена (opens in a new tab), аналогично коду пользовательского интерфейса выше. Выполнение в блокчейне обходится гораздо дороже, поэтому мы вычисляем его только один раз.

    struct GreetingRequest {
        string greeting;
    }

Это структура, которая подписывается. Здесь у нас только одно поле.

    bytes32 private constant GREETING_TYPEHASH =
        keccak256("GreetingRequest(string greeting)");

Это идентификатор структуры (opens in a new tab). Он вычисляется каждый раз в пользовательском интерфейсе.

    function sponsoredSetGreeting(
        GreetingRequest calldata req,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {

Эта функция получает подписанный запрос и обновляет приветствие.

Создание дайджеста в соответствии с EIP-712 (opens in a new tab).

        // Восстановить подписанта
        address signer = ecrecover(digest, v, r, s);
        require(signer != address(0), "Invalid signature");

Используйте ecrecover (opens in a new tab), чтобы получить адрес подписавшего. Обратите внимание, что неверная подпись все равно может привести к действительному адресу, просто случайному.

        // Применить приветствие так, как если бы его вызвал подписант
        greeting = req.greeting;
        emit SetGreeting(signer, req.greeting);
    }

Обновление приветствия.

Уязвимости

Это не код для рабочей среды. Он уязвим для серьезных атак и не имеет важных функций. Вот некоторые из них, а также способы их решения.

Чтобы увидеть некоторые из этих атак, нажимайте кнопки под заголовком Attacks и смотрите, что происходит. Для кнопки Invalid signature проверьте консоль сервера, чтобы увидеть ответ на транзакцию.

Отказ в обслуживании на сервере

Самая простая атака — это атака типа отказ в обслуживании (opens in a new tab) (DoS) на сервер. Сервер получает запросы из любой точки Интернета и на основе этих запросов отправляет транзакции. Абсолютно ничто не мешает злоумышленнику выпустить кучу подписей, действительных или недействительных. Каждая из них вызовет транзакцию. В конечном итоге на сервере закончатся ETH для оплаты газа.

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

Другое решение — отслеживать адреса и разрешать подписи только от действительных клиентов.

Подписи с неверным приветствием

Когда вы нажимаете Signature for wrong greeting, вы отправляете действительную подпись для определенного адреса (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) и приветствия (Hello). Но она отправляется с другим приветствием. Это сбивает с толку ecrecover, который меняет приветствие, но имеет неверный адрес.

Чтобы решить эту проблему, добавьте адрес в подписанную структуру (opens in a new tab). Таким образом, случайный адрес ecrecover не совпадет с адресом в подписи, и смарт-контракт отклонит сообщение.

Атаки повторного воспроизведения

Когда вы нажимаете Replay attack, вы отправляете ту же подпись «Я 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, и я хочу, чтобы приветствие было Hello», но с правильным приветствием. В результате смарт-контракт считает, что адрес (который не является вашим) изменил приветствие обратно на Hello. Информация для этого общедоступна в информации о транзакции (opens in a new tab).

Если это проблема, одним из решений является добавление нонса (opens in a new tab). Создайте сопоставление (mapping) (opens in a new tab) между адресами и числами и добавьте поле нонса в подпись. Если поле нонса совпадает с сопоставлением для адреса, примите подпись и увеличьте значение в сопоставлении для следующего раза. Если нет, отклоните транзакцию.

Другое решение — добавить временную метку к подписанным данным и принимать подпись как действительную только в течение нескольких секунд после этой временной метки. Это проще и дешевле, но мы рискуем подвергнуться атакам повторного воспроизведения в пределах временного окна, а также сбоем легитимных транзакций, если временное окно будет превышено.

Другие недостающие функции

Есть дополнительные функции, которые мы бы добавили в рабочей среде.

Доступ с других серверов

В настоящее время мы разрешаем любому адресу отправлять sponsorSetGreeting. Возможно, это именно то, что нам нужно в интересах децентрализации. Или, может быть, мы хотим убедиться, что спонсируемые транзакции проходят через наш сервер, и в этом случае мы бы проверяли msg.sender в смарт-контракте.

В любом случае, это должно быть осознанное архитектурное решение, а не просто результат того, что об этой проблеме не подумали.

Обработка ошибок

Пользователь отправляет приветствие. Может быть, оно обновится в следующем блоке. А может и нет. Ошибки невидимы. В рабочей системе пользователь должен иметь возможность различать эти случаи:

  • Новое приветствие еще не отправлено
  • Новое приветствие отправлено и находится в процессе обработки
  • Новое приветствие было отклонено

Заключение

На данном этапе вы должны быть в состоянии создать опыт без газа для пользователей вашего dapp, ценой некоторой централизации.

Однако это работает только со смарт-контрактами, поддерживающими ERC-712. Например, для перевода токена ERC-20 необходимо, чтобы транзакция была подписана владельцем, а не просто сообщением. Решением является абстракция учетной записи (ERC-4337) (opens in a new tab). Я надеюсь написать об этом будущее руководство.

Смотрите здесь другие мои работы (opens in a new tab).

Последнее обновление страницы: 24 марта 2026 г.

Было ли это руководство полезным?