Patrocinio de tarifas de gas: Cómo cubrir los costos de transacción para sus usuarios
Introducción
Si queremos que Ethereum sirva a mil millones de personas más (opens in a new tab), necesitamos eliminar la fricción y hacerlo lo más fácil de usar posible. Una fuente de esta fricción es la necesidad de ETH para pagar las tarifas de gas.
Si tiene una aplicación descentralizada (dapp) que gana dinero de los usuarios, podría tener sentido permitir que los usuarios envíen transacciones a través de su servidor y pagar usted mismo las tarifas de transacción. Debido a que los usuarios aún firman un mensaje de autorización EIP-712 (opens in a new tab) en sus billeteras, conservan las garantías de integridad de Ethereum. La disponibilidad depende del servidor que retransmite las transacciones, por lo que es más limitada. Sin embargo, puede configurar las cosas para que los usuarios también puedan acceder al contrato inteligente directamente (si consiguen ETH), y permitir que otros configuren sus propios servidores si desean patrocinar transacciones.
La técnica de este tutorial solo funciona cuando usted controla el contrato inteligente. Existen otras técnicas, incluida la abstracción de cuentas (opens in a new tab), que le permiten patrocinar transacciones a otros contratos inteligentes, las cuales espero cubrir en un futuro tutorial.
Nota: Este no es código de nivel de producción. Es vulnerable a ataques significativos y carece de características importantes. Obtenga más información en la sección de vulnerabilidades de esta guía.
Requisitos previos
Para entender este tutorial, ya debe estar familiarizado con:
- Solidity
- JavaScript
- React y WAGMI. Si no está familiarizado con estas herramientas de interfaz de usuario, tenemos un tutorial para ello.
La aplicación de muestra
La aplicación de muestra aquí es una variante del contrato Greeter de Hardhat. Puede verla en GitHub (opens in a new tab). El contrato inteligente ya está implementado en Sepolia (opens in a new tab), en la dirección 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
Para verla en acción, siga estos pasos.
-
Clone el repositorio e instale el software necesario.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
Edite
.envpara configurarPRIVATE_KEYcon una billetera que tenga ETH en Sepolia. Si necesita ETH de Sepolia, use un faucet. Idealmente, esta clave privada debería ser diferente de la que tiene en la billetera de su navegador. -
Inicie el servidor.
1npm run dev -
Navegue a la aplicación en la URL
http://localhost:5173(opens in a new tab). -
Haga clic en Connect with Injected para conectarse a una billetera. Apruebe en la billetera y apruebe el cambio a Sepolia si es necesario.
-
Escriba un nuevo saludo y haga clic en Update greeting via sponsor.
-
Firme el mensaje.
-
Espere unos 12 segundos (el tiempo de bloque en Sepolia). Mientras espera, puede mirar la URL en la consola del servidor para ver la transacción.
-
Vea que el saludo cambió y que el valor de la dirección de la última actualización ahora es la dirección de la billetera de su navegador.
Para entender cómo funciona esto, necesitamos ver cómo se crea el mensaje en la interfaz de usuario, cómo lo retransmite el servidor y cómo lo procesa el contrato inteligente.
La interfaz de usuario
La interfaz de usuario se basa en WAGMI (opens in a new tab); puede leer sobre ello en este tutorial.
Así es como firmamos el mensaje:
1const signGreeting = useCallback(El hook de React useCallback (opens in a new tab) nos permite mejorar el rendimiento al reutilizar la misma función cuando se vuelve a dibujar el componente.
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")Si no hay una cuenta, genere un error. Esto nunca debería suceder porque el botón de la interfaz de usuario que inicia el proceso que llama a signGreeting está deshabilitado en ese caso. Sin embargo, futuros programadores podrían eliminar esa protección, por lo que es una buena idea verificar esta condición aquí también.
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }Parámetros para el separador de dominio (opens in a new tab). Este valor es constante, por lo que en una implementación mejor optimizada, podríamos calcularlo una vez en lugar de recalcularlo cada vez que se llama a la función.
namees un nombre legible por el usuario, como el nombre de la aplicación descentralizada (dapp) para la que estamos produciendo firmas.versiones la versión. Las diferentes versiones no son compatibles.chainIdes la cadena que estamos usando, tal como la proporciona WAGMI (opens in a new tab).verifyingContractes la dirección del contrato que verificará esta firma. No queremos que la misma firma se aplique a múltiples contratos, en caso de que haya varios contratosGreetery queramos que tengan diferentes saludos.
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }El tipo de datos que firmamos. Aquí, tenemos un solo parámetro, greeting, pero los sistemas de la vida real suelen tener más.
1 const message = { greeting }El mensaje real que queremos firmar y enviar. greeting es tanto el nombre del campo como el nombre de la variable que lo llena.
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })Obtener realmente la firma. Esta función es asíncrona porque los usuarios tardan mucho tiempo (desde la perspectiva de una computadora) en firmar datos.
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 función devuelve un único valor hexadecimal. Aquí lo dividimos en campos.
1 [account, chainId, contractAddr, signTypedDataAsync],2)Si alguna de estas variables cambia, cree una nueva instancia de la función. Los parámetros account y chainId pueden ser cambiados por el usuario en la billetera. contractAddr es una función del Id de la cadena. signTypedDataAsync no debería cambiar, pero lo importamos de un hook (opens in a new tab), por lo que no podemos estar seguros, y es mejor agregarlo aquí.
Ahora que el nuevo saludo está firmado, necesitamos enviarlo al servidor.
1 const sponsoredGreeting = async () => {2 try {Esta función toma una firma y la envía al servidor.
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {Enviar a la ruta /server/sponsor en el servidor del que venimos.
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })Use POST para enviar la información codificada en JSON.
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }Muestre la respuesta. En un sistema de producción también mostraríamos la respuesta al usuario.
El servidor
Me gusta usar Vite (opens in a new tab) como mi front-end. Sirve automáticamente las bibliotecas de React y actualiza el navegador cuando cambia el código del front-end. Sin embargo, Vite no incluye herramientas de backend.
La solución está en index.js (opens in a new tab).
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // Dejar que Vite se encargue de todo lo demás6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)Primero registramos un manejador para las solicitudes que manejamos nosotros mismos (POST a /server/sponsor). Luego creamos y usamos un servidor Vite para manejar todas las demás 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 })Esta es solo una llamada estándar a la cadena de bloques con viem (opens in a new tab).
El contrato inteligente
Finalmente, Greeter.sol (opens in a new tab) necesita verificar 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 }El constructor crea el separador de dominio (opens in a new tab), de manera similar al código de la interfaz de usuario anterior. La ejecución en la cadena de bloques es mucho más costosa, por lo que solo lo calculamos una vez.
1 struct GreetingRequest {2 string greeting;3 }Esta es la estructura que se firma. Aquí tenemos solo un campo.
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");Este es el identificador de estructura (opens in a new tab). Se calcula cada vez en la interfaz de usuario.
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {Esta función recibe una solicitud firmada y actualiza el saludo.
1 // Calcular el resumen 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 );Cree el resumen (digest) de acuerdo con EIP 712 (opens in a new tab).
1 // Recuperar el firmante2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");Use ecrecover (opens in a new tab) para obtener la dirección del firmante. Tenga en cuenta que una firma incorrecta aún puede resultar en una dirección válida, solo que aleatoria.
1 // Aplicar el saludo como si el firmante lo hubiera llamado2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }Actualice el saludo.
Vulnerabilidades
Este no es código de nivel de producción. Es vulnerable a ataques significativos y carece de características importantes. Aquí hay algunas, junto con cómo resolverlas.
Para ver algunos de estos ataques, haga clic en los botones bajo el encabezado Attacks y vea qué sucede. Para el botón Invalid signature, verifique la consola del servidor para ver la respuesta de la transacción.
Denegación de servicio en el servidor
El ataque más fácil es un ataque de denegación de servicio (opens in a new tab) en el servidor. El servidor recibe solicitudes de cualquier parte de Internet y, en función de esas solicitudes, envía transacciones. No hay absolutamente nada que impida a un atacante emitir un montón de firmas, válidas o inválidas. Cada una causará una transacción. Eventualmente, el servidor se quedará sin ETH para pagar el gas.
Una solución a este problema es limitar la tasa a una transacción por bloque. Si el propósito es mostrar saludos a cuentas de propiedad externa, de todos modos no importa cuál sea el saludo en el medio del bloque.
Otra solución es realizar un seguimiento de las direcciones y solo permitir firmas de clientes válidos.
Firmas de saludo incorrectas
Cuando hace clic en Signature for wrong greeting, envía una firma válida para una dirección específica (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) y un saludo (Hello). Pero lo envía con un saludo diferente. Esto confunde a ecrecover, que cambia el saludo pero tiene la dirección incorrecta.
Para resolver este problema, agregue la dirección a la estructura firmada (opens in a new tab). De esta manera, la dirección aleatoria de ecrecover no coincidirá con la dirección en la firma, y el contrato inteligente rechazará el mensaje.
Ataques de repetición
Cuando hace clic en Replay attack, envía la misma firma "Soy 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, y me gustaría que el saludo fuera Hello", pero con el saludo correcto. Como resultado, el contrato inteligente cree que la dirección (que no es la suya) volvió a cambiar el saludo a Hello. La información para hacer esto está disponible públicamente en la información de la transacción (opens in a new tab).
Si esto es un problema, una solución es agregar un nonce (opens in a new tab). Tenga un mapeo (opens in a new tab) entre direcciones y números, y agregue un campo nonce a la firma. Si el campo nonce coincide con el mapeo para la dirección, acepte la firma e incremente el mapeo para la próxima vez. Si no es así, rechace la transacción.
Otra solución es agregar una marca de tiempo a los datos firmados y aceptar la firma como válida solo durante unos segundos después de esa marca de tiempo. Esto es más simple y económico, pero corremos el riesgo de ataques de repetición dentro de la ventana de tiempo, y el fallo de transacciones legítimas si se excede la ventana de tiempo.
Otras características faltantes
Hay características adicionales que agregaríamos en un entorno de producción.
Acceso desde otros servidores
Actualmente, permitimos que cualquier dirección envíe un sponsorSetGreeting. Esto puede ser exactamente lo que queremos, en aras de la descentralización. O tal vez queramos asegurarnos de que las transacciones patrocinadas pasen por nuestro servidor, en cuyo caso verificaríamos msg.sender en el contrato inteligente.
De cualquier manera, esta debería ser una decisión de diseño consciente, no solo el resultado de no pensar en el problema.
Manejo de errores
Un usuario envía un saludo. Tal vez se actualice en el siguiente bloque. Tal vez no. Los errores son invisibles. En un sistema de producción, el usuario debería poder distinguir entre estos casos:
- El nuevo saludo aún no se ha enviado
- El nuevo saludo se ha enviado y está en proceso
- El nuevo saludo ha sido rechazado
Conclusión
En este punto, debería poder crear una experiencia sin gas para los usuarios de su aplicación descentralizada (dapp), a costa de cierta centralización.
Sin embargo, esto solo funciona con contratos inteligentes que admiten ERC-712. Para transferir un token ERC-20, por ejemplo, es necesario que la transacción sea firmada por el propietario en lugar de solo un mensaje. La solución es la abstracción de cuentas (ERC-4337) (opens in a new tab). Espero escribir un futuro tutorial al respecto.
Vea aquí más de mi trabajo (opens in a new tab).
Última actualización de la página: 3 de marzo de 2026