Construindo uma interface de usuário para o seu contrato
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
-
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).
-
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 -
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. -
Inicie o aplicativo.
npm run dev -
Navegue até a URL mostrada pelo aplicativo. Na maioria dos casos, é http://localhost:5173/ (opens in a new tab).
-
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
import {
useConnect,
useConnection,
useDisconnect,
useSwitchChain
} from 'wagmi'
import { useEffect } from 'react'
import { Greeter } from './Greeter'
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.
<h2>Connection</h2>
<div>
status: {connection.status}
<br />
addresses: {JSON.stringify(connection.addresses)}
<br />
chainId: {connection.chainId}
</div>
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.
import {
useState,
useEffect,
} from 'react'
import { useChainId,
useAccount,
useReadContract,
useWriteContract,
useWatchContractEvent,
useSimulateContract
} from 'wagmi'
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:
- O valor atual da variável de estado.
- 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:
- Atualizar a string de status (
status) - 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:
- Envie a transação para um nó na blockchain usando
eth_estimateGas(opens in a new tab). - Aguarde uma resposta do nó.
- 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.
- Aguarde a aprovação do usuário.
- 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
valuepara manter o valor da nova saudação, porque, caso contrário, ele voltaria ao padrão, a string vazia. simulationtambém é atualizado toda vez quenewGreetingmuda, 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).
-
Edite
src/wagmi.tsA. Importe o tipo
defineChaindo 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á emviem.const optimismSepolia = defineChain({ id: 11_155_420, name: 'OP Sepolia', nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: ['https://sepolia.optimism.io'], webSocket: ['wss://optimism-sepolia.drpc.org'], }, }, blockExplorers: { default: { name: 'Blockscout', url: 'https://optimism-sepolia.blockscout.com', apiUrl: 'https://optimism-sepolia.blockscout.com/api', } }, })C. Adicione a nova cadeia à chamada
createConfig.export const config = createConfig({ chains: [sepolia, optimismSepolia], connectors: [ injected(), ], transports: { [optimismSepolia.id]: http(), [sepolia.id]: http() }, multiInjectedProviderDiscovery: false, }) -
Edite
src/App.tsxpara 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./* useEffect(() => { if (connection.status === 'connected' && connection.chainId !== SEPOLIA_CHAIN_ID ) { switchChain({ chainId: SEPOLIA_CHAIN_ID }) } }, [connection.status, connection.chainId]) */ -
Edite
src/Greeter.tsxpara 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", } -
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:
-
Especifique para criar um aplicativo Wagmi.
npm create wagmi -
Digite
ypara prosseguir. -
Dê um nome ao aplicativo.
-
Selecione o framework React.
-
Selecione a variante Vite.
Agora vá e torne seus contratos utilizáveis para o mundo todo.