Спонсирование комиссий за газ: как покрыть транзакционные издержки для ваших пользователей
Введение
Если мы хотим, чтобы Эфириум обслуживал еще миллиард человек (opens in a new tab), нам нужно устранить препятствия и сделать его максимально простым в использовании. Одним из источников таких препятствий является необходимость наличия ETH для оплаты комиссий за газ.
Если у вас есть децентрализованное приложение (dapp), которое приносит доход от пользователей, возможно, имеет смысл позволить им отправлять транзакции через ваш сервер и оплачивать транзакционные комиссии самостоятельно. Поскольку пользователи по-прежнему подписывают сообщение авторизации EIP-712 (opens in a new tab) в своих кошельках, они сохраняют гарантии целостности Эфириума. Доступность зависит от сервера, который ретранслирует транзакции, поэтому она более ограничена. Однако вы можете все настроить так, чтобы пользователи также могли напрямую обращаться к смарт-контракту (если у них есть ETH), и позволить другим настраивать свои собственные серверы, если они хотят спонсировать транзакции.
Метод, описанный в этом руководстве, работает только тогда, когда вы контролируете смарт-контракт. Существуют и другие методы, включая абстракцию учетной записи (opens in a new tab), которые позволяют спонсировать транзакции к другим смарт-контрактам, и я надеюсь рассмотреть их в будущем руководстве.
Примечание: Это не код для рабочей среды (production). Он уязвим для серьезных атак и не имеет важных функций. Узнайте больше в разделе об уязвимостях этого руководства.
Предварительные требования
Для понимания этого руководства вам необходимо быть знакомым с:
- Solidity
- JavaScript
- React и WAGMI. Если вы не знакомы с этими инструментами пользовательского интерфейса, у нас есть руководство по ним.
Пример приложения
Представленный здесь пример приложения является вариантом контракта Greeter от Hardhat. Вы можете посмотреть его на GitHub (opens in a new tab). Смарт-контракт уже развернут в сети Sepolia (opens in a new tab) по адресу 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
Чтобы увидеть его в действии, выполните следующие шаги.
-
Клонируйте репозиторий и установите необходимое программное обеспечение.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
Отредактируйте
.env, чтобы установить дляPRIVATE_KEYкошелек, на котором есть ETH в сети Sepolia. Если вам нужны Sepolia ETH, используйте кран. В идеале этот приватный ключ должен отличаться от того, который находится в вашем браузерном кошельке. -
Запустите сервер.
1npm run dev -
Перейдите к приложению по URL-адресу
http://localhost:5173(opens in a new tab). -
Нажмите Connect with Injected, чтобы подключиться к кошельку. Одобрите действие в кошельке и при необходимости одобрите переключение на сеть Sepolia.
-
Напишите новое приветствие и нажмите Update greeting via sponsor.
-
Подпишите сообщение.
-
Подождите около 12 секунд (время блока в сети Sepolia). Во время ожидания вы можете посмотреть URL-адрес в консоли сервера, чтобы увидеть транзакцию.
-
Убедитесь, что приветствие изменилось, и что значение адреса последнего обновившего теперь является адресом вашего браузерного кошелька.
Чтобы понять, как это работает, нам нужно рассмотреть, как сообщение создается в пользовательском интерфейсе, как оно ретранслируется сервером и как смарт-контракт его обрабатывает.
Пользовательский интерфейс
Пользовательский интерфейс основан на 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.body4
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 s6 ) external {Эта функция получает подписанный запрос и обновляет приветствие.
1 // Вычислить дайджест EIP-7122 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 г.