Przejdź do głównej treści

Sponsorowanie opłat za gaz: Jak pokryć koszty transakcji dla swoich użytkowników

bez gazu
Solidity
EIP-712
metatransakcje
Średnio zaawansowany
Ori Pomerantz
27 lutego 2026
10 minuta czytania

Wprowadzenie

Jeśli chcemy, aby Ethereum służyło kolejnemu miliardowi ludzi (opens in a new tab), musimy usunąć przeszkody i uczynić je tak łatwym w użyciu, jak to tylko możliwe. Jednym ze źródeł tych trudności jest konieczność posiadania ETH do uiszczania opłat za gaz.

Jeśli masz zdecentralizowaną aplikację (dapp), która zarabia na użytkownikach, sensowne może być umożliwienie im przesyłania transakcji przez Twój serwer i samodzielne opłacanie kosztów transakcji. Ponieważ użytkownicy nadal podpisują wiadomość autoryzacyjną EIP-712 (opens in a new tab) w swoich portfelach, zachowują gwarancje integralności Ethereum. Dostępność zależy od serwera przekazującego transakcje, więc jest bardziej ograniczona. Możesz jednak skonfigurować wszystko tak, aby użytkownicy mogli również uzyskiwać bezpośredni dostęp do inteligentnego kontraktu (jeśli zdobędą ETH), a inni mogli konfigurować własne serwery, jeśli chcą sponsorować transakcje.

Technika opisana w tym samouczku działa tylko wtedy, gdy kontrolujesz inteligentny kontrakt. Istnieją inne techniki, w tym abstrakcja konta (opens in a new tab), które pozwalają sponsorować transakcje do innych inteligentnych kontraktów, co mam nadzieję omówić w przyszłym samouczku.

Uwaga: To nie jest kod gotowy do wdrożenia na produkcję. Jest podatny na poważne ataki i brakuje mu kluczowych funkcji. Dowiedz się więcej w sekcji dotyczącej luk w zabezpieczeniach tego przewodnika.

Wymagania wstępne

Aby zrozumieć ten samouczek, musisz już znać:

  • Solidity
  • JavaScript
  • React i WAGMI. Jeśli nie znasz tych narzędzi interfejsu użytkownika, mamy do tego samouczek.

Przykładowa aplikacja

Przykładowa aplikacja jest wariantem kontraktu Greeter narzędzia Hardhat. Możesz ją zobaczyć na GitHubie (opens in a new tab). Inteligentny kontrakt jest już wdrożony w sieci Sepolia (opens in a new tab), pod adresem 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).

Aby zobaczyć ją w akcji, wykonaj następujące kroki.

  1. Sklonuj repozytorium i zainstaluj niezbędne oprogramowanie.

    1git clone https://github.com/qbzzt/260301-gasless.git
    2cd 260301-gasless/server
    3npm install
  2. Edytuj plik .env, aby ustawić PRIVATE_KEY na portfel, który posiada ETH w sieci Sepolia. Jeśli potrzebujesz Sepolia ETH, użyj kranika. W idealnym przypadku ten klucz prywatny powinien być inny niż ten, który masz w portfelu w przeglądarce.

  3. Uruchom serwer.

    1npm run dev
  4. Przejdź do aplikacji pod adresem URL http://localhost:5173 (opens in a new tab).

  5. Kliknij Connect with Injected, aby połączyć się z portfelem. Zatwierdź w portfelu i w razie potrzeby zatwierdź zmianę sieci na Sepolia.

  6. Wpisz nowe powitanie i kliknij Update greeting via sponsor.

  7. Podpisz wiadomość.

  8. Poczekaj około 12 sekund (czas bloku w sieci Sepolia). W trakcie oczekiwania możesz spojrzeć na adres URL w konsoli serwera, aby zobaczyć transakcję.

  9. Zobacz, że powitanie uległo zmianie, a wartość adresu ostatniej aktualizacji to teraz adres Twojego portfela w przeglądarce.

Aby zrozumieć, jak to działa, musimy przyjrzeć się, jak wiadomość jest tworzona w interfejsie użytkownika, jak jest przekazywana przez serwer i jak przetwarza ją inteligentny kontrakt.

Interfejs użytkownika

Interfejs użytkownika oparty jest na WAGMI (opens in a new tab); możesz o nim przeczytać w tym samouczku.

Oto jak podpisujemy wiadomość:

1const signGreeting = useCallback(

Hook Reacta useCallback (opens in a new tab) pozwala nam poprawić wydajność poprzez ponowne użycie tej samej funkcji podczas ponownego renderowania komponentu.

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

Jeśli nie ma konta, zgłoś błąd. To nigdy nie powinno się zdarzyć, ponieważ przycisk interfejsu użytkownika, który uruchamia proces wywołujący signGreeting, jest w takim przypadku wyłączony. Jednak przyszli programiści mogą usunąć to zabezpieczenie, więc dobrym pomysłem jest sprawdzenie tego warunku również tutaj.

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

Parametry dla separatora domeny (opens in a new tab). Ta wartość jest stała, więc w lepiej zoptymalizowanej implementacji moglibyśmy obliczyć ją raz, zamiast przeliczać za każdym razem, gdy funkcja jest wywoływana.

  • name to czytelna dla użytkownika nazwa, taka jak nazwa zdecentralizowanej aplikacji (dapp), dla której generujemy podpisy.
  • version to wersja. Różne wersje nie są ze sobą kompatybilne.
  • chainId to łańcuch, którego używamy, dostarczony przez WAGMI (opens in a new tab).
  • verifyingContract to adres kontraktu, który zweryfikuje ten podpis. Nie chcemy, aby ten sam podpis miał zastosowanie do wielu kontraktów, na wypadek gdyby istniało kilka kontraktów Greeter i chcielibyśmy, aby miały różne powitania.
1
2 const types = {
3 GreetingRequest: [
4 { name: "greeting", type: "string" },
5 ],
6 }

Typ danych, który podpisujemy. Tutaj mamy pojedynczy parametr, greeting, ale systemy w świecie rzeczywistym zazwyczaj mają ich więcej.

1 const message = { greeting }

Właściwa wiadomość, którą chcemy podpisać i wysłać. greeting to zarówno nazwa pola, jak i nazwa zmiennej, która je wypełnia.

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

Właściwe pobranie podpisu. Ta funkcja jest asynchroniczna, ponieważ użytkownikom zajmuje dużo czasu (z perspektywy komputera) podpisanie danych.

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 },

Funkcja zwraca pojedynczą wartość szesnastkową. Tutaj dzielimy ją na pola.

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

Jeśli którakolwiek z tych zmiennych ulegnie zmianie, utwórz nową instancję funkcji. Parametry account i chainId mogą zostać zmienione przez użytkownika w portfelu. contractAddr jest funkcją identyfikatora łańcucha (chain Id). signTypedDataAsync nie powinno się zmieniać, ale importujemy je z hooka (opens in a new tab), więc nie możemy być pewni i najlepiej dodać je tutaj.

Teraz, gdy nowe powitanie jest podpisane, musimy wysłać je do serwera.

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

Ta funkcja przyjmuje podpis i wysyła go do serwera.

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

Wyślij na ścieżkę /server/sponsor na serwerze, z którego przyszliśmy.

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

Użyj POST, aby wysłać informacje zakodowane w formacie JSON.

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

Wypisz odpowiedź. W systemie produkcyjnym pokazalibyśmy również odpowiedź użytkownikowi.

Serwer

Lubię używać Vite (opens in a new tab) jako mojego front-endu. Automatycznie serwuje biblioteki Reacta i aktualizuje przeglądarkę, gdy zmienia się kod front-endu. Jednak Vite nie zawiera narzędzi backendowych.

Rozwiązanie znajduje się w index.js (opens in a new tab).

1 app.post("/server/sponsor", async (req, res) => {
2 ...
3 })
4
5 // Niech Vite zajmie się całą resztą
6 const vite = await createViteServer({
7 server: { middlewareMode: true }
8 })
9
10 app.use(vite.middlewares)

Najpierw rejestrujemy procedurę obsługi (handler) dla żądań, które obsługujemy sami (POST do /server/sponsor). Następnie tworzymy i używamy serwera Vite do obsługi wszystkich innych adresów 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 })

To tylko standardowe wywołanie blockchaina za pomocą viem (opens in a new tab).

Inteligentny kontrakt

Na koniec, Greeter.sol (opens in a new tab) musi zweryfikować podpis.

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 }

Konstruktor tworzy separator domeny (opens in a new tab), podobnie jak w powyższym kodzie interfejsu użytkownika. Wykonywanie operacji na blockchainie jest znacznie droższe, więc obliczamy go tylko raz.

1 struct GreetingRequest {
2 string greeting;
3 }

To jest struktura, która zostaje podpisana. Tutaj mamy tylko jedno pole.

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

To jest identyfikator struktury (opens in a new tab). Jest on obliczany za każdym razem w interfejsie użytkownika.

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

Ta funkcja odbiera podpisane żądanie i aktualizuje powitanie.

1 // Oblicz skrót 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 );

Utwórz skrót (digest) zgodnie z EIP-712 (opens in a new tab).

1 // Odzyskaj podpisującego
2 address signer = ecrecover(digest, v, r, s);
3 require(signer != address(0), "Invalid signature");

Użyj ecrecover (opens in a new tab), aby uzyskać adres podpisującego. Zauważ, że zły podpis nadal może skutkować prawidłowym adresem, po prostu losowym.

1 // Zastosuj powitanie, tak jakby wywołał je podpisujący
2 greeting = req.greeting;
3 emit SetGreeting(signer, req.greeting);
4 }

Zaktualizuj powitanie.

Luki w zabezpieczeniach

To nie jest kod gotowy do wdrożenia na produkcję. Jest podatny na poważne ataki i brakuje mu kluczowych funkcji. Oto niektóre z nich, wraz ze sposobami ich rozwiązania.

Aby zobaczyć niektóre z tych ataków, kliknij przyciski pod nagłówkiem Attacks i zobacz, co się stanie. W przypadku przycisku Invalid signature, sprawdź konsolę serwera, aby zobaczyć odpowiedź transakcji.

Odmowa usługi (Denial of Service) na serwerze

Najprostszym atakiem jest atak typu odmowa usługi (denial-of-service) (opens in a new tab) na serwer. Serwer odbiera żądania z dowolnego miejsca w Internecie i na ich podstawie wysyła transakcje. Absolutnie nic nie powstrzymuje atakującego przed wygenerowaniem mnóstwa podpisów, ważnych lub nieważnych. Każdy z nich spowoduje transakcję. W końcu serwerowi zabraknie ETH na opłacenie gazu.

Jednym z rozwiązań tego problemu jest ograniczenie częstotliwości do jednej transakcji na blok. Jeśli celem jest pokazywanie powitań kontom zewnętrznym (externally owned accounts), i tak nie ma znaczenia, jakie jest powitanie w środku bloku.

Innym rozwiązaniem jest śledzenie adresów i zezwalanie na podpisy tylko od zweryfikowanych klientów.

Podpisy dla niewłaściwego powitania

Kiedy klikniesz Signature for wrong greeting, przesyłasz ważny podpis dla określonego adresu (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) i powitania (Hello). Ale przesyła go z innym powitaniem. To dezorientuje ecrecover, co zmienia powitanie, ale ma niewłaściwy adres.

Aby rozwiązać ten problem, dodaj adres do podpisanej struktury (opens in a new tab). W ten sposób losowy adres z ecrecover nie będzie pasował do adresu w podpisie, a inteligentny kontrakt odrzuci wiadomość.

Ataki typu replay (powtórzenia)

Kiedy klikniesz Replay attack, przesyłasz ten sam podpis „Jestem 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e i chciałbym, aby powitanie brzmiało Hello”, ale z poprawnym powitaniem. W rezultacie inteligentny kontrakt uważa, że adres (który nie jest Twój) zmienił powitanie z powrotem na Hello. Informacje potrzebne do zrobienia tego są publicznie dostępne w informacjach o transakcji (opens in a new tab).

Jeśli stanowi to problem, jednym z rozwiązań jest dodanie nonce (opens in a new tab). Utwórz mapowanie (opens in a new tab) między adresami a liczbami i dodaj pole nonce do podpisu. Jeśli pole nonce pasuje do mapowania dla danego adresu, zaakceptuj podpis i zwiększ wartość w mapowaniu na następny raz. Jeśli nie, odrzuć transakcję.

Innym rozwiązaniem jest dodanie znacznika czasu (timestamp) do podpisanych danych i akceptowanie podpisu jako ważnego tylko przez kilka sekund po tym znaczniku czasu. Jest to prostsze i tańsze, ale ryzykujemy atakami typu replay w tym oknie czasowym oraz niepowodzeniem legalnych transakcji, jeśli okno czasowe zostanie przekroczone.

Inne brakujące funkcje

Istnieją dodatkowe funkcje, które dodalibyśmy w środowisku produkcyjnym.

Dostęp z innych serwerów

Obecnie pozwalamy dowolnemu adresowi na przesłanie sponsorSetGreeting. Może to być dokładnie to, czego chcemy w interesie decentralizacji. A może chcemy mieć pewność, że sponsorowane transakcje przechodzą przez nasz serwer, w którym to przypadku sprawdzalibyśmy msg.sender w inteligentnym kontrakcie.

Tak czy inaczej, powinna to być świadoma decyzja projektowa, a nie tylko wynik nieprzemyślenia tej kwestii.

Obsługa błędów

Użytkownik przesyła powitanie. Może zostanie ono zaktualizowane w następnym bloku. A może nie. Błędy są niewidoczne. W systemie produkcyjnym użytkownik powinien być w stanie rozróżnić te przypadki:

  • Nowe powitanie nie zostało jeszcze przesłane
  • Nowe powitanie zostało przesłane i jest w trakcie przetwarzania
  • Nowe powitanie zostało odrzucone

Podsumowanie

W tym momencie powinieneś być w stanie stworzyć doświadczenie bez gazu dla użytkowników Twojej zdecentralizowanej aplikacji (dapp), kosztem pewnej centralizacji.

Jednak działa to tylko z inteligentnymi kontraktami, które obsługują ERC-712. Aby na przykład przetransferować token ERC-20, konieczne jest, aby transakcja została podpisana przez właściciela, a nie tylko wiadomość. Rozwiązaniem jest abstrakcja konta (ERC-4337) (opens in a new tab). Mam nadzieję napisać o tym przyszły samouczek.

Zobacz tutaj więcej moich prac (opens in a new tab).

Ostatnia aktualizacja strony: 3 marca 2026

Czy ten samouczek był pomocny?