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

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

без газа
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. Клонируйте репозиторий и установите необходимое программное обеспечение.

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

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

    1npm 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); вы можете прочитать об этом в этом руководстве.

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

1const signGreeting = useCallback(

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

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

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

1 const domain = {
2 name: "Greeter",
3 version: "1",
4 chainId,
5 verifyingContract: contractAddr,
6 }

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

  • name — это удобочитаемое имя, например, название dapp, для которого мы создаем подписи.
  • version — это версия. Разные версии несовместимы.
  • chainId — это цепь, которую мы используем, как предоставлено WAGMI (opens in a new tab).
  • verifyingContract — это адрес контракта, который будет проверять эту подпись. Мы не хотим, чтобы одна и та же подпись применялась к нескольким контрактам, на случай, если существует несколько контрактов Greeter и мы хотим, чтобы у них были разные приветствия.
1
2 const types = {
3 GreetingRequest: [
4 { name: "greeting", type: "string" },
5 ],
6 }

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

1 const message = { greeting }

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

1 const signature = await signTypedDataAsync({
2 domain,
3 types,
4 primaryType: "GreetingRequest",
5 message,
6 })

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

1 const r = `0x${signature.slice(2, 66)}`
2 const s = `0x${signature.slice(66, 130)}`
3 const v = parseInt(signature.slice(130, 132), 16)
4
5 return {
6 req: { greeting },
7 v,
8 r,
9 s,
10 }
11 },

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

1 [account, chainId, contractAddr, signTypedDataAsync],
2)

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

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

1 const sponsoredGreeting = async () => {
2 try {

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

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

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

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

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

1 const data = await response.json()
2 console.log("Server response:", data)
3 } catch (err) {
4 console.error("Error:", err)
5 }
6 }

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

Сервер

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

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

1 app.post("/server/sponsor", async (req, res) => {
2 ...
3 })
4
5 // Пусть Vite обработает все остальное
6 const vite = await createViteServer({
7 server: { middlewareMode: true }
8 })
9
10 app.use(vite.middlewares)

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

1 app.post("/server/sponsor", async (req, res) => {
2 try {
3 const signed = req.body
4
5 const txHash = await sepoliaClient.writeContract({
6 address: greeterAddr,
7 abi: greeterABI,
8 functionName: 'sponsoredSetGreeting',
9 args: [signed.req, signed.v, signed.r, signed.s],
10 })
11 } ...
12 })

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

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

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

1 constructor(string memory _greeting) {
2 greeting = _greeting;
3
4 DOMAIN_SEPARATOR = keccak256(
5 abi.encode(
6 keccak256(
7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
8 ),
9 keccak256(bytes("Greeter")),
10 keccak256(bytes("1")),
11 block.chainid,
12 address(this)
13 )
14 );
15 }

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

1 struct GreetingRequest {
2 string greeting;
3 }

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

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

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

1 function sponsoredSetGreeting(
2 GreetingRequest calldata req,
3 uint8 v,
4 bytes32 r,
5 bytes32 s
6 ) external {

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

1 // Вычислить дайджест EIP-712
2 bytes32 digest = keccak256(
3 abi.encodePacked(
4 "\x19\x01",
5 DOMAIN_SEPARATOR,
6 keccak256(
7 abi.encode(
8 GREETING_TYPEHASH,
9 keccak256(bytes(req.greeting))
10 )
11 )
12 )
13 );

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

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

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

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

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

Уязвимости

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

Чтобы увидеть некоторые из этих атак, нажимайте кнопки под заголовком 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).

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

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