Mensponsori biaya gas: Cara menanggung biaya transaksi untuk pengguna Anda
Pengantar
Jika kita ingin Ethereum melayani satu miliar orang lagi (opens in a new tab), kita perlu menghilangkan hambatan dan membuatnya semudah mungkin untuk digunakan. Salah satu sumber hambatan ini adalah kebutuhan akan ETH untuk membayar biaya gas.
Jika Anda memiliki dapp yang menghasilkan uang dari pengguna, mungkin masuk akal untuk membiarkan pengguna mengirimkan transaksi melalui server Anda dan Anda sendiri yang membayar biaya transaksinya. Karena pengguna masih menandatangani pesan otorisasi EIP-712 (opens in a new tab) di dompet mereka, mereka mempertahankan jaminan integritas Ethereum. Ketersediaan bergantung pada server yang meneruskan transaksi, sehingga lebih terbatas. Namun, Anda dapat mengatur agar pengguna juga dapat mengakses kontrak pintar secara langsung (jika mereka mendapatkan ETH), dan membiarkan orang lain menyiapkan server mereka sendiri jika mereka ingin mensponsori transaksi.
Teknik dalam tutorial ini hanya berfungsi ketika Anda mengontrol kontrak pintar. Ada teknik lain, termasuk abstraksi akun (opens in a new tab) yang memungkinkan Anda mensponsori transaksi ke kontrak pintar lain, yang saya harap dapat dibahas dalam tutorial mendatang.
Catatan: Ini bukan kode tingkat produksi. Kode ini rentan terhadap serangan signifikan dan tidak memiliki fitur-fitur utama. Pelajari lebih lanjut di bagian kerentanan dari panduan ini.
Prasyarat
Untuk memahami tutorial ini, Anda harus sudah familier dengan:
- Solidity
- JavaScript
- React dan WAGMI. Jika Anda tidak familier dengan alat antarmuka pengguna ini, kami memiliki tutorial untuk itu.
Aplikasi sampel
Aplikasi sampel di sini adalah varian dari kontrak Greeter milik Hardhat. Anda dapat melihatnya di GitHub (opens in a new tab). Kontrak pintar ini sudah diterapkan di Sepolia (opens in a new tab), pada alamat 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
Untuk melihatnya beraksi, ikuti langkah-langkah berikut.
-
Klon repositori dan instal perangkat lunak yang diperlukan.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install
1
22. Edit `.env` untuk mengatur `PRIVATE_KEY` ke dompet yang memiliki ETH di Sepolia. Jika Anda membutuhkan ETH Sepolia, [gunakan faucet](/developers/docs/networks/#sepolia). Idealnya, kunci pribadi ini harus berbeda dari yang Anda miliki di dompet peramban Anda.3
43. Mulai server.5
6 ```sh7 npm run dev-
Buka aplikasi pada URL
http://localhost:5173(opens in a new tab). -
Klik Connect with Injected untuk terhubung ke dompet. Setujui di dompet, dan setujui perubahan ke Sepolia jika perlu.
-
Tulis sapaan baru dan klik Update greeting via sponsor.
-
Tandatangani pesan.
-
Tunggu sekitar 12 detik (waktu blok di Sepolia). Sambil menunggu, Anda dapat melihat URL di konsol server untuk melihat transaksi.
-
Lihat bahwa sapaan telah berubah, dan nilai alamat yang terakhir memperbarui sekarang adalah alamat dompet peramban Anda.
Untuk memahami cara kerjanya, kita perlu melihat bagaimana pesan dibuat di antarmuka pengguna, bagaimana pesan diteruskan oleh server, dan bagaimana kontrak pintar memprosesnya.
Antarmuka pengguna
Antarmuka pengguna didasarkan pada WAGMI (opens in a new tab); Anda dapat membacanya di tutorial ini.
Berikut adalah cara kita menandatangani pesan:
1const signGreeting = useCallback(Hook React useCallback (opens in a new tab) memungkinkan kita meningkatkan kinerja dengan menggunakan kembali fungsi yang sama saat komponen digambar ulang.
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")Jika tidak ada akun, munculkan kesalahan. Ini seharusnya tidak pernah terjadi karena tombol UI yang memulai proses yang memanggil signGreeting dinonaktifkan dalam kasus tersebut. Namun, pemrogram di masa mendatang mungkin menghapus pengamanan tersebut, jadi ada baiknya untuk memeriksa kondisi ini di sini juga.
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }Parameter untuk pemisah domain (opens in a new tab). Nilai ini konstan, jadi dalam implementasi yang lebih dioptimalkan, kita mungkin menghitungnya sekali daripada menghitungnya kembali setiap kali fungsi dipanggil.
nameadalah nama yang dapat dibaca pengguna, seperti nama dapp yang tanda tangannya sedang kita buat.versionadalah versinya. Versi yang berbeda tidak kompatibel.chainIdadalah rantai yang kita gunakan, seperti yang disediakan oleh WAGMI (opens in a new tab).verifyingContractadalah alamat kontrak yang akan memverifikasi tanda tangan ini. Kita tidak ingin tanda tangan yang sama berlaku untuk beberapa kontrak, seandainya ada beberapa kontrakGreeterdan kita ingin mereka memiliki sapaan yang berbeda.
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }Tipe data yang kita tandatangani. Di sini, kita memiliki parameter tunggal, greeting, tetapi sistem di kehidupan nyata biasanya memiliki lebih banyak.
1 const message = { greeting }Pesan sebenarnya yang ingin kita tandatangani dan kirim. greeting adalah nama bidang sekaligus nama variabel yang mengisinya.
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })Benar-benar mendapatkan tanda tangan. Fungsi ini asinkron karena pengguna membutuhkan waktu lama (dari sudut pandang komputer) untuk menandatangani data.
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 },Fungsi ini mengembalikan nilai heksadesimal tunggal. Di sini kita membaginya menjadi beberapa bidang.
1 [account, chainId, contractAddr, signTypedDataAsync],2)Jika salah satu dari variabel ini berubah, buat instans baru dari fungsi tersebut. Parameter account dan chainId dapat diubah oleh pengguna di dompet. contractAddr adalah fungsi dari Id rantai. signTypedDataAsync seharusnya tidak berubah, tetapi kita mengimpornya dari sebuah hook (opens in a new tab), jadi kita tidak bisa memastikannya, dan sebaiknya menambahkannya di sini.
Sekarang setelah sapaan baru ditandatangani, kita perlu mengirimkannya ke server.
1 const sponsoredGreeting = async () => {2 try {Fungsi ini mengambil tanda tangan dan mengirimkannya ke server.
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {Kirim ke jalur /server/sponsor di server asal kita.
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })Gunakan POST untuk mengirim informasi yang dienkode JSON.
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }Keluarkan respons. Pada sistem produksi, kita juga akan menampilkan respons kepada pengguna.
Server
Saya suka menggunakan Vite (opens in a new tab) sebagai front-end saya. Ini secara otomatis menyajikan pustaka React dan memperbarui peramban saat kode front-end berubah. Namun, Vite tidak menyertakan perkakas backend.
Solusinya ada di index.js (opens in a new tab).
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // Let Vite handle everything else // Biarkan Vite menangani semua hal lainnya6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)Pertama kita mendaftarkan penangan untuk permintaan yang kita tangani sendiri (POST ke /server/sponsor). Kemudian kita membuat dan menggunakan server Vite untuk menangani semua URL lainnya.
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 })Ini hanyalah panggilan blockchain viem (opens in a new tab) standar.
Kontrak pintar
Terakhir, Greeter.sol (opens in a new tab) perlu memverifikasi tanda tangan.
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 membuat pemisah domain (opens in a new tab), mirip dengan kode antarmuka pengguna di atas. Eksekusi blockchain jauh lebih mahal, jadi kita hanya menghitungnya sekali.
1 struct GreetingRequest {2 string greeting;3 }Ini adalah struktur yang ditandatangani. Di sini kita hanya memiliki satu bidang.
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");Ini adalah pengidentifikasi struktur (opens in a new tab). Ini dihitung setiap kali di antarmuka pengguna.
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {Fungsi ini menerima permintaan yang ditandatangani dan memperbarui sapaan.
1 // Compute EIP-712 digest // Hitung digest 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 );Buat intisari sesuai dengan EIP 712 (opens in a new tab).
1 // Recover signer // Pulihkan penandatangan2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");Gunakan ecrecover (opens in a new tab) untuk mendapatkan alamat penandatangan. Perhatikan bahwa tanda tangan yang buruk masih dapat menghasilkan alamat yang valid, hanya saja alamatnya acak.
1 // Apply greeting as if signer called it // Terapkan sapaan seolah-olah penandatangan yang memanggilnya2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }Perbarui sapaan.
Kerentanan
Ini bukan kode tingkat produksi. Kode ini rentan terhadap serangan signifikan dan tidak memiliki fitur-fitur utama. Berikut adalah beberapa di antaranya, beserta cara menyelesaikannya.
Untuk melihat beberapa serangan ini, klik tombol di bawah judul Attacks dan lihat apa yang terjadi. Untuk tombol Invalid signature, periksa konsol server untuk melihat respons transaksi.
Penolakan layanan di server
Serangan termudah adalah serangan penolakan layanan (denial-of-service) (opens in a new tab) di server. Server menerima permintaan dari mana saja di Internet dan berdasarkan permintaan tersebut mengirimkan transaksi. Sama sekali tidak ada yang mencegah penyerang mengeluarkan banyak tanda tangan, baik yang valid maupun tidak valid. Masing-masing akan menyebabkan transaksi. Pada akhirnya server akan kehabisan ETH untuk membayar gas.
Salah satu solusi untuk masalah ini adalah membatasi laju menjadi satu transaksi per blok. Jika tujuannya adalah untuk menampilkan sapaan ke akun yang dimiliki secara eksternal, tidak masalah apa sapaannya di tengah blok.
Solusi lain adalah melacak alamat dan hanya mengizinkan tanda tangan dari pelanggan yang valid.
Tanda tangan sapaan yang salah
Saat Anda mengeklik Signature for wrong greeting, Anda mengirimkan tanda tangan yang valid untuk alamat tertentu (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) dan sapaan (Hello). Namun, ini mengirimkannya dengan sapaan yang berbeda. Hal ini membingungkan ecrecover, yang mengubah sapaan tetapi memiliki alamat yang salah.
Untuk menyelesaikan masalah ini, tambahkan alamat ke struktur yang ditandatangani (opens in a new tab). Dengan cara ini, alamat acak ecrecover tidak akan cocok dengan alamat di tanda tangan, dan kontrak pintar akan menolak pesan tersebut.
Serangan pemutaran ulang
Saat Anda mengeklik Replay attack, Anda mengirimkan tanda tangan "Saya 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, dan saya ingin sapaannya menjadi Hello" yang sama, tetapi dengan sapaan yang benar. Akibatnya, kontrak pintar percaya bahwa alamat tersebut (yang bukan milik Anda) mengubah sapaan kembali menjadi Hello. Informasi untuk melakukan ini tersedia untuk umum di informasi transaksi (opens in a new tab).
Jika ini menjadi masalah, salah satu solusinya adalah menambahkan nonce (opens in a new tab). Buat pemetaan (opens in a new tab) antara alamat dan angka, dan tambahkan bidang nonce ke tanda tangan. Jika bidang nonce cocok dengan pemetaan untuk alamat tersebut, terima tanda tangan dan tingkatkan pemetaan untuk waktu berikutnya. Jika tidak, tolak transaksi.
Solusi lain adalah menambahkan stempel waktu ke data yang ditandatangani dan menerima tanda tangan sebagai valid hanya selama beberapa detik setelah stempel waktu tersebut. Ini lebih sederhana dan lebih murah, tetapi kita berisiko mengalami serangan pemutaran ulang dalam jendela waktu tersebut, dan kegagalan transaksi yang sah jika jendela waktu terlampaui.
Fitur lain yang hilang
Ada fitur tambahan yang akan kita tambahkan dalam pengaturan produksi.
Akses dari server lain
Saat ini, kita mengizinkan alamat mana pun untuk mengirimkan sponsorSetGreeting. Ini mungkin persis seperti yang kita inginkan, demi kepentingan desentralisasi. Atau mungkin kita ingin memastikan bahwa transaksi yang disponsori melalui server kita, dalam hal ini kita akan memeriksa msg.sender di kontrak pintar.
Bagaimanapun, ini harus menjadi keputusan desain yang sadar, bukan hanya hasil dari tidak memikirkan masalah tersebut.
Penanganan kesalahan
Seorang pengguna mengirimkan sapaan. Mungkin itu diperbarui di blok berikutnya. Mungkin juga tidak. Kesalahan tidak terlihat. Pada sistem produksi, pengguna harus dapat membedakan antara kasus-kasus ini:
- Sapaan baru belum dikirimkan
- Sapaan baru telah dikirimkan, dan sedang diproses
- Sapaan baru telah ditolak
Kesimpulan
Pada titik ini, Anda seharusnya dapat menciptakan pengalaman tanpa gas untuk pengguna dapp Anda, dengan mengorbankan sedikit sentralisasi.
Namun, ini hanya berfungsi dengan kontrak pintar yang mendukung ERC-712. Untuk mentransfer token ERC-20, misalnya, transaksi perlu ditandatangani oleh pemiliknya, bukan hanya pesan. Solusinya adalah abstraksi akun (ERC-4337) (opens in a new tab). Saya berharap dapat menulis tutorial di masa mendatang tentang hal ini.
Lihat di sini untuk karya saya yang lain (opens in a new tab).
Pembaruan terakhir halaman: 3 Maret 2026