Sponsorizzare le commissioni: Come coprire i costi di transazione per i tuoi utenti
Introduzione
Se vogliamo che Ethereum serva un miliardo di persone in più (opens in a new tab), dobbiamo rimuovere gli attriti e renderlo il più facile possibile da usare. Una fonte di questo attrito è la necessità di ETH per pagare le commissioni.
Se hai una dApp che guadagna dagli utenti, potrebbe avere senso consentire agli utenti di inviare transazioni tramite il tuo server e pagare tu stesso le commissioni della transazione. Poiché gli utenti firmano comunque un messaggio di autorizzazione EIP-712 (opens in a new tab) nei loro portafogli, mantengono le garanzie di integrità di Ethereum. La disponibilità dipende dal server che trasmette le transazioni, quindi è più limitata. Tuttavia, puoi configurare le cose in modo che gli utenti possano anche accedere direttamente al contratto intelligente (se ottengono ETH) e consentire ad altri di configurare i propri server se desiderano sponsorizzare le transazioni.
La tecnica in questo tutorial funziona solo quando controlli il contratto intelligente. Ci sono altre tecniche, inclusa l'astrazione dell'account (opens in a new tab) che ti permettono di sponsorizzare transazioni verso altri contratti intelligenti, che spero di trattare in un tutorial futuro.
Nota: Questo non è codice a livello di produzione. È vulnerabile ad attacchi significativi e manca di funzionalità importanti. Scopri di più nella sezione sulle vulnerabilità di questa guida.
Prerequisiti
Per comprendere questo tutorial devi avere già familiarità con:
- Solidity
- JavaScript
- React e WAGMI. Se non hai familiarità con questi strumenti per l'interfaccia utente, abbiamo un tutorial a riguardo.
L'applicazione di esempio
L'applicazione di esempio qui è una variante del contratto Greeter di Hardhat. Puoi vederla su GitHub (opens in a new tab). Il contratto intelligente è già distribuito su Sepolia (opens in a new tab), all'indirizzo 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
Per vederla in azione, segui questi passaggi.
-
Clona il repository e installa il software necessario.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install
1
22. Modifica `.env` per impostare `PRIVATE_KEY` su un portafoglio che ha ETH su Sepolia. Se hai bisogno di ETH su Sepolia, [usa un rubinetto](/developers/docs/networks/#sepolia). Idealmente, questa chiave privata dovrebbe essere diversa da quella che hai nel portafoglio del tuo browser.3
43. Avvia il server.5
6 ```sh7 npm run dev-
Naviga verso l'applicazione all'URL
http://localhost:5173(opens in a new tab). -
Fai clic su Connect with Injected per connetterti a un portafoglio. Approva nel portafoglio e approva il passaggio a Sepolia se necessario.
-
Scrivi un nuovo saluto e fai clic su Update greeting via sponsor.
-
Firma il messaggio.
-
Attendi circa 12 secondi (il tempo di blocco su Sepolia). Durante l'attesa puoi guardare l'URL nella console del server per vedere la transazione.
-
Verifica che il saluto sia cambiato e che il valore dell'indirizzo dell'ultimo aggiornamento sia ora l'indirizzo del portafoglio del tuo browser.
Per capire come funziona, dobbiamo esaminare come il messaggio viene creato nell'interfaccia utente, come viene trasmesso dal server e come il contratto intelligente lo elabora.
L'interfaccia utente
L'interfaccia utente è basata su WAGMI (opens in a new tab); puoi leggerne a riguardo in questo tutorial.
Ecco come firmiamo il messaggio:
1const signGreeting = useCallback(L'hook di React useCallback (opens in a new tab) ci permette di migliorare le prestazioni riutilizzando la stessa funzione quando il componente viene ridisegnato.
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")Se non c'è alcun account, solleva un errore. Questo non dovrebbe mai accadere perché il pulsante dell'interfaccia utente che avvia il processo che chiama signGreeting è disabilitato in quel caso. Tuttavia, i programmatori futuri potrebbero rimuovere quella salvaguardia, quindi è una buona idea controllare questa condizione anche qui.
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }Parametri per il separatore di dominio (opens in a new tab). Questo valore è costante, quindi in un'implementazione meglio ottimizzata, potremmo calcolarlo una volta anziché ricalcolarlo ogni volta che la funzione viene chiamata.
nameè un nome leggibile dall'utente, come il nome della dApp per cui stiamo producendo le firme.versionè la versione. Versioni diverse non sono compatibili.chainIdè la catena che stiamo utilizzando, come fornito da WAGMI (opens in a new tab).verifyingContractè l'indirizzo del contratto che verificherà questa firma. Non vogliamo che la stessa firma si applichi a più contratti, nel caso in cui ci siano diversi contrattiGreetere vogliamo che abbiano saluti diversi.
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }Il tipo di dati che firmiamo. Qui abbiamo un singolo parametro, greeting, ma i sistemi reali in genere ne hanno di più.
1 const message = { greeting }Il messaggio effettivo che vogliamo firmare e inviare. greeting è sia il nome del campo che il nome della variabile che lo riempie.
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })Ottieni effettivamente la firma. Questa funzione è asincrona perché gli utenti impiegano molto tempo (dal punto di vista di un computer) per firmare i dati.
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 },La funzione restituisce un singolo valore esadecimale. Qui lo dividiamo in campi.
1 [account, chainId, contractAddr, signTypedDataAsync],2)Se una qualsiasi di queste variabili cambia, crea una nuova istanza della funzione. I parametri account e chainId possono essere modificati dall'utente nel portafoglio. contractAddr è una funzione dell'Id della catena. signTypedDataAsync non dovrebbe cambiare, ma lo importiamo da un hook (opens in a new tab), quindi non possiamo esserne sicuri, ed è meglio aggiungerlo qui.
Ora che il nuovo saluto è firmato, dobbiamo inviarlo al server.
1 const sponsoredGreeting = async () => {2 try {Questa funzione prende una firma e la invia al server.
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {Invia al percorso /server/sponsor nel server da cui proveniamo.
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })Usa POST per inviare le informazioni codificate in JSON.
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }Emetti la risposta. Su un sistema di produzione mostreremmo anche la risposta all'utente.
Il server
Mi piace usare Vite (opens in a new tab) come mio front-end. Serve automaticamente le librerie React e aggiorna il browser quando il codice front-end cambia. Tuttavia, Vite non include strumenti di backend.
La soluzione è in index.js (opens in a new tab).
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // Lascia che Vite gestisca tutto il resto6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)Per prima cosa registriamo un gestore per le richieste che gestiamo noi stessi (POST a /server/sponsor). Quindi creiamo e usiamo un server Vite per gestire tutti gli altri 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 })Questa è solo una chiamata standard alla blockchain con viem (opens in a new tab).
Il contratto intelligente
Infine, Greeter.sol (opens in a new tab) deve verificare la firma.
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 }Il costruttore crea il separatore di dominio (opens in a new tab), in modo simile al codice dell'interfaccia utente sopra. L'esecuzione sulla blockchain è molto più costosa, quindi lo calcoliamo solo una volta.
1 struct GreetingRequest {2 string greeting;3 }Questa è la struttura che viene firmata. Qui abbiamo un solo campo.
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");Questo è l'identificatore della struttura (opens in a new tab). Viene calcolato ogni volta nell'interfaccia utente.
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {Questa funzione riceve una richiesta firmata e aggiorna il saluto.
1 // Calcola il 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 );Crea il digest in conformità con l'EIP 712 (opens in a new tab).
1 // Recupera il firmatario2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");Usa ecrecover (opens in a new tab) per ottenere l'indirizzo del firmatario. Nota che una firma errata può comunque produrre un indirizzo valido, solo che sarà casuale.
1 // Applica il saluto come se il firmatario lo avesse chiamato2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }Aggiorna il saluto.
Vulnerabilità
Questo non è codice a livello di produzione. È vulnerabile ad attacchi significativi e manca di funzionalità importanti. Eccone alcune, insieme a come risolverle.
Per vedere alcuni di questi attacchi, fai clic sui pulsanti sotto l'intestazione Attacks e guarda cosa succede. Per il pulsante Invalid signature, controlla la console del server per vedere la risposta della transazione.
Denial of service sul server
L'attacco più semplice è un attacco denial-of-service (opens in a new tab) sul server. Il server riceve richieste da qualsiasi parte di Internet e, in base a tali richieste, invia transazioni. Non c'è assolutamente nulla che impedisca a un utente malintenzionato di emettere un mucchio di firme, valide o non valide. Ognuna causerà una transazione. Alla fine il server esaurirà gli ETH per pagare il gas.
Una soluzione a questo problema è limitare la frequenza a una transazione per blocco. Se lo scopo è mostrare i saluti agli account controllati esternamente, non importa comunque quale sia il saluto nel mezzo del blocco.
Un'altra soluzione è tenere traccia degli indirizzi e consentire solo le firme da clienti validi.
Firme di saluto errate
Quando fai clic su Signature for wrong greeting, invii una firma valida per un indirizzo specifico (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) e un saluto (Hello). Ma lo invia con un saluto diverso. Questo confonde ecrecover, che cambia il saluto ma ha l'indirizzo sbagliato.
Per risolvere questo problema, aggiungi l'indirizzo alla struttura firmata (opens in a new tab). In questo modo, l'indirizzo casuale di ecrecover non corrisponderà all'indirizzo nella firma e il contratto intelligente rifiuterà il messaggio.
Attacchi di replay
Quando fai clic su Replay attack, invii la stessa firma "Sono 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e e vorrei che il saluto fosse Hello", ma con il saluto corretto. Di conseguenza, il contratto intelligente crede che l'indirizzo (che non è il tuo) abbia riportato il saluto a Hello. Le informazioni per farlo sono pubblicamente disponibili nelle informazioni della transazione (opens in a new tab).
Se questo è un problema, una soluzione è aggiungere un nonce (opens in a new tab). Crea una mappatura (opens in a new tab) tra indirizzi e numeri e aggiungi un campo nonce alla firma. Se il campo nonce corrisponde alla mappatura per l'indirizzo, accetta la firma e incrementa la mappatura per la volta successiva. In caso contrario, rifiuta la transazione.
Un'altra soluzione è aggiungere un timestamp ai dati firmati e accettare la firma come valida solo per pochi secondi dopo quel timestamp. Questo è più semplice ed economico, ma rischiamo attacchi di replay all'interno della finestra temporale e il fallimento di transazioni legittime se la finestra temporale viene superata.
Altre funzionalità mancanti
Ci sono funzionalità aggiuntive che aggiungeremmo in un ambiente di produzione.
Accesso da altri server
Attualmente, consentiamo a qualsiasi indirizzo di inviare un sponsorSetGreeting. Questo potrebbe essere esattamente ciò che vogliamo, nell'interesse della decentralizzazione. O forse vogliamo assicurarci che le transazioni sponsorizzate passino attraverso il nostro server, nel qual caso controlleremmo msg.sender nel contratto intelligente.
In ogni caso, questa dovrebbe essere una decisione di progettazione consapevole, non solo il risultato di non aver pensato al problema.
Gestione degli errori
Un utente invia un saluto. Forse viene aggiornato al blocco successivo. Forse no. Gli errori sono invisibili. Su un sistema di produzione, l'utente dovrebbe essere in grado di distinguere tra questi casi:
- Il nuovo saluto non è stato ancora inviato
- Il nuovo saluto è stato inviato ed è in fase di elaborazione
- Il nuovo saluto è stato rifiutato
Conclusione
A questo punto, dovresti essere in grado di creare un'esperienza senza gas per gli utenti della tua dApp, al costo di una certa centralizzazione.
Tuttavia, questo funziona solo con contratti intelligenti che supportano ERC-712. Per trasferire un token ERC-20, ad esempio, è necessario che la transazione sia firmata dal proprietario anziché solo un messaggio. La soluzione è l'astrazione dell'account (ERC-4337) (opens in a new tab). Spero di scrivere un tutorial futuro a riguardo.
Vedi qui per altri miei lavori (opens in a new tab).
Ultimo aggiornamento della pagina: 3 marzo 2026