Pular para o conteúdo principal

Construindo uma interface de usuário para o seu contrato

TypeScript
React
Vite
Wagmi
frontend
Iniciante
Ori Pomerantz
1 de novembro de 2023
19 minutos de leitura

Você encontrou um recurso que precisamos no ecossistema Ethereum. Você escreveu os contratos inteligentes para implementá-lo e talvez até algum código relacionado que é executado offchain. Isso é ótimo! Infelizmente, sem uma interface de usuário, você não terá nenhum usuário, e a última vez que você escreveu um site, as pessoas usavam modems discados e o JavaScript era novidade.

Este artigo é para você. Presumo que você saiba programar e talvez um pouco de JavaScript e HTML, mas que suas habilidades de interface de usuário estejam enferrujadas e desatualizadas. Juntos, analisaremos um aplicativo moderno e simples para que você veja como isso é feito hoje em dia.

Por que isso é importante

Na teoria, você poderia simplesmente fazer com que as pessoas usassem o Etherscan (opens in a new tab) ou o Blockscout (opens in a new tab) para interagir com seus contratos. Isso é ótimo para os Ethereans experientes. Mas estamos tentando atender a mais um bilhão de pessoas (opens in a new tab). Isso não acontecerá sem uma ótima experiência de usuário, e uma interface de usuário amigável é uma grande parte disso.

Aplicativo Greeter

Há muita teoria por trás de como a interface de usuário moderna funciona, e muitos sites bons (opens in a new tab) que explicam isso (opens in a new tab). Em vez de repetir o excelente trabalho feito por esses sites, vou presumir que você prefere aprender fazendo e começar com um aplicativo com o qual você possa brincar. Você ainda precisa da teoria para fazer as coisas, e chegaremos lá - vamos apenas passar arquivo fonte por arquivo fonte e discutir as coisas à medida que chegarmos a elas.

Instalação

  1. O aplicativo usa a rede de teste Sepolia (opens in a new tab). Se necessário, obtenha ETH de teste da Sepolia e adicione a Sepolia à sua carteira (opens in a new tab).

  2. Clone o repositório do GitHub e instale os pacotes necessários.

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. O aplicativo usa pontos de acesso gratuitos, que têm limitações de desempenho. Se você quiser usar um provedor de Nó como serviço, substitua as URLs em src/wagmi.ts.

  4. Inicie o aplicativo.

    npm run dev
    
  5. Navegue até a URL mostrada pelo aplicativo. Na maioria dos casos, é http://localhost:5173/ (opens in a new tab).

  6. Você pode ver o código-fonte do contrato, uma versão modificada do Greeter do Hardhat, em um explorador de blockchain (opens in a new tab).

Passo a passo dos arquivos

index.html

Este arquivo é um modelo HTML padrão, exceto por esta linha, que importa o arquivo de script.

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

src/main.tsx

A extensão do arquivo indica que este é um componente React (opens in a new tab) escrito em TypeScript (opens in a new tab), uma extensão do JavaScript que suporta verificação de tipos (opens in a new tab). O TypeScript é compilado para JavaScript, então podemos usá-lo no lado do cliente.

Este arquivo é explicado principalmente caso você esteja interessado. Geralmente você não modifica este arquivo, mas sim o src/App.tsx e os arquivos que ele importa.

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

Importe o código da biblioteca que precisamos.

import App from './App.tsx'

Importe o componente React que implementa o aplicativo (veja abaixo).

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

Importe a configuração do Wagmi (opens in a new tab), que inclui a configuração da blockchain.

const queryClient = new QueryClient()

Cria uma nova instância do gerenciador de cache do React Query (opens in a new tab). Este objeto armazenará:

  • Chamadas RPC em cache
  • Leituras de contrato
  • Estado de reobtenção (refetching) em segundo plano

Precisamos do gerenciador de cache porque o Wagmi v3 usa o React Query internamente.

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

Crie o componente React raiz. O parâmetro para render é JSX (opens in a new tab), uma linguagem de extensão que usa tanto HTML quanto JavaScript/TypeScript. O ponto de exclamação aqui diz ao componente TypeScript: "você não sabe que document.getElementById('root') será um parâmetro válido para ReactDOM.createRoot, mas não se preocupe - eu sou o desenvolvedor e estou dizendo que haverá".

  <React.StrictMode>

O aplicativo vai dentro de um componente React.StrictMode (opens in a new tab). Este componente diz à biblioteca React para inserir verificações de depuração adicionais, o que é útil durante o desenvolvimento.

    <WagmiProvider config={config}>

O aplicativo também está dentro de um componente WagmiProvider (opens in a new tab). A biblioteca Wagmi (que vamos criar) (opens in a new tab) conecta as definições de interface de usuário do React com a biblioteca Viem (opens in a new tab) para escrever um aplicativo descentralizado (dapp) Ethereum.

      <QueryClientProvider client={queryClient}>

E, finalmente, adicione um provedor do React Query para que qualquer componente do aplicativo possa usar consultas em cache.

        <App />

Agora podemos ter o componente para o aplicativo, que realmente implementa a interface de usuário. O /> no final do componente diz ao React que este componente não tem nenhuma definição dentro dele, de acordo com o padrão XML.

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

Claro, temos que fechar os outros componentes.

src/App.tsx

Importe as bibliotecas que precisamos, bem como o componente Greeter.

const SEPOLIA_CHAIN_ID = 11155111

O ID da cadeia da Sepolia.

function App() {

Esta é a maneira padrão de criar um componente React: defina uma função que é chamada sempre que precisar ser renderizada. Esta função normalmente contém código TypeScript ou JavaScript, seguido por uma instrução return que retorna o código JSX.

  const connection = useConnection()

Use useConnection (opens in a new tab) para obter informações relacionadas à conexão atual, como o endereço e o chainId.

Por convenção, no React, funções chamadas use... são hooks (opens in a new tab). Essas funções não apenas retornam dados para o componente; elas também garantem que ele seja renderizado novamente (a função do componente é executada novamente e sua saída substitui a anterior no HTML) quando esses dados mudam.

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

Use useConnect (opens in a new tab) para obter informações sobre a conexão da carteira.

  const { disconnect } = useDisconnect()

Este hook (opens in a new tab) nos dá a função para desconectar da carteira.

  const { switchChain } = useSwitchChain()

Este hook (opens in a new tab) nos permite trocar de cadeia.

  useEffect(() => {

O hook do React useEffect (opens in a new tab) permite que você execute uma função sempre que o valor de uma variável mudar para sincronizar um sistema externo.

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

Se estivermos conectados, mas não à blockchain da Sepolia, mude para a Sepolia.

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

Execute a função novamente toda vez que o status da conexão ou o chainId da conexão mudar.

  return (
    <>

O JSX de um componente React deve retornar um único componente HTML. Quando temos vários componentes e não precisamos de um contêiner para envolver todos eles, usamos um componente vazio (<> ... </>) para combiná-los em um único componente.

Forneça informações sobre a conexão atual. Dentro do JSX, {<expression>} significa avaliar a expressão como JavaScript.

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

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

Esta é a maneira padrão de colocar instruções if dentro do JSX.

        <div>
          <Greeter />
          <hr />

O JSX segue o padrão XML, que é mais rigoroso que o HTML. Se uma tag não tiver uma tag de fechamento correspondente, ela deve ter uma barra (/) no final para terminá-la.

Aqui temos duas dessas tags, <Greeter /> (que na verdade contém o código HTML que se comunica com o contrato) e <hr /> para uma linha horizontal (opens in a new tab).

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

Se o usuário clicar neste botão, chame a função disconnect.

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

Se não estivermos conectados, mostre as opções necessárias para conectar à carteira.

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

Em connectors temos uma lista de conectores. Usamos map (opens in a new tab) para transformá-la em uma lista de botões JSX para exibir.

            <button
              key={connector.uid}

No JSX, é necessário que as tags "irmãs" (tags que descendem do mesmo pai) tenham identificadores diferentes.

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

Os botões do conector.

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

Forneça informações adicionais. A sintaxe da expressão <variable>?.<field> diz ao JavaScript que, se a variável estiver definida, avalie para esse campo. Se a variável não estiver definida, então esta expressão é avaliada como undefined.

A expressão error.message, quando não há erro, geraria uma exceção. Usar error?.message nos permite evitar esse problema.

src/Greeter.tsx

Este arquivo contém a maior parte da funcionalidade da interface de usuário. Ele inclui definições que normalmente estariam em vários arquivos, mas como este é um tutorial, o programa é otimizado para ser fácil de entender na primeira vez, em vez de focar em desempenho ou facilidade de manutenção.

Usamos essas funções de biblioteca. Novamente, elas são explicadas abaixo onde são usadas.

import { AddressType } from 'abitype'

A biblioteca abitype (opens in a new tab) nos fornece definições TypeScript para vários tipos de dados do 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

A ABI para o contrato Greeter. Se você estiver desenvolvendo os contratos e a interface de usuário ao mesmo tempo, normalmente os colocaria no mesmo repositório e usaria a ABI gerada pelo compilador Solidity como um arquivo em seu aplicativo. No entanto, isso não é necessário aqui porque o contrato já está desenvolvido e não mudará.

Usamos as const (opens in a new tab) para dizer ao TypeScript que esta é uma constante real. Normalmente, quando você especifica no JavaScript const x = {"a": 1}, você pode alterar o valor em x, você apenas não pode atribuir a ele.

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

O TypeScript é fortemente tipado. Usamos esta definição para especificar o endereço onde o contrato Greeter é implantado em diferentes cadeias. A chave é um número (o chainId) e o valor é um AddressType (um endereço).

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

O endereço do contrato na Sepolia (opens in a new tab).

Componente Timer

O componente Timer mostra o número de segundos desde um determinado momento. Isso é importante para fins de usabilidade. Quando os usuários fazem algo, eles esperam uma reação imediata. Em blockchains, isso geralmente é impossível porque nada acontece até que uma transação seja colocada em um bloco. Uma solução é mostrar quanto tempo se passou desde que o usuário executou a ação, para que o usuário possa decidir se o tempo necessário é razoável.

type TimerProps = {
  lastUpdate: Date
}

O componente Timer recebe um parâmetro, lastUpdate, que é o momento da última ação.

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

Precisamos ter um estado (uma variável vinculada ao componente) e atualizá-lo para que o componente funcione corretamente. Mas nunca precisamos lê-lo, então não se preocupe em criar uma variável.

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

A função setInterval (opens in a new tab) nos permite agendar uma função para ser executada periodicamente. Neste caso, a cada segundo. A função chama setNow para atualizar o estado, de modo que o componente Timer será renderizado novamente. Envolvemos isso dentro de useEffect (opens in a new tab) com uma lista de dependências vazia para que aconteça apenas uma vez, em vez de cada vez que o componente for renderizado.

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

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

Calcule o número de segundos desde a última atualização e retorne-o.

Componente Greeter
const Greeter = () => {

Finalmente, chegamos à definição do componente.

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

Informações sobre a cadeia e a conta que estamos usando, cortesia do Wagmi (opens in a new tab). Como este é um hook (use...), o componente é renderizado novamente sempre que essas informações mudam.

  const greeterAddr = chainId && contractAddrs[chainId] 

O endereço do contrato Greeter, que é undefined se não tivermos informações da cadeia, ou se estivermos em uma cadeia sem esse contrato.

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

O hook useReadContract (opens in a new tab) chama a função greet do contrato (opens in a new tab).

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

O hook useState (opens in a new tab) do React nos permite especificar uma variável de estado, cujo valor persiste de uma renderização do componente para outra. O valor inicial é o parâmetro, neste caso a string vazia.

O hook useState retorna uma lista com dois valores:

  1. O valor atual da variável de estado.
  2. Uma função para modificar a variável de estado quando necessário. Como este é um hook, toda vez que é chamado, o componente é renderizado novamente.

Neste caso, estamos usando uma variável de estado para a nova saudação que o usuário deseja definir.

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

Se vários usuários estiverem usando o mesmo contrato ao mesmo tempo, eles podem sobrescrever as saudações uns dos outros. Isso pareceria aos usuários como se o aplicativo estivesse com mau funcionamento. Se o aplicativo mostrar quem definiu a saudação por último, o usuário saberá que foi outra pessoa e que o aplicativo está funcionando corretamente.

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

Os usuários gostam de ver que suas ações têm um efeito imediato. No entanto, em uma blockchain, esse não é o caso. Essas variáveis de estado nos permitem pelo menos exibir algo aos usuários para que eles saibam que sua ação está em andamento.

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

Se readResults acima alterar os dados e não estiver definido como um valor falso (undefined, por exemplo), atualize a saudação atual para aquela lida da blockchain. Além disso, atualize o status.

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

Ouça os eventos SetGreeting.

    enabled: !!greeterAddr,

!!<value> significa que se o valor for false, ou um valor que seja avaliado como falso, como undefined, 0 ou uma string vazia, a expressão geral será false. Para qualquer outro valor, é true. É uma maneira de converter valores em booleanos, porque se não houver greeterAddr, não queremos ouvir eventos.

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

Quando vemos logs (o que acontece quando vemos um novo evento), significa que a saudação foi modificada. Nesse caso, podemos atualizar currentGreeting e lastSetterAddress para os novos valores. Além disso, queremos atualizar a exibição do status.

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

Quando atualizamos o status, queremos fazer duas coisas:

  1. Atualizar a string de status (status)
  2. Atualizar o momento da última atualização de status (statusTime) para agora.
  const greetingChange = (evt) =>
    setNewGreeting(evt.target.value)

Este é o manipulador de eventos para alterações no novo campo de entrada de saudação. Poderíamos especificar o tipo do parâmetro evt, mas o TypeScript é uma linguagem de tipo opcional. Como esta função é chamada apenas uma vez, em um manipulador de eventos HTML, não acho que seja necessário.

  const { writeContractAsync } = useWriteContract()

A função para gravar em um contrato. É semelhante a writeContracts (opens in a new tab), mas permite melhores atualizações de status.

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

Este é o processo para enviar uma transação na blockchain da perspectiva do cliente:

  1. Envie a transação para um nó na blockchain usando eth_estimateGas (opens in a new tab).
  2. Aguarde uma resposta do nó.
  3. Quando a resposta for recebida, peça ao usuário para assinar a transação através da carteira. Esta etapa tem que acontecer depois que a resposta do nó for recebida porque o custo do gás da transação é mostrado ao usuário antes de assiná-la.
  4. Aguarde a aprovação do usuário.
  5. Envie a transação novamente, desta vez usando eth_sendRawTransaction (opens in a new tab).

A etapa 2 provavelmente levará um tempo perceptível, durante o qual os usuários podem se perguntar se o comando deles foi recebido pela interface de usuário e por que ainda não estão sendo solicitados a assinar a transação. Isso cria uma experiência de usuário (UX) ruim.

Uma solução é enviar eth_estimateGas toda vez que um parâmetro mudar. Então, quando o usuário realmente quiser enviar a transação (neste caso, pressionando Update greeting), o custo do gás é conhecido e o usuário pode ver a página da carteira imediatamente.

  return (

Agora podemos finalmente criar o HTML real para retornar.

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

Mostre a saudação atual.

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

Se soubermos quem definiu a saudação por último, exiba essa informação. O Greeter não rastreia essas informações e não queremos olhar para trás em busca de eventos SetGreeting, então só as obtemos quando a saudação é alterada enquanto estamos em execução.

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

Este é o campo de texto de entrada onde o usuário pode definir uma nova saudação. Toda vez que o usuário pressiona uma tecla, chamamos greetingChange, que chama setNewGreeting. Como setNewGreeting vem de useState, isso faz com que o componente Greeter seja renderizado novamente. Isso significa que:

  • Precisamos especificar value para manter o valor da nova saudação, porque, caso contrário, ele voltaria ao padrão, a string vazia.
  • simulation também é atualizado toda vez que newGreeting muda, o que significa que obteremos uma simulação com a saudação correta. Isso pode ser relevante porque o custo do gás depende do tamanho dos dados da chamada, que depende do comprimento da string.
      <button disabled={!simulation.data}

Habilite o botão apenas quando tivermos as informações necessárias para enviar a transação.

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

Atualize o status. Neste ponto, o usuário precisa confirmar na carteira.

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

writeContractAsync só retorna depois que a transação é realmente enviada. Isso nos permite mostrar ao usuário há quanto tempo a transação está esperando para ser incluída na blockchain.

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

Mostre o status e quanto tempo se passou desde que foi atualizado.

export {Greeter}

Exporte o componente.

src/wagmi.ts

Finalmente, várias definições relacionadas ao Wagmi estão em src/wagmi.ts. Não vou explicar tudo aqui, porque a maior parte é um modelo padrão que você provavelmente não precisará alterar.

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

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

A configuração do Wagmi inclui as cadeias suportadas por este aplicativo. Você pode ver a lista de cadeias disponíveis (opens in a new tab).

  connectors: [
    injected(),
  ],

Este conector (opens in a new tab) nos permite falar com uma carteira instalada no navegador.

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

O endpoint HTTP padrão que vem com o Viem é bom o suficiente. Se quisermos uma URL diferente, podemos usar http("https:// hostname ") ou webSocket("wss:// hostname ").

  },
  multiInjectedProviderDiscovery: false,
})

Adicionando outra blockchain

Hoje em dia, existem muitas soluções de escalabilidade de L2 (opens in a new tab), e você pode querer suportar algumas que o Viem ainda não suporta. Para fazer isso, você modifica src/wagmi.ts. Estas instruções explicam como adicionar a Optimism Sepolia (opens in a new tab).

  1. Edite src/wagmi.ts

    A. Importe o tipo defineChain do Viem.

    import { defineChain } from 'viem'
    

    B. Adicione a definição da rede. Você não precisa realmente fazer isso para a Optimism Sepolia, ela já está em viem (opens in a new tab), mas desta forma você aprende como adicionar uma blockchain que não está em viem.

    C. Adicione a nova cadeia à chamada createConfig.

  2. Edite src/App.tsx para comentar a mudança automática para a Sepolia. Em um sistema de produção, você provavelmente mostraria botões com links para cada uma das blockchains que você suporta.

  3. Edite src/Greeter.tsx para garantir que o aplicativo conheça o endereço dos seus contratos na nova rede.

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

    A. Navegue até a ChainList (opens in a new tab) e clique em um dos botões no lado direito da tabela para adicionar a cadeia à sua carteira.

    B. No aplicativo, clique em Disconnect e reconecte para alterar a blockchain. Existem maneiras melhores de lidar com isso, mas elas exigiriam alterações no aplicativo.

Conclusão

Claro, você não se importa muito em fornecer uma interface de usuário para o Greeter. Você quer criar uma interface de usuário para seus próprios contratos. Para criar seu próprio aplicativo, execute estas etapas:

  1. Especifique para criar um aplicativo Wagmi.

    npm create wagmi
    
  2. Digite y para prosseguir.

  3. Dê um nome ao aplicativo.

  4. Selecione o framework React.

  5. Selecione a variante Vite.

Agora vá e torne seus contratos utilizáveis para o mundo todo.

Veja aqui mais do meu trabalho (opens in a new tab).