Спонсорування комісій за газ: як покрити витрати на транзакції для ваших користувачів
Вступ
Якщо ми хочемо, щоб Етеріум обслуговував ще мільярд людей (opens in a new tab), нам потрібно усунути перешкоди та зробити його максимально простим у використанні. Одним із джерел цих перешкод є необхідність мати ETH для оплати комісій за газ.
Якщо у вас є децентралізований застосунок (dapp), який заробляє на користувачах, можливо, є сенс дозволити їм надсилати транзакції через ваш сервер і самостійно оплачувати комісії за транзакції. Оскільки користувачі все ще підписують повідомлення авторизації EIP-712 (opens in a new tab) у своїх гаманцях, вони зберігають гарантії цілісності Етеріуму. Доступність залежить від сервера, який ретранслює транзакції, тому вона більш обмежена. Однак ви можете налаштувати все так, щоб користувачі також могли отримувати доступ до смарт-контракту безпосередньо (якщо вони отримають ETH), і дозволити іншим налаштовувати власні сервери, якщо вони хочуть спонсорувати транзакції.
Метод у цьому посібнику працює лише тоді, коли ви контролюєте смарт-контракт. Існують інші методи, зокрема абстракція облікового запису (opens in a new tab), які дозволяють спонсорувати транзакції до інших смарт-контрактів, і я сподіваюся розглянути їх у майбутньому посібнику.
Примітка: це не код виробничого рівня. Він вразливий до серйозних атак і не має основних функцій. Дізнайтеся більше в розділі про вразливості цього посібника.
Передумови
Щоб зрозуміти цей посібник, ви вже повинні бути знайомі з:
- 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) на сервер. Сервер отримує запити з будь-якої точки Інтернету і на основі цих запитів надсилає транзакції. Абсолютно нічого не заважає зловмиснику видати купу підписів, дійсних чи недійсних. Кожен з них викличе транзакцію. Зрештою на сервері закінчаться 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 р.