Componentes de servidor y agentes para aplicaciones web3
Introducción
En la mayoría de los casos, una aplicación descentralizada utiliza un servidor para distribuir el software, pero toda la interacción real ocurre entre el cliente (normalmente, el navegador web) y la cadena de bloques.
Sin embargo, hay algunos casos en los que una aplicación se beneficiaría de tener un componente de servidor que se ejecute de forma independiente. Dicho servidor podría responder a eventos y a solicitudes que provienen de otras fuentes, como una API, emitiendo transacciones.
Hay varias tareas posibles que un servidor de este tipo podría cumplir.
-
Poseedor de estado secreto. En los juegos, a menudo es útil no tener toda la información que el juego conoce a disposición de los jugadores. Sin embargo, no hay secretos en la cadena de bloques, cualquier información que esté en la cadena de bloques es fácil de descubrir para cualquiera. Por lo tanto, si parte del estado del juego se va a mantener en secreto, debe almacenarse en otro lugar (y posiblemente verificar los efectos de ese estado utilizando pruebas de conocimiento cero).
-
Oráculo centralizado. Si las apuestas son suficientemente bajas, un servidor externo que lea alguna información en línea y luego la publique en la cadena puede ser lo suficientemente bueno como para usarlo como un oráculo.
-
Agente. No sucede nada en la cadena de bloques sin una transacción que lo active. Un servidor puede actuar en nombre de un usuario para realizar acciones como arbitraje cuando se presenta la oportunidad.
Programa de ejemplo
Puede ver un servidor de ejemplo en GitHubopens in a new tab. Este servidor escucha los eventos provenientes de este contratoopens in a new tab, una versión modificada del Greeter de Hardhat. Cuando se cambia el saludo, lo vuelve a cambiar.
Para ejecutarlo:
-
Clone el repositorio.
1git clone https://github.com/qbzzt/20240715-server-component.git2cd 20240715-server-component -
Instale los paquetes necesarios. Si aún no lo tiene, instale Node primeroopens in a new tab.
1npm install -
Edite
.envpara especificar la clave privada de una cuenta que tenga ETH en la red de prueba de Holesky. Si no tiene ETH en Holesky, puede usar este grifoopens in a new tab.1PRIVATE_KEY=0x <aquí va la clave privada> -
Inicie el servidor.
1npm start -
Vaya a un explorador de bloquesopens in a new tab y, utilizando una dirección diferente a la que tiene la clave privada, modifique el saludo. Verá que el saludo se modifica automáticamente.
¿Cómo funciona?
La forma más fácil de entender cómo escribir un componente de servidor es repasar el ejemplo línea por línea.
src/app.ts
La gran mayoría del programa está contenida en src/app.tsopens in a new tab.
Creación de los objetos prerrequisito
1import {2 createPublicClient,3 createWalletClient,4 getContract,5 http,6 Address,7} from "viem"Estas son las entidades de Viemopens in a new tab que necesitamos, las funciones y el tipo Addressopens in a new tab. Este servidor está escrito en TypeScriptopens in a new tab, que es una extensión de JavaScript que lo hace fuertemente tipadoopens in a new tab.
1import { privateKeyToAccount } from "viem/accounts"Esta funciónopens in a new tab nos permite generar la información de la billetera, incluida la dirección, correspondiente a una clave privada.
1import { holesky } from "viem/chains"Para usar una cadena de bloques en Viem necesita importar su definición. En este caso, queremos conectarnos a la cadena de bloques de prueba Holeskyopens in a new tab.
1// Así es como añadimos las definiciones en .env a process.env.2import * as dotenv from "dotenv"3dotenv.config()Así es como leemos .env en el entorno. Lo necesitamos para la clave privada (véase más adelante).
1const greeterAddress : Address = "0xB8f6460Dc30c44401Be26B0d6eD250873d8a50A6"2const greeterABI = [3 {4 "inputs": [5 {6 "internalType": "string",7 "name": "_greeting",8 "type": "string"9 }10 ],11 "stateMutability": "nonpayable",12 "type": "constructor"13 },14 .15 .16 .17 {18 "inputs": [19 {20 "internalType": "string",21 "name": "_greeting",22 "type": "string"23 }24 ],25 "name": "setGreeting",26 "outputs": [],27 "stateMutability": "nonpayable",28 "type": "function"29 }30] as constMostrar todoPara usar un contrato necesitamos su dirección y su . Aquí proporcionamos ambos.
En JavaScript (y por lo tanto en TypeScript) no se puede asignar un nuevo valor a una constante, pero sí se puede modificar el objeto que está almacenado en ella. Al usar el sufijo as const le estamos diciendo a TypeScript que la lista en sí es constante y no puede ser modificada.
1const publicClient = createPublicClient({2 chain: holesky,3 transport: http(),4})Crear un cliente públicoopens in a new tab de Viem. Los clientes públicos no tienen una clave privada adjunta y, por lo tanto, no pueden enviar transacciones. Pueden llamar a funciones viewopens in a new tab, leer saldos de cuentas, etc.
1const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)Las variables de entorno están disponibles en process.envopens in a new tab. Sin embargo, TypeScript es fuertemente tipado. Una variable de entorno puede ser cualquier cadena, o estar vacía, por lo que el tipo de una variable de entorno es string | undefined. Sin embargo, una clave se define en Viem como 0x${string} (0x seguido de una cadena). Aquí le decimos a TypeScript que la variable de entorno PRIVATE_KEY será de ese tipo. Si no lo es, obtendremos un error en tiempo de ejecución.
La función privateKeyToAccountopens in a new tab utiliza esta clave privada para crear un objeto de cuenta completo.
1const walletClient = createWalletClient({2 account,3 chain: holesky,4 transport: http(),5})A continuación, usamos el objeto de cuenta para crear un cliente de billeteraopens in a new tab. Este cliente tiene una clave privada y una dirección, por lo que se puede usar para enviar transacciones.
1const greeter = getContract({2 address: greeterAddress,3 abi: greeterABI,4 client: { public: publicClient, wallet: walletClient },5})Ahora que tenemos todos los requisitos previos, podemos crear finalmente una instancia de contratoopens in a new tab. Usaremos esta instancia de contrato para comunicarnos con el contrato en cadena.
Leer desde la cadena de bloques
1console.log(`Current greeting:`, await greeter.read.greet())Las funciones de contrato que son de solo lectura (viewopens in a new tab y pureopens in a new tab) están disponibles en read. En este caso, lo usamos para acceder a la función greetopens in a new tab, que devuelve el saludo.
JavaScript es de un solo hilo, por lo que cuando iniciamos un proceso de larga duración necesitamos especificar que lo hacemos de forma asíncronaopens in a new tab. Llamar a la cadena de bloques, incluso para una operación de solo lectura, requiere un viaje de ida y vuelta entre el ordenador y un nodo de la cadena de bloques. Esa es la razón por la que especificamos aquí que el código necesita esperar (await) el resultado.
Si le interesa cómo funciona esto, puede leer sobre ello aquíopens in a new tab, pero en términos prácticos todo lo que necesita saber es que debe esperar los resultados si inicia una operación que tarda mucho tiempo, y que cualquier función que haga esto debe declararse como async.
Emisión de transacciones
1const setGreeting = async (greeting: string): Promise<any> => {Esta es la función a la que se llama para emitir una transacción que cambia el saludo. Como esta es una operación larga, la función se declara como async. Debido a la implementación interna, cualquier función async necesita devolver un objeto Promise. En este caso, Promise<any> significa que no especificamos qué se devolverá exactamente en la Promise.
1const txHash = await greeter.write.setGreeting([greeting])El campo write de la instancia del contrato tiene todas las funciones que escriben en el estado de la cadena de bloques (aquellas que requieren enviar una transacción), como setGreetingopens in a new tab. Los parámetros, si los hay, se proporcionan como una lista, y la función devuelve el hash de la transacción.
1 console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)23 return txHash4}Informa del hash de la transacción (como parte de una URL al explorador de bloques para verla) y lo devuelve.
Respondiendo a eventos
1greeter.watchEvent.SetGreeting({La función watchEventopens in a new tab le permite especificar que una función se ejecutará cuando se emita un evento. Si solo le interesa un tipo de evento (en este caso, SetGreeting), puede usar esta sintaxis para limitarse a ese tipo de evento.
1 onLogs: logs => {La función onLogs se llama cuando hay entradas de registro. En Ethereum, «registro» y «evento» suelen ser intercambiables.
1console.log(2 `Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`3)Podría haber múltiples eventos, pero por simplicidad solo nos importa el primero. logs[0].args son los argumentos del evento, en este caso sender y greeting.
1 if (logs[0].args.sender != account.address)2 setGreeting(`${account.address} insists on it being Hello!`)3 }4})Si el remitente no es este servidor, use setGreeting para cambiar el saludo.
package.json
Este archivoopens in a new tab controla la configuración de Node.jsopens in a new tab. Este artículo solo explica las definiciones importantes.
1{2 "main": "dist/index.js",Esta definición especifica qué archivo JavaScript ejecutar.
1 "scripts": {2 "start": "tsc && node dist/app.js",3 },Los scripts son varias acciones de la aplicación. En este caso, el único que tenemos es start, que compila y luego ejecuta el servidor. El comando tsc es parte del paquete typescript y compila TypeScript en JavaScript. Si desea ejecutarlo manualmente, se encuentra en node_modules/.bin. El segundo comando ejecuta el servidor.
1 "type": "module",Existen múltiples tipos de aplicaciones de nodo JavaScript. El tipo module nos permite tener await en el código de nivel superior, lo cual es importante cuando se realizan operaciones lentas (y por lo tanto asíncronas).
1 "devDependencies": {2 "@types/node": "^20.14.2",3 "typescript": "^5.4.5"4 },Estos son paquetes que solo se requieren para el desarrollo. Aquí necesitamos typescript y como lo estamos usando con Node.js, también estamos obteniendo los tipos para las variables y objetos de nodo, como process. La notación ^<version>opens in a new tab significa esa versión o una versión superior que no tenga cambios que rompan la compatibilidad. Vea aquíopens in a new tab para más información sobre el significado de los números de versión.
1 "dependencies": {2 "dotenv": "^16.4.5",3 "viem": "2.14.1"4 }5}Estos son paquetes que se requieren en tiempo de ejecución, al ejecutar dist/app.js.
Conclusión
El servidor centralizado que creamos aquí hace su trabajo, que es actuar como un agente para un usuario. Cualquier otra persona que quiera que la dapp siga funcionando y esté dispuesta a gastar el gas puede ejecutar una nueva instancia del servidor con su propia dirección.
Sin embargo, esto solo funciona cuando las acciones del servidor centralizado pueden verificarse fácilmente. Si el servidor centralizado tiene alguna información de estado secreta, o ejecuta cálculos difíciles, es una entidad centralizada en la que necesita confiar para usar la aplicación, que es exactamente lo que las cadenas de bloques intentan evitar. En un futuro artículo planeo mostrar cómo usar pruebas de conocimiento cero para solucionar este problema.
Vea aquí más de mi trabajoopens in a new tab.
Última actualización de la página: 9 de septiembre de 2025