Saltar al contenido principal

Crear una interfaz de usuario para tu contrato

TypeScript
React
Vite
Wagmi
frontend
Principiante
Ori Pomerantz
1 de noviembre de 2023
19 minutos de lectura

Encontraste una característica que necesitamos en el ecosistema de Ethereum. Escribiste los contratos inteligentes para implementarla, y tal vez incluso algo de código relacionado que se ejecuta fuera de la cadena. ¡Esto es genial! Desafortunadamente, sin una interfaz de usuario no vas a tener ningún usuario, y la última vez que escribiste un sitio web la gente usaba módems de acceso telefónico y JavaScript era nuevo.

Este artículo es para ti. Asumo que sabes programar, y tal vez un poco de JavaScript y HTML, pero que tus habilidades de interfaz de usuario están oxidadas y desactualizadas. Juntos repasaremos una aplicación moderna y sencilla para que veas cómo se hace hoy en día.

Por qué es importante esto

En teoría, podrías simplemente hacer que la gente use Etherscan (opens in a new tab) o Blockscout (opens in a new tab) para interactuar con tus contratos. Eso es genial para los Ethereans experimentados. Pero estamos intentando servir a otros mil millones de personas (opens in a new tab). Esto no sucederá sin una gran experiencia de usuario, y una interfaz de usuario amigable es una gran parte de ello.

Aplicación Greeter

Hay mucha teoría detrás de cómo funciona la interfaz de usuario moderna, y muchos sitios buenos (opens in a new tab) que lo explican (opens in a new tab). En lugar de repetir el excelente trabajo realizado por esos sitios, voy a asumir que prefieres aprender haciendo y comenzar con una aplicación con la que puedas jugar. Todavía necesitas la teoría para hacer las cosas, y llegaremos a ella; simplemente iremos archivo fuente por archivo fuente, y discutiremos las cosas a medida que lleguemos a ellas.

Instalación

  1. La aplicación usa la red de prueba Sepolia (opens in a new tab). Si es necesario, obtén ETH de prueba de Sepolia y agrega Sepolia a tu billetera (opens in a new tab).

  2. Clona el repositorio de GitHub e instala los paquetes necesarios.

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. La aplicación utiliza puntos de acceso gratuitos, que tienen limitaciones de rendimiento. Si deseas utilizar un proveedor de Nodo como servicio, reemplaza las URL en src/wagmi.ts.

  4. Inicia la aplicación.

    npm run dev
    
  5. Navega a la URL mostrada por la aplicación. En la mayoría de los casos, es http://localhost:5173/ (opens in a new tab).

  6. Puedes ver el código fuente del contrato, una versión modificada del Greeter de Hardhat, en un explorador de cadena de bloques (opens in a new tab).

Recorrido por los archivos

index.html

Este archivo es una plantilla HTML estándar, excepto por esta línea, que importa el archivo de script.

<script type="module" src="/src/main.tsx"></script>

src/main.tsx

La extensión del archivo indica que se trata de un componente de React (opens in a new tab) escrito en TypeScript (opens in a new tab), una extensión de JavaScript que admite la comprobación de tipos (opens in a new tab). TypeScript se compila a JavaScript, por lo que podemos usarlo en el lado del cliente.

Este archivo se explica principalmente por si estás interesado. Por lo general, no modificas este archivo, sino src/App.tsx y los archivos que importa.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { WagmiProvider } from 'wagmi'

Importa el código de la biblioteca que necesitamos.

import App from './App.tsx'

Importa el componente de React que implementa la aplicación (ver más abajo).

import { config } from './wagmi.ts'

Importa la configuración de Wagmi (opens in a new tab), que incluye la configuración de la cadena de bloques.

const queryClient = new QueryClient()

Crea una nueva instancia del administrador de caché de React Query (opens in a new tab). Este objeto almacenará:

  • Llamadas RPC en caché
  • Lecturas de contratos
  • Estado de recuperación en segundo plano

Necesitamos el administrador de caché porque wagmi v3 usa React Query internamente.

ReactDOM.createRoot(document.getElementById('root')!).render(

Crea el componente raíz de React. El parámetro para render es JSX (opens in a new tab), un lenguaje de extensión que usa tanto HTML como JavaScript/TypeScript. El signo de exclamación aquí le dice al componente de TypeScript: "no sabes que document.getElementById('root') será un parámetro válido para ReactDOM.createRoot, pero no te preocupes, soy el desarrollador y te digo que lo habrá".

  <React.StrictMode>

La aplicación va dentro de un componente React.StrictMode (opens in a new tab). Este componente le dice a la biblioteca de React que inserte comprobaciones de depuración adicionales, lo cual es útil durante el desarrollo.

    <WagmiProvider config={config}>

La aplicación también está dentro de un componente WagmiProvider (opens in a new tab). La biblioteca Wagmi (we are going to make it) (opens in a new tab) conecta las definiciones de la interfaz de usuario de React con la biblioteca Viem (opens in a new tab) para escribir una aplicación descentralizada (dapp) de Ethereum.

      <QueryClientProvider client={queryClient}>

Y finalmente, agrega un proveedor de React Query para que cualquier componente de la aplicación pueda usar consultas en caché.

        <App />

Ahora podemos tener el componente para la aplicación, que realmente implementa la interfaz de usuario. El /> al final del componente le dice a React que este componente no tiene ninguna definición en su interior, según el estándar XML.

      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

Por supuesto, tenemos que cerrar los otros componentes.

src/App.tsx

Importa las bibliotecas que necesitamos, así como el componente Greeter.

const SEPOLIA_CHAIN_ID = 11155111

El ID de la cadena Sepolia.

function App() {

Esta es la forma estándar de crear un componente de React: definir una función que se llama cada vez que necesita ser renderizada. Esta función normalmente contiene código TypeScript o JavaScript, seguido de una declaración return que devuelve el código JSX.

  const connection = useConnection()

Usa useConnection (opens in a new tab) para obtener información relacionada con la conexión actual, como la dirección y el chainId.

Por convención, en React las funciones llamadas use... son hooks (opens in a new tab). Estas funciones no solo devuelven datos al componente; también aseguran que se vuelva a renderizar (la función del componente se ejecuta de nuevo, y su salida reemplaza a la anterior en el HTML) cuando esos datos cambian.

  const { connectors, connect, status, error } = useConnect()

Usa useConnect (opens in a new tab) para obtener información sobre la conexión de la billetera.

  const { disconnect } = useDisconnect()

Este hook (opens in a new tab) nos da la función para desconectarnos de la billetera.

  const { switchChain } = useSwitchChain()

Este hook (opens in a new tab) nos permite cambiar de cadena.

  useEffect(() => {

El hook de React useEffect (opens in a new tab) te permite ejecutar una función cada vez que el valor de una variable cambia para sincronizar un sistema externo.

    if (connection.status === 'connected' &&
        connection.chainId !== SEPOLIA_CHAIN_ID
    ) {
      switchChain({ chainId: SEPOLIA_CHAIN_ID })
    }

Si estamos conectados, pero no a la cadena de bloques Sepolia, cambia a Sepolia.

  }, [connection.status, connection.chainId])

Vuelve a ejecutar la función cada vez que cambie el estado de la conexión o el chainId de la conexión.

  return (
    <>

El JSX de un componente de React debe devolver un solo componente HTML. Cuando tenemos varios componentes y no necesitamos un contenedor para envolverlos a todos, usamos un componente vacío (<> ... </>) para combinarlos en un solo componente.

Proporciona información sobre la conexión actual. Dentro de JSX, {<expression>} significa evaluar la expresión como JavaScript.

      {connection.status === 'connected' && (

La sintaxis {<condition> && <value>} means "if the condition is true, evaluate to the value; if it isn't, evaluate to false`".

Esta es la forma estándar de poner declaraciones if dentro de JSX.

        <div>
          <Greeter />
          <hr />

JSX sigue el estándar XML, que es más estricto que HTML. Si una etiqueta no tiene una etiqueta de cierre correspondiente, debe tener una barra diagonal (/) al final para terminarla.

Aquí tenemos dos de esas etiquetas, <Greeter /> (que en realidad contiene el código HTML que se comunica con el contrato) y <hr /> para una línea horizontal (opens in a new tab).

          <button type="button" onClick={disconnect}>
            Disconnect
          </button>
 
</div>
      )}

Si el usuario hace clic en este botón, llama a la función disconnect.

      {connection.status !== 'connected' && (

Si no estamos conectados, muestra las opciones necesarias para conectarse a la billetera.

        <div>
          <h2>Connect</h2>
          {connectors.map((connector) => (

En connectors tenemos una lista de conectores. Usamos map (opens in a new tab) para convertirla en una lista de botones JSX para mostrar.

            <button
              key={connector.uid}

En JSX es necesario que las etiquetas "hermanas" (etiquetas que descienden del mismo padre) tengan identificadores diferentes.

              onClick={() => connect({ connector })}
              type="button"
            >
              {connector.name}
            </button>
          ))}

Los botones del conector.

          <div>{status}</div>
          <div>{error?.message}</div>
 
</div>
      )}

Proporciona información adicional. La sintaxis de la expresión <variable>?.<field> le dice a JavaScript que si la variable está definida, evalúe ese campo. Si la variable no está definida, entonces esta expresión se evalúa como undefined.

La expresión error.message, cuando no hay error, generaría una excepción. El uso de error?.message nos permite evitar este problema.

src/Greeter.tsx

Este archivo contiene la mayor parte de la funcionalidad de la interfaz de usuario. Incluye definiciones que normalmente estarían en varios archivos, pero como se trata de un tutorial, el programa está optimizado para ser fácil de entender la primera vez, en lugar de para el rendimiento o la facilidad de mantenimiento.

Usamos estas funciones de la biblioteca. Nuevamente, se explican a continuación donde se usan.

import { AddressType } from 'abitype'

La biblioteca abitype (opens in a new tab) nos proporciona definiciones de TypeScript para varios tipos de datos de Ethereum, como AddressType (opens in a new tab).

let greeterABI = [
  { "type": "function", "name": "greet", ... },
  { "type": "function", "name": "setGreeting", ... },
  { "type": "event", "name": "SetGreeting", ... },
] as const   // greeterABI

El ABI para el contrato Greeter. Si estás desarrollando los contratos y la interfaz de usuario al mismo tiempo, normalmente los pondrías en el mismo repositorio y usarías el ABI generado por el compilador de Solidity como un archivo en tu aplicación. Sin embargo, esto no es necesario aquí porque el contrato ya está desarrollado y no cambiará.

Usamos as const (opens in a new tab) para decirle a TypeScript que esta es una constante real. Normalmente, cuando especificas en JavaScript const x = {"a": 1}, puedes cambiar el valor en x, simplemente no puedes asignarle un valor.

type AddressPerBlockchainType = {
  [key: number]: AddressType
}

TypeScript está fuertemente tipado. Usamos esta definición para especificar la dirección donde se implementa el contrato Greeter en diferentes cadenas. La clave es un número (el chainId), y el valor es un AddressType (una dirección).

const contractAddrs : AddressPerBlockchainType = {
  // Sepolia
    11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'
}

La dirección del contrato en Sepolia (opens in a new tab).

Componente Timer

El componente Timer muestra el número de segundos desde un momento dado. Esto es importante por motivos de usabilidad. Cuando los usuarios hacen algo, esperan una reacción inmediata. En las cadenas de bloques, esto a menudo es imposible porque no sucede nada hasta que se coloca una transacción en un bloque. Una solución es mostrar cuánto tiempo ha pasado desde que el usuario realizó la acción, para que el usuario pueda decidir si el tiempo requerido es razonable.

type TimerProps = {
  lastUpdate: Date
}

El componente Timer toma un parámetro, lastUpdate, que es el momento de la última acción.

const Timer = ({ lastUpdate }: TimerProps) => {
  const [_, setNow] = useState(new Date())

Necesitamos tener un estado (una variable vinculada al componente) y actualizarlo para que el componente funcione correctamente. Pero nunca necesitamos leerlo, así que no te molestes en crear una variable.

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000)
    return () => clearInterval(id)
  }, [])

La función setInterval (opens in a new tab) nos permite programar una función para que se ejecute periódicamente. En este caso, cada segundo. La función llama a setNow para actualizar el estado, por lo que el componente Timer se volverá a renderizar. Envolvemos esto dentro de useEffect (opens in a new tab) con una lista de dependencias vacía para que suceda solo una vez, en lugar de cada vez que se renderiza el componente.

  const secondsSinceUpdate = Math.floor(
    (Date.now() - lastUpdate.getTime()) / 1000
  )

  return (
    <span>{secondsSinceUpdate} seconds ago</span>
  )
}

Calcula el número de segundos desde la última actualización y devuélvelo.

Componente Greeter
const Greeter = () => {

Finalmente, llegamos a definir el componente.

  const chainId = useChainId()
  const account = useAccount()

Información sobre la cadena y la cuenta que estamos usando, cortesía de Wagmi (opens in a new tab). Debido a que este es un hook (use...), el componente se vuelve a renderizar cada vez que esta información cambia.

  const greeterAddr = chainId && contractAddrs[chainId] 

La dirección del contrato Greeter, que es undefined si no tenemos información de la cadena, o si estamos en una cadena sin ese contrato.

  const readResults = useReadContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: "greet", // Sin argumentos
  })

El hook useReadContract (opens in a new tab) llama a la función greet del contrato (opens in a new tab).

  const [ currentGreeting, setCurrentGreeting ] = 
    useState("Please wait while we fetch the greeting from the blockchain...")
  const [ newGreeting, setNewGreeting ] = useState("")

El hook useState (opens in a new tab) de React nos permite especificar una variable de estado, cuyo valor persiste de una renderización del componente a otra. El valor inicial es el parámetro, en este caso la cadena vacía.

El hook useState devuelve una lista con dos valores:

  1. El valor actual de la variable de estado.
  2. Una función para modificar la variable de estado cuando sea necesario. Como este es un hook, cada vez que se llama, el componente se renderiza de nuevo.

En este caso, estamos usando una variable de estado para el nuevo saludo que el usuario quiere establecer.

  const [ lastSetterAddress, setLastSetterAddress ] = useState("")

Si varios usuarios están usando el mismo contrato al mismo tiempo, podrían sobrescribir los saludos de los demás. Esto les parecería a los usuarios como si la aplicación estuviera funcionando mal. Si la aplicación muestra quién estableció el saludo por última vez, el usuario sabrá que fue otra persona y que la aplicación está funcionando correctamente.

  const [ status, setStatus ] = useState("")
  const [ statusTime, setStatusTime ] = useState(new Date())

A los usuarios les gusta ver que sus acciones tienen un efecto inmediato. Sin embargo, en una cadena de bloques, este no es el caso. Estas variables de estado nos permiten al menos mostrar algo a los usuarios para que sepan que su acción está en progreso.

  useEffect(() => {
    if (readResults.data) {
      setCurrentGreeting(readResults.data)
      setStatus("Greeting fetched from blockchain")
    }
  }, [readResults.data])

Si readResults anterior cambia los datos y no se establece en un valor falso (undefined, por ejemplo), actualiza el saludo actual al leído de la cadena de bloques. Además, actualiza el estado.

  useWatchContractEvent({
    address: greeterAddr,
    abi: greeterABI,
    eventName: 'SetGreeting',
    chainId,

Escucha los eventos SetGreeting.

    enabled: !!greeterAddr,

!!<value> significa que si el valor es false, o un valor que se evalúa como falso, como undefined, 0 o una cadena vacía, la expresión en general es false. Para cualquier otro valor, es true. Es una forma de convertir valores a booleanos, porque si no hay greeterAddr, no queremos escuchar eventos.

    onLogs: logs => {
      const greetingFromContract = logs[0].args.greeting
      setCurrentGreeting(greetingFromContract)
      setLastSetterAddress(logs[0].args.sender)
      updateStatus("Greeting updated by event")
    },
  })

Cuando vemos registros (lo que sucede cuando vemos un nuevo evento), significa que el saludo ha sido modificado. En ese caso, podemos actualizar currentGreeting y lastSetterAddress a los nuevos valores. Además, queremos actualizar la visualización del estado.

  const updateStatus = (newStatus: string) => {
    setStatus(newStatus)
    setStatusTime(new Date())
  }

Cuando actualizamos el estado queremos hacer dos cosas:

  1. Actualizar la cadena de estado (status)
  2. Actualizar la hora de la última actualización de estado (statusTime) a la hora actual.
  const greetingChange = (evt) =>
    setNewGreeting(evt.target.value)

Este es el controlador de eventos para los cambios en el nuevo campo de entrada de saludo. Podríamos especificar el tipo del parámetro evt, pero TypeScript es un lenguaje de tipado opcional. Como esta función se llama solo una vez, en un controlador de eventos HTML, no creo que sea necesario.

  const { writeContractAsync } = useWriteContract()

La función para escribir en un contrato. Es similar a writeContracts (opens in a new tab), pero permite mejores actualizaciones de estado.

  const simulation = useSimulateContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: 'setGreeting',
    args: [newGreeting],
    account: account.address    
  })

Este es el proceso para enviar una transacción de cadena de bloques desde la perspectiva del cliente:

  1. Enviar la transacción a un nodo en la cadena de bloques usando eth_estimateGas (opens in a new tab).
  2. Esperar una respuesta del nodo.
  3. Cuando se recibe la respuesta, pedir al usuario que firme la transacción a través de la billetera. Este paso tiene que ocurrir después de que se reciba la respuesta del nodo porque al usuario se le muestra el costo de gas de la transacción antes de firmarla.
  4. Esperar a que el usuario apruebe.
  5. Enviar la transacción de nuevo, esta vez usando eth_sendRawTransaction (opens in a new tab).

Es probable que el paso 2 tome una cantidad de tiempo perceptible, durante el cual los usuarios pueden preguntarse si su comando fue recibido por la interfaz de usuario y por qué aún no se les pide que firmen la transacción. Eso crea una mala experiencia de usuario (UX).

Una solución es enviar eth_estimateGas cada vez que cambia un parámetro. Luego, cuando el usuario realmente quiere enviar la transacción (en este caso presionando Update greeting), se conoce el costo del gas y el usuario puede ver la página de la billetera de inmediato.

  return (

Ahora finalmente podemos crear el HTML real para devolver.

    <>
      <h2>Greeter</h2>
      {currentGreeting}

Muestra el saludo actual.

      {lastSetterAddress && (
        <p>Last updated by {
          lastSetterAddress === account.address ? "you" : lastSetterAddress
        }</p>
      )}

Si sabemos quién estableció el saludo por última vez, muestra esa información. Greeter no realiza un seguimiento de esta información, y no queremos mirar hacia atrás en busca de eventos SetGreeting, por lo que solo la obtenemos una vez que se cambia el saludo mientras nos estamos ejecutando.

      <hr />      
      <input type="text"
        value={newGreeting}
        onChange={greetingChange}
      />      
      <br />

Este es el campo de texto de entrada donde el usuario puede establecer un nuevo saludo. Cada vez que el usuario presiona una tecla, llamamos a greetingChange, que llama a setNewGreeting. Dado que setNewGreeting proviene de useState, hace que el componente Greeter se vuelva a renderizar. Esto significa que:

  • Necesitamos especificar value para mantener el valor del nuevo saludo, porque de lo contrario volvería al valor predeterminado, la cadena vacía.
  • simulation también se actualiza cada vez que cambia newGreeting, lo que significa que obtendremos una simulación con el saludo correcto. Esto podría ser relevante porque el costo del gas depende del tamaño de los datos de la llamada, que depende de la longitud de la cadena.
      <button disabled={!simulation.data}

Solo habilita el botón una vez que tengamos la información que necesitamos para enviar la transacción.

        onClick={async () => {
          updateStatus("Please confirm in wallet...")

Actualiza el estado. En este punto, el usuario debe confirmar en la billetera.

          await writeContractAsync(simulation.data.request)
          updateStatus("Transaction sent, waiting for greeting to change...")
        }}
      >
        Update greeting
      </button>

writeContractAsync solo regresa después de que la transacción se envía realmente. Esto nos permite mostrar al usuario cuánto tiempo ha estado esperando la transacción para ser incluida en la cadena de bloques.

      <h4>Status: {status}</h4>
      <p>Updated <Timer lastUpdate={statusTime} /> </p>
    </>
  )
}

Muestra el estado y cuánto tiempo ha pasado desde que se actualizó.

export {Greeter}

Exporta el componente.

src/wagmi.ts

Finalmente, varias definiciones relacionadas con Wagmi están en src/wagmi.ts. No voy a explicar todo aquí, porque la mayor parte es código repetitivo que es poco probable que necesites cambiar.

import { http, webSocket, createConfig, fallback } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { injected } from 'wagmi/connectors'

export const config = createConfig({
  chains: [sepolia],

La configuración de Wagmi incluye las cadenas compatibles con esta aplicación. Puedes ver la lista de cadenas disponibles (opens in a new tab).

  connectors: [
    injected(),
  ],

Este conector (opens in a new tab) nos permite hablar con una billetera instalada en el navegador.

  transports: {
    [sepolia.id]: http()

El punto de conexión HTTP predeterminado que viene con Viem es lo suficientemente bueno. Si queremos una URL diferente, podemos usar http("https:// hostname ") o webSocket("wss:// hostname ").

  },
  multiInjectedProviderDiscovery: false,
})

Agregar otra cadena de bloques

En estos días hay muchas soluciones de escalado de capa 2 (L2) (opens in a new tab), y es posible que desees admitir algunas que Viem aún no admite. Para hacerlo, modificas src/wagmi.ts. Estas instrucciones explican cómo agregar Optimism Sepolia (opens in a new tab).

  1. Edita src/wagmi.ts

    A. Importa el tipo defineChain de Viem.

    import { defineChain } from 'viem'
    

    B. Agrega la definición de la red. Realmente no necesitas hacer esto para Optimism Sepolia, ya está en viem (opens in a new tab), pero de esta manera aprendes cómo agregar una cadena de bloques que no está en viem.

    C. Agrega la nueva cadena a la llamada createConfig.

  2. Edita src/App.tsx para comentar el cambio automático a Sepolia. En un sistema de producción, probablemente mostrarías botones con enlaces a cada una de las cadenas de bloques que admites.

  3. Edita src/Greeter.tsx para asegurarte de que la aplicación conozca la dirección de tus contratos en la nueva red.

    const contractAddrs: AddressPerBlockchainType = {
      // Optimism Sepolia
      11155420: "0x4dd85791923E9294E934271522f63875EAe5806f",
    
      // Sepolia
      11155111: "0x7143d5c190F048C8d19fe325b748b081903E3BF0",
    }
    
  4. En tu navegador.

    A. Navega a ChainList (opens in a new tab) y haz clic en uno de los botones en el lado derecho de la tabla para agregar la cadena a tu billetera.

    B. En la aplicación, haz clic en Disconnect (Desconectar) y luego vuelve a conectarte para cambiar la cadena de bloques. Hay formas más agradables de manejar esto, pero requerirían cambios en la aplicación.

Conclusión

Por supuesto, realmente no te importa proporcionar una interfaz de usuario para Greeter. Quieres crear una interfaz de usuario para tus propios contratos. Para crear tu propia aplicación, ejecuta estos pasos:

  1. Especifica crear una aplicación Wagmi.

    npm create wagmi
    
  2. Escribe y para continuar.

  3. Nombra la aplicación.

  4. Selecciona el marco de trabajo React.

  5. Selecciona la variante Vite.

Ahora ve y haz que tus contratos sean utilizables para todo el mundo.

Mira aquí para ver más de mi trabajo (opens in a new tab).