Passer au contenu principal

Permettre à vos utilisateurs sans gaz de détenir des jetons et d'appeler des contrats

sans gaz
erc-20
abstraction de compte
Intermédiaire
Ori Pomerantz
1 avril 2026
19 minutes de lecture

Introduction

Un article précédent a abordé l'utilisation d'un accès sans gaz à votre propre application à l'aide des signatures EIP-712, mais cela se limite à vos propres contrats intelligents. En utilisant l'abstraction de compte, nous pouvons créer des portefeuilles de contrats intelligents qui acceptent deux types de transactions et les relaient vers une destination demandée :

  • Les transactions envoyées par un EOA spécifique (ce qui nécessite que cet EOA possède de l'ETH)
  • Les transactions envoyées de n'importe où, mais signées par le même EOA.

De cette façon, nous pouvons fournir un moyen sans gaz pour un compte de détenir des actifs (jetons, etc.) et d'exécuter toutes les fonctions qu'un EOA avec du gaz peut accomplir.

Pourquoi ne pouvons-nous pas simplement relayer la requête ?

Dans la norme ERC-20 et les normes associées, le propriétaire du compte est msg.sender (opens in a new tab), l'adresse qui a appelé le contrat de jeton, qui n'est pas nécessairement l'initiateur de la transaction, tx.origin (opens in a new tab). Ceci est requis pour des raisons de sécurité (opens in a new tab). Cela signifie que si nous relayons des requêtes de transfert de jetons, elles tenteront de transférer des jetons depuis l'adresse du relayeur plutôt que depuis une adresse contrôlée par l'utilisateur.

Il existe une solution qui vous permet d'utiliser l'adresse EOA via l'EIP-7702 (opens in a new tab), mais elle nécessite de signer une délégation potentiellement dangereuse, vous ne pouvez donc l'utiliser que pour déléguer à un contrat intelligent approuvé par le fournisseur du portefeuille. Pour ce tutoriel, je préfère la méthode beaucoup plus simple consistant à créer un contrat intelligent servant de proxy pour l'utilisateur.

Le voir en action

  1. Assurez-vous d'avoir à la fois Node (opens in a new tab) et Foundry (opens in a new tab).

  2. Clonez l'application et installez les logiciels nécessaires.

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. Modifiez .env pour définir SEPOLIA_PRIVATE_KEY sur un portefeuille qui possède de l'ETH sur Sepolia. Si vous avez besoin d'ETH Sepolia, utilisez un faucet pour en obtenir. Idéalement, cette clé privée devrait être différente de celle que vous avez dans le portefeuille de votre navigateur.

  4. Démarrez le serveur.

    npm run dev
    
  5. Accédez à l'application à l'URL http://localhost:5173 (opens in a new tab).

  6. Cliquez sur Connect with Injected pour vous connecter à un portefeuille. Approuvez dans le portefeuille, et approuvez le changement vers Sepolia si nécessaire.

  7. Faites défiler vers le bas et cliquez sur Deploy UserProxy (slow process).

  8. Vous pouvez voir quand le proxy utilisateur est déployé car il y a une adresse à côté de UserProxy access. Si vous avez attendu 24 secondes (2 blocs) et que cela ne s'est toujours pas produit, il se peut qu'il y ait un problème avec la détection des changements.

    Si c'est le cas, allez sur l'explorateur de blocs Sepolia (opens in a new tab) et entrez le hachage de transaction de déploiement que vous voyez dans la sortie du serveur à npm run dev. Cliquez sur le contrat créé pour voir son adresse, puis copiez-la. Collez l'adresse dans le champ Or enter existing proxy address, puis cliquez sur Set proxy address.

  9. Cliquez sur Request more tokens for proxy pour soumettre un appel à la fonction faucet (opens in a new tab) du contrat ERC-20 afin d'obtenir des jetons. Confirmez la signature dans le portefeuille. Bien sûr, les jetons arrivent à l'adresse du proxy, et non à celle de l'utilisateur.

  10. Faites défiler vers le bas et cliquez sur le lien sous Last transaction:. Cela ouvrira le navigateur pour vous montrer la transaction faucet.

  11. Dans amount to transfer, entrez un nombre entre un et mille. Cliquez sur Transfer pour transférer les jetons vers votre propre adresse. Avant de cliquer sur Confirm pour la requête, remarquez que les données signées sont opaques. Les utilisateurs auraient du mal à comprendre ce qu'ils signent. N'oubliez pas que nous en discuterons ci-dessous.

  12. Une fois la transaction confirmée, attendez de voir le changement à la fois dans your balance et proxy balance. Notez que cela prendra également un certain temps, car Sepolia a un temps de bloc de 12 secondes.

Comment ça marche

Pour une expérience sans gaz, nous avons besoin d'une interface utilisateur pour l'utilisateur, d'un serveur pour acheminer les messages de l'interface utilisateur vers la chaîne, et d'un contrat intelligent pour les recevoir et les vérifier.

Le contrat intelligent du portefeuille

Voici le contrat intelligent (opens in a new tab). Son but est de faire tout ce que le véritable propriétaire demande, quel que soit le canal utilisé pour le demander, et d'ignorer tout le reste. Pour ce faire, ses fonctions reçoivent une adresse cible à appeler et les données à utiliser pour l'appeler.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

L'identité du propriétaire et un nonce (opens in a new tab) pour empêcher la répétition des messages. Étant donné que le nonce est une variable public, le compilateur Solidity crée également une fonction de vue, nonce() (opens in a new tab), qui permet au code hors chaîne de lire sa valeur.

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

Les informations requises pour vérifier les signatures EIP-712 (opens in a new tab).

    constructor(address owner_) {
        OWNER = owner_;

Un UserProxy est lié à une seule adresse de propriétaire. C'est nécessaire car il peut posséder des actifs (jetons ERC-20, NFT, etc.). Nous ne voulons pas mélanger les actifs appartenant à différents propriétaires.

Le séparateur de domaine (opens in a new tab). Il ne peut pas être calculé au moment de la compilation, car il dépend de l'ID de la chaîne et de l'adresse du contrat. Cela rend impossible pour un UserProxy d'être trompé par un message préparé pour un autre.

    event CallResult(address target, bytes returnData);

Enregistrer les résultats d'un appel dans le journal.

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

Cette fonction peut être appelée directement par le propriétaire. Si aucun relais n'est disponible, le propriétaire peut toujours accéder aux actifs directement sur la chaîne de blocs (si l'utilisateur possède de l'ETH).

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

Si nous sommes appelés directement par le propriétaire, appelez la cible avec les données d'appel fournies.

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

C'est la fonction principale de UserProxy. Elle obtient target et data, ainsi qu'une signature.

Le condensat inclut également le nonce, mais nous n'avons pas besoin de le recevoir de la transaction ; nous connaissons déjà la bonne valeur. Une signature avec le mauvais nonce sera rejetée.


    // Récupérer le signataire
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

Si la signature est invalide, ecrecover renverra généralement une adresse différente, et elle ne sera pas acceptée.

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

Appeler le contrat que l'utilisateur nous a dit d'appeler, et annuler en cas d'échec.

    emit CallResult(target, returnData);

    nonce++; // Incrémenter le nonce pour empêcher le rejeu

    return returnData;
}

En cas de succès, émettre un événement de journal et incrémenter le nonce.

Ce sont des variantes presque identiques qui vous permettent également de transférer de l'ETH hors du contrat.

Le relayeur

Le relayeur est un composant serveur. Il est écrit en JavaScript ; vous pouvez voir le code source ici (opens in a new tab).

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

Les bibliothèques dont nous avons besoin. Il s'agit d'un serveur Express (opens in a new tab), qui utilise Vite (opens in a new tab) pour servir le code de l'interface utilisateur. Nous utilisons Viem (opens in a new tab) pour communiquer avec la chaîne de blocs, et dotenv (opens in a new tab) pour lire la clé privée de l'adresse qui envoie la transaction.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

C'est un moyen simple de lire le UserProxy compilé. Nous avons besoin de l'ABI pour pouvoir appeler UserProxy, et du code compilé pour pouvoir le déployer pour un utilisateur.

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

Lire le fichier .env, extraire l'adresse, et l'afficher dans la console.

Les clients Viem qui communiquent avec la chaîne de blocs.

const start = async () => {
  const app = express()

Exécuter un serveur Express.

  app.use(express.json())

Indiquer à Express de lire le corps de la requête, et si c'est du JSON, de l'analyser.

  app.post("/server/deploy", async (req, res) => {

C'est le code qui gère les requêtes de déploiement du proxy. Notez que nous sommes vulnérables aux attaques par déni de service (opens in a new tab) ici car un attaquant peut nous spammer avec des requêtes pour déployer le proxy jusqu'à ce que notre ETH soit épuisé. Sur un système de production, nous exigerions probablement que la requête de déploiement du proxy soit signée et que le signataire soit un client existant.

    try {
      const ownerAddress = req.body.ownerAddress

Obtenir l'adresse du propriétaire à partir de la requête.

Déployer le contrat (opens in a new tab) et attendre qu'il soit déployé (opens in a new tab).

      res.json({ contractAddress: receipt.contractAddress })

Si tout va bien, renvoyer l'adresse du proxy à l'interface utilisateur.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

S'il y a un problème, le signaler.

  app.post("/server/message", async (req, res) => {

C'est le code qui traite les messages des utilisateurs pour le contrat UserProxy. C'est un autre point vulnérable à une attaque par déni de service.

Obtenir les données de la requête et les utiliser pour appeler signedAccess sur le proxy.

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

Renvoyer le hachage de transaction. Cela permet à l'interface utilisateur d'afficher une URL pour que l'utilisateur puisse vérifier la transaction.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

Encore une fois, s'il y a un problème, signalez-le.

Pour tout le reste, utilisez Vite, qui se charge de servir l'interface utilisateur pour nous.

Interface utilisateur

Voici le code de l'interface utilisateur (opens in a new tab). La majeure partie du code est presque identique à celle documentée dans cet article, à l'exception de Token.jsx (opens in a new tab).

Certaines parties de Token.jsx (opens in a new tab) sont similaires à Greeter.jsx (opens in a new tab) dans cet article. Voici les nouvelles parties.

import {
   encodeFunctionData
       } from 'viem'

Cette fonction (opens in a new tab) crée les données d'appel pour un appel de fonction EVM. C'est nécessaire pour que l'utilisateur puisse signer les données d'appel.

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

Le UserProxy, expliqué ci-dessus.

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

Ce contrat (opens in a new tab) est principalement un contrat ERC-20 normal, avec l'ajout d'une fonction importante, faucet(). Cette fonction accorde des jetons à quiconque les demande à des fins de test.

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

L'adresse pour FaucetToken.

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

Ce composant génère une adresse avec un lien vers le contrat sur un explorateur de blocs.

const Token = () => {
    ...

C'est le composant principal qui fait la majeure partie du travail.

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

Le solde de jetons de l'adresse de l'utilisateur.

  const [ proxyAddr, setProxyAddr ] = useState(null)

L'adresse d'un proxy possédé par l'utilisateur.

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

Le solde de jetons du proxy.

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

Ce champ est utilisé lorsque l'utilisateur définit manuellement l'adresse du proxy. Avoir la possibilité de définir l'adresse du proxy manuellement permet à l'utilisateur d'utiliser un proxy existant au lieu d'en déployer un nouveau à chaque fois (et de perdre tous les jetons possédés par l'ancien proxy).

  const [ txHash, setTxHash ] = useState(null)

Le hachage de la dernière transaction, utilisé pour afficher un lien vers l'explorateur afin que l'utilisateur puisse vérifier cette transaction.

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

Ces champs sont tous utilisés pour envoyer des commandes de transfert de jetons à un contrat ERC-20. Il peut s'agir de FaucetToken, mais ce n'est pas obligatoire. La fonction transfer fait partie de la norme ERC-20.

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

Lire les deux soldes de jetons qui nous intéressent, combien l'utilisateur possède, et combien le proxy possède.

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

Pour empêcher les attaques par rejeu (par exemple, un vendeur rejouant une transaction qui lui donne de l'argent), nous utilisons un nonce (opens in a new tab). Nous devons connaître la valeur actuelle pour l'ajouter aux données que nous signons.

Utiliser useEffect (opens in a new tab) pour mettre à jour le solde affiché à l'utilisateur lorsque les informations lues depuis la chaîne de blocs changent.

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

Le comportement par défaut est de transférer des jetons FaucetToken vers le propre compte de l'utilisateur. Ici, nous définissons ces valeurs lorsque nous les recevons de Viem.

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

Gestionnaires d'événements pour les changements dans les champs de texte.

Demander au serveur de déployer un proxy pour cet utilisateur.

  const signMessage = async(proxyAddr, target, calldata) => {

Signer un message avant de l'envoyer au serveur pour qu'il l'envoie à UserProxy onchain. Ceci est expliqué ici. Nous devons signer un message avec à la fois l'adresse cible (l'adresse du jeton que nous appelons) et les données d'appel à envoyer.

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

Envoyer un message signé à UserProxy, qui vérifiera la signature puis l'enverra à la target.

Envoyer une requête au serveur, et lorsque vous recevez la réponse, obtenir le hachage de transaction.

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

Simuler l'appel de la fonction faucet. Nous n'activons le bouton du faucet que si cela réussit.

Pour appeler une fonction via le serveur et UserProxy, nous suivons trois étapes :

  1. Créer les données d'appel à signer et à envoyer en utilisant encodeFunctionData (opens in a new tab).

  2. Signer le message (adresse cible, données d'appel et nonce).

  3. Envoyer le message au serveur.

Cette partie du composant vous permet d'utiliser FaucetToken directement depuis le navigateur. Son but principal est de faciliter le débogage.

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

Laisser l'utilisateur déployer un nouveau UserProxy.

Ne laisser les utilisateurs cliquer sur Set proxy address que lorsqu'ils entrent une adresse légitime. Notez que cela ne garantit pas que l'adresse en question est bien un contrat UserProxy. Il est possible d'ajouter une telle vérification, mais ce sera beaucoup plus lent (pire expérience utilisateur) et n'améliorera pas la sécurité (les attaquants peuvent toujours utiliser leur propre code pour l'interface utilisateur).

         <br /><br />
         { proxyAddr && (

Afficher le reste uniquement s'il y a une adresse de proxy légitime.

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

L'utilisateur n'a pas besoin de connaître le nonce ; c'est juste à des fins de débogage.

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

Nous ne pouvons pas simuler un appel à faucet() via le proxy. Cependant, nous pouvons au moins nous assurer que nous avons un proxy et que le proxy nous a signalé un nonce.

Laisser l'utilisateur émettre des transactions de transfert ERC-20.

S'il y a un hachage de la dernière transaction, afficher un lien pour que l'utilisateur puisse le voir dans un explorateur de blocs.

 
</div>
    </>
  )
}

export {Token}

C'est juste du code passe-partout React.

Vulnérabilités

Notre serveur est vulnérable aux attaques par déni de service. Cette attaque est expliquée dans l'article précédent de la série.

De plus, nous encourageons un mauvais comportement de l'utilisateur. Voici ce que nous demandons à l'utilisateur de signer :

Screen capture with opaque calldata

Nous savons qu'il s'agit d'un transfert ERC-20 légitime pour le jeton, le montant et l'adresse de destination que l'utilisateur souhaite transférer. Mais la plupart des utilisateurs ne savent pas comment interpréter les données d'appel, et n'ont aucune idée de ce qu'ils signent. C'est une mauvaise conception, pour deux raisons :

  • Certains utilisateurs ne nous utiliseront pas car ils ne font pas confiance aux données que nous leur disons de signer.
  • D'autres utilisateurs nous feront confiance et apprendront qu'ils doivent simplement signer les données d'appel sans comprendre ce que c'est. Cela signifie que si Adam l'Attaquant parvient à les rediriger vers son site Web, il peut leur faire signer une transaction qui lui accorde tous les USDC (ou DAI, ou tout autre ERC-20) que l'utilisateur possède.

La solution est d'avoir des fonctions séparées dans UserProxy pour les fonctions couramment utilisées, telles que le transfert. Ensuite, les utilisateurs peuvent signer quelque chose qu'ils comprennent.

Screen capture with transfer details

Remarque : Bien que les utilisateurs puissent utiliser le portefeuille de leur choix, il est fortement recommandé que les applications utilisant l'EIP-712 les encouragent à utiliser un portefeuille qui affiche l'intégralité des données de signature (opens in a new tab). Certains portefeuilles tronquent l'adresse, ce qui n'est pas sécurisé. Un attaquant peut créer une adresse qui a les mêmes caractères de début et de fin, mais qui diffère au milieu.

Screen capture with truncated addresses

Conclusion

En plus des vulnérabilités ci-dessus, la solution de ce tutoriel présente plusieurs inconvénients qu'Ethereum peut nous aider à résoudre.

  • Résistance à la censure. Actuellement, les utilisateurs peuvent utiliser votre serveur, un serveur concurrent mis en place par quelqu'un d'autre, ou se connecter directement à Ethereum, ce qui entraîne des frais de gaz. L'utilisation de l'ERC-4337 (opens in a new tab) permet aux utilisateurs de proposer leur transaction à un grand groupe de serveurs, réduisant ainsi la probabilité que leurs transactions soient censurées.
  • Actifs possédés par un EOA. Comme noté ci-dessus, l'EIP-7702 (opens in a new tab) peut être utilisé pour gérer des actifs déjà possédés par une adresse EOA. Cela présente des difficultés, mais c'est parfois nécessaire.

J'espère publier des tutoriels sur l'ajout de ces fonctionnalités dans un avenir proche.

Voir ici pour plus de mon travail (opens in a new tab).