Componentes de servidor y agentes para aplicaciones web3
Introducción
En la mayoría de los casos, una aplicación descentralizada (dapp) utiliza un servidor para distribuir el software, pero toda la interacción real ocurre entre el cliente (generalmente, 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 provengan 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 disponible para 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 debe mantenerse en secreto, debe almacenarse en otro lugar (y posiblemente verificar los efectos de ese estado utilizando pruebas de conocimiento cero).
-
Oráculo centralizado. Si lo que está en juego es lo suficientemente bajo, un servidor externo que lea cierta 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. Nada sucede 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 el arbitraje cuando se presenta la oportunidad.
Programa de muestra
Puede ver un servidor de muestra en GitHub (opens in a new tab). Este servidor escucha los eventos provenientes de este contrato (opens 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.
git clone https://github.com/qbzzt/20240715-server-component.git cd 20240715-server-component -
Instale los paquetes necesarios. Si aún no lo tiene, instale Node primero (opens in a new tab).
npm install -
Edite
.envpara especificar la clave privada de una cuenta que tenga ETH en la red de prueba Holesky. Si no tiene ETH en Holesky, puede usar este faucet (opens in a new tab).PRIVATE_KEY=0x <private key goes here> -
Inicie el servidor.
npm start -
Vaya a un explorador de bloques (opens 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 vuelve a modificar automáticamente.
¿Cómo funciona?
La forma más fácil de entender cómo escribir un componente de servidor es repasar el de muestra línea por línea.
src/app.ts
La gran mayoría del programa está contenida en src/app.ts (opens in a new tab).
Creación de los objetos prerrequisitos
import {
createPublicClient,
createWalletClient,
getContract,
http,
Address,
} from "viem"
Estas son las entidades de Viem (opens in a new tab) que necesitamos, funciones y el tipo Address (opens in a new tab). Este servidor está escrito en TypeScript (opens in a new tab), que es una extensión de JavaScript que lo hace fuertemente tipado (opens in a new tab).
import { privateKeyToAccount } from "viem/accounts"
Esta función (opens in a new tab) nos permite generar la información de la billetera, incluida la dirección, correspondiente a una clave privada.
import { 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 Holesky (opens in a new tab).
// Así es como agregamos las definiciones en .env a process.env.
import * as dotenv from "dotenv"
dotenv.config()
Así es como leemos .env en el entorno. Lo necesitamos para la clave privada (ver más adelante).
const greeterAddress : Address = "0xB8f6460Dc30c44401Be26B0d6eD250873d8a50A6"
const greeterABI = [
{
"inputs": [
{
"internalType": "string",
"name": "_greeting",
"type": "string"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
.
.
.
{
"inputs": [
{
"internalType": "string",
"name": "_greeting",
"type": "string"
}
],
"name": "setGreeting",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
] as const
Para usar un contrato necesitamos su dirección y el para el mismo. Proporcionamos ambos aquí.
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 cambiada.
const publicClient = createPublicClient({
chain: holesky,
transport: http(),
})
Cree un cliente público (opens 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 view (opens in a new tab), leer saldos de cuentas, etc.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
Las variables de entorno están disponibles en process.env (opens 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 para 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 privateKeyToAccount (opens in a new tab) luego usa esta clave privada para crear un objeto de cuenta completo.
const walletClient = createWalletClient({
account,
chain: holesky,
transport: http(),
})
A continuación, usamos el objeto de cuenta para crear un cliente de billetera (opens in a new tab). Este cliente tiene una clave privada y una dirección, por lo que se puede usar para enviar transacciones.
const greeter = getContract({
address: greeterAddress,
abi: greeterABI,
client: { public: publicClient, wallet: walletClient },
})
Ahora que tenemos todos los prerrequisitos, finalmente podemos crear una instancia de contrato (opens in a new tab). Usaremos esta instancia de contrato para comunicarnos con el contrato en cadena.
Lectura de la cadena de bloques
console.log(`Current greeting:`, await greeter.read.greet())
Las funciones del contrato que son de solo lectura (view (opens in a new tab) y pure (opens in a new tab)) están disponibles bajo read. En este caso, lo usamos para acceder a la función greet (opens 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íncrona (opens 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 la computadora y un nodo de la cadena de bloques. Esa es la razón por la que especificamos aquí que el código necesita hacer await para el resultado.
Si está interesado en cómo funciona esto, puede leer al respecto aquí (opens in a new tab), pero en términos prácticos todo lo que necesita saber es que hace await a los resultados si inicia una operación que toma mucho tiempo, y que cualquier función que haga esto tiene que ser declarada como async.
Emisión de transacciones
const setGreeting = async (greeting: string): Promise<any> => {
Esta es la función a la que 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.
const 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 setGreeting (opens 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.
console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)
return txHash
}
Reporte el hash de la transacción (como parte de una URL al explorador de bloques para verla) y devuélvalo.
Respuesta a eventos
greeter.watchEvent.SetGreeting({
La función watchEvent (opens in a new tab) le permite especificar que una función se ejecute cuando se emite un evento. Si solo le importa un tipo de evento (en este caso, SetGreeting), puede usar esta sintaxis para limitarse a ese tipo de evento.
onLogs: logs => {
La función onLogs se llama cuando hay entradas de registro. En Ethereum, "registro" y "evento" suelen ser intercambiables.
console.log(
`Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`
)
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.
if (logs[0].args.sender != account.address)
setGreeting(`${account.address} insists on it being Hello!`)
}
})
Si el remitente no es este servidor, use setGreeting para cambiar el saludo.
package.json
Este archivo (opens in a new tab) controla la configuración de Node.js (opens in a new tab). Este artículo solo explica las definiciones importantes.
{
"main": "dist/index.js",
Esta definición especifica qué archivo JavaScript ejecutar.
"scripts": {
"start": "tsc && node dist/app.js",
},
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 a JavaScript. Si desea ejecutarlo manualmente, se encuentra en node_modules/.bin. El segundo comando ejecuta el servidor.
"type": "module",
Hay múltiples tipos de aplicaciones de nodo de JavaScript. El tipo module nos permite tener await en el código de nivel superior, lo cual es importante cuando realiza operaciones lentas (y por lo tanto, asíncronas).
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
},
Estos son paquetes que solo se requieren para el desarrollo. Aquí necesitamos typescript y debido a que lo estamos usando con Node.js, también estamos obteniendo los tipos para 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 incompatibles. Consulte aquí (opens in a new tab) para obtener más información sobre el significado de los números de versión.
"dependencies": {
"dotenv": "^16.4.5",
"viem": "2.14.1"
}
}
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 se pueden verificar 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 artículo futuro planeo mostrar cómo usar pruebas de conocimiento cero para sortear este problema.