Sponsoriser les frais de gaz : Comment couvrir les coûts de transaction pour vos utilisateurs
Introduction
Si nous voulons qu'Ethereum serve un milliard de personnes supplémentaires (opens in a new tab), nous devons éliminer les frictions et le rendre aussi facile à utiliser que possible. L'une des sources de cette friction est la nécessité d'avoir de l'ETH pour payer les frais de gaz.
Si vous avez une application décentralisée (dapp) qui génère des revenus grâce aux utilisateurs, il pourrait être judicieux de laisser les utilisateurs soumettre des transactions via votre serveur et de payer vous-même les frais de transaction. Étant donné que les utilisateurs signent toujours un message d'autorisation EIP-712 (opens in a new tab) dans leurs portefeuilles, ils conservent les garanties d'intégrité d'Ethereum. La disponibilité dépend du serveur qui relaie les transactions, elle est donc plus limitée. Cependant, vous pouvez configurer les choses de manière à ce que les utilisateurs puissent également accéder directement au contrat intelligent (s'ils obtiennent de l'ETH), et laisser d'autres personnes configurer leurs propres serveurs s'ils souhaitent sponsoriser des transactions.
La technique présentée dans ce tutoriel ne fonctionne que lorsque vous contrôlez le contrat intelligent. Il existe d'autres techniques, notamment l'abstraction de compte (opens in a new tab), qui vous permettent de sponsoriser des transactions vers d'autres contrats intelligents, que j'espère aborder dans un futur tutoriel.
Remarque : Il ne s'agit pas d'un code de niveau production. Il est vulnérable à des attaques importantes et manque de fonctionnalités majeures. Apprenez-en davantage dans la section sur les vulnérabilités de ce guide.
Prérequis
Pour comprendre ce tutoriel, vous devez déjà être familier avec :
- Solidity
- JavaScript
- React et WAGMI. Si vous n'êtes pas familier avec ces outils d'interface utilisateur, nous avons un tutoriel pour cela.
L'application d'exemple
L'application d'exemple ici est une variante du contrat Greeter de Hardhat. Vous pouvez la voir sur GitHub (opens in a new tab). Le contrat intelligent est déjà déployé sur Sepolia (opens in a new tab), à l'adresse 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
Pour la voir en action, suivez ces étapes.
-
Clonez le dépôt et installez les logiciels nécessaires.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
Modifiez
.envpour définirPRIVATE_KEYsur un portefeuille qui possède de l'ETH sur Sepolia. Si vous avez besoin d'ETH Sepolia, utilisez un faucet. Idéalement, cette clé privée devrait être différente de celle que vous avez dans le portefeuille de votre navigateur. -
Démarrez le serveur.
1npm run dev -
Accédez à l'application à l'URL
http://localhost:5173(opens in a new tab). -
Cliquez sur Connect with Injected pour vous connecter à un portefeuille. Approuvez dans le portefeuille, et approuvez le changement vers Sepolia si nécessaire.
-
Écrivez un nouveau message d'accueil et cliquez sur Update greeting via sponsor.
-
Signez le message.
-
Attendez environ 12 secondes (le temps de bloc sur Sepolia). En attendant, vous pouvez regarder l'URL dans la console du serveur pour voir la transaction.
-
Constatez que le message d'accueil a changé, et que la valeur de l'adresse de la dernière mise à jour est maintenant l'adresse du portefeuille de votre navigateur.
Pour comprendre comment cela fonctionne, nous devons examiner comment le message est créé dans l'interface utilisateur, comment il est relayé par le serveur, et comment le contrat intelligent le traite.
L'interface utilisateur
L'interface utilisateur est basée sur WAGMI (opens in a new tab) ; vous pouvez en apprendre davantage à ce sujet dans ce tutoriel.
Voici comment nous signons le message :
1const signGreeting = useCallback(Le hook React useCallback (opens in a new tab) nous permet d'améliorer les performances en réutilisant la même fonction lorsque le composant est redessiné.
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")S'il n'y a pas de compte, déclenchez une erreur. Cela ne devrait jamais se produire car le bouton de l'interface utilisateur qui lance le processus appelant signGreeting est désactivé dans ce cas. Cependant, de futurs programmeurs pourraient supprimer cette sécurité, il est donc judicieux de vérifier cette condition ici également.
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }Paramètres pour le séparateur de domaine (opens in a new tab). Cette valeur est constante, donc dans une implémentation mieux optimisée, nous pourrions la calculer une seule fois plutôt que de la recalculer à chaque appel de la fonction.
nameest un nom lisible par l'utilisateur, tel que le nom de la dapp pour laquelle nous produisons des signatures.versionest la version. Les différentes versions ne sont pas compatibles.chainIdest la chaîne que nous utilisons, telle que fournie par WAGMI (opens in a new tab).verifyingContractest l'adresse du contrat qui vérifiera cette signature. Nous ne voulons pas que la même signature s'applique à plusieurs contrats, au cas où il y aurait plusieurs contratsGreeteret que nous voudrions qu'ils aient des messages d'accueil différents.
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }Le type de données que nous signons. Ici, nous avons un seul paramètre, greeting, mais les systèmes réels en ont généralement davantage.
1 const message = { greeting }Le message réel que nous voulons signer et envoyer. greeting est à la fois le nom du champ et le nom de la variable qui le remplit.
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })Obtenir réellement la signature. Cette fonction est asynchrone car les utilisateurs mettent beaucoup de temps (du point de vue d'un ordinateur) à signer des données.
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 fonction renvoie une seule valeur hexadécimale. Ici, nous la divisons en champs.
1 [account, chainId, contractAddr, signTypedDataAsync],2)Si l'une de ces variables change, créez une nouvelle instance de la fonction. Les paramètres account et chainId peuvent être modifiés par l'utilisateur dans le portefeuille. contractAddr est une fonction de l'ID de la chaîne. signTypedDataAsync ne devrait pas changer, mais nous l'importons depuis un hook (opens in a new tab), nous ne pouvons donc pas en être sûrs, et il est préférable de l'ajouter ici.
Maintenant que le nouveau message d'accueil est signé, nous devons l'envoyer au serveur.
1 const sponsoredGreeting = async () => {2 try {Cette fonction prend une signature et l'envoie au serveur.
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {Envoyer au chemin /server/sponsor sur le serveur d'où nous venons.
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })Utilisez POST pour envoyer les informations encodées en JSON.
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }Affichez la réponse. Sur un système en production, nous montrerions également la réponse à l'utilisateur.
Le serveur
J'aime utiliser Vite (opens in a new tab) pour mon front-end. Il sert automatiquement les bibliothèques React et met à jour le navigateur lorsque le code front-end change. Cependant, Vite n'inclut pas d'outils back-end.
La solution se trouve dans index.js (opens in a new tab).
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // Laisser Vite gérer tout le reste6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)D'abord, nous enregistrons un gestionnaire pour les requêtes que nous traitons nous-mêmes (POST vers /server/sponsor). Ensuite, nous créons et utilisons un serveur Vite pour gérer toutes les autres 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 })Il s'agit simplement d'un appel standard à la chaîne de blocs avec viem (opens in a new tab).
Le contrat intelligent
Enfin, Greeter.sol (opens in a new tab) doit vérifier la signature.
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 }Le constructeur crée le séparateur de domaine (opens in a new tab), de manière similaire au code de l'interface utilisateur ci-dessus. L'exécution sur la chaîne de blocs est beaucoup plus coûteuse, nous ne le calculons donc qu'une seule fois.
1 struct GreetingRequest {2 string greeting;3 }C'est la structure qui est signée. Ici, nous n'avons qu'un seul champ.
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");Il s'agit de l'identifiant de structure (opens in a new tab). Il est calculé à chaque fois dans l'interface utilisateur.
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {Cette fonction reçoit une requête signée et met à jour le message d'accueil.
1 // Calculer le condensé 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 );Créez le condensat (digest) conformément à l'EIP 712 (opens in a new tab).
1 // Récupérer le signataire2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");Utilisez ecrecover (opens in a new tab) pour obtenir l'adresse du signataire. Notez qu'une mauvaise signature peut tout de même aboutir à une adresse valide, mais simplement aléatoire.
1 // Appliquer la salutation comme si le signataire l'avait appelée2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }Mettez à jour le message d'accueil.
Vulnérabilités
Il ne s'agit pas d'un code de niveau production. Il est vulnérable à des attaques importantes et manque de fonctionnalités majeures. En voici quelques-unes, ainsi que la manière de les résoudre.
Pour voir certaines de ces attaques, cliquez sur les boutons sous l'en-tête Attacks et observez ce qui se passe. Pour le bouton Invalid signature, vérifiez la console du serveur pour voir la réponse de la transaction.
Déni de service sur le serveur
L'attaque la plus simple est une attaque par déni de service (opens in a new tab) sur le serveur. Le serveur reçoit des requêtes de n'importe où sur Internet et, sur la base de ces requêtes, envoie des transactions. Il n'y a absolument rien qui empêche un attaquant d'émettre un tas de signatures, valides ou invalides. Chacune provoquera une transaction. Finalement, le serveur manquera d'ETH pour payer le gaz.
Une solution à ce problème est de limiter le taux à une transaction par bloc. Si le but est de montrer des messages d'accueil à des comptes détenus par des entités externes, peu importe quel est le message d'accueil au milieu du bloc de toute façon.
Une autre solution consiste à garder une trace des adresses et à n'autoriser que les signatures de clients valides.
Signatures de message d'accueil erronées
Lorsque vous cliquez sur Signature for wrong greeting, vous soumettez une signature valide pour une adresse spécifique (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) et un message d'accueil (Hello). Mais il la soumet avec un message d'accueil différent. Cela perturbe ecrecover, qui modifie le message d'accueil mais avec la mauvaise adresse.
Pour résoudre ce problème, ajoutez l'adresse à la structure signée (opens in a new tab). De cette façon, l'adresse aléatoire de ecrecover ne correspondra pas à l'adresse dans la signature, et le contrat intelligent rejettera le message.
Attaques par rejeu
Lorsque vous cliquez sur Replay attack, vous soumettez la même signature « Je suis 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, et j'aimerais que le message d'accueil soit Hello », mais avec le bon message d'accueil. En conséquence, le contrat intelligent croit que l'adresse (qui n'est pas la vôtre) a rétabli le message d'accueil à Hello. Les informations pour ce faire sont publiquement disponibles dans les informations de la transaction (opens in a new tab).
Si cela pose problème, une solution consiste à ajouter un nonce (opens in a new tab). Ayez un mapping (opens in a new tab) entre les adresses et les nombres, et ajoutez un champ nonce à la signature. Si le champ nonce correspond au mapping pour l'adresse, acceptez la signature et incrémentez le mapping pour la prochaine fois. Si ce n'est pas le cas, rejetez la transaction.
Une autre solution consiste à ajouter un horodatage aux données signées et à n'accepter la signature comme valide que pendant quelques secondes après cet horodatage. C'est plus simple et moins cher, mais nous risquons des attaques par rejeu dans la fenêtre de temps, et l'échec de transactions légitimes si la fenêtre de temps est dépassée.
Autres fonctionnalités manquantes
Il y a des fonctionnalités supplémentaires que nous ajouterions dans un environnement de production.
Accès depuis d'autres serveurs
Actuellement, nous permettons à n'importe quelle adresse de soumettre un sponsorSetGreeting. C'est peut-être exactement ce que nous voulons, dans l'intérêt de la décentralisation. Ou peut-être voulons-nous nous assurer que les transactions sponsorisées passent par notre serveur, auquel cas nous vérifierions msg.sender dans le contrat intelligent.
Quoi qu'il en soit, cela devrait être une décision de conception consciente, et non le simple résultat de ne pas avoir réfléchi à la question.
Gestion des erreurs
Un utilisateur soumet un message d'accueil. Peut-être qu'il sera mis à jour au prochain bloc. Peut-être pas. Les erreurs sont invisibles. Sur un système en production, l'utilisateur devrait pouvoir distinguer ces cas :
- Le nouveau message d'accueil n'a pas encore été soumis
- Le nouveau message d'accueil a été soumis, et il est en cours de traitement
- Le nouveau message d'accueil a été rejeté
Conclusion
À ce stade, vous devriez être en mesure de créer une expérience sans gaz pour les utilisateurs de votre dapp, au prix d'une certaine centralisation.
Cependant, cela ne fonctionne qu'avec les contrats intelligents qui prennent en charge l'ERC-712. Pour transférer un jeton ERC-20, par exemple, il est nécessaire que la transaction soit signée par le propriétaire plutôt que par un simple message. La solution est l'abstraction de compte (ERC-4337) (opens in a new tab). J'espère écrire un futur tutoriel à ce sujet.
Voir ici pour plus de mes travaux (opens in a new tab).
Dernière mise à jour de la page : 3 mars 2026