Componentes de servidor e agentes para aplicativos web3
Introdução
Na maioria dos casos, um aplicativo descentralizado (dapp) usa um servidor para distribuir o software, mas toda a interação real acontece entre o cliente (normalmente, o navegador da web) e a blockchain.
No entanto, há alguns casos em que um aplicativo se beneficiaria de ter um componente de servidor que é executado de forma independente. Esse servidor seria capaz de responder a eventos e a solicitações que vêm de outras fontes, como uma API, emitindo transações.
Existem várias tarefas possíveis que esse servidor poderia cumprir.
-
Detentor de estado secreto. Em jogos, muitas vezes é útil não ter todas as informações que o jogo conhece disponíveis para os jogadores. No entanto, não há segredos na blockchain, qualquer informação que esteja na blockchain é fácil de ser descoberta por qualquer pessoa. Portanto, se parte do estado do jogo deve ser mantida em segredo, ela deve ser armazenada em outro lugar (e possivelmente ter os efeitos desse estado verificados usando provas de conhecimento zero).
-
Oráculo centralizado. Se os riscos forem suficientemente baixos, um servidor externo que lê algumas informações online e depois as publica na cadeia pode ser bom o suficiente para ser usado como um oráculo.
-
Agente. Nada acontece na blockchain sem uma transação para ativá-la. Um servidor pode agir em nome de um usuário para realizar ações como arbitragem quando a oportunidade se apresentar.
Programa de exemplo
Você pode ver um servidor de exemplo no GitHub (opens in a new tab). Este servidor escuta eventos vindos deste contrato (opens in a new tab), uma versão modificada do Greeter do Hardhat. Quando a saudação é alterada, ele a altera de volta.
Para executá-lo:
-
Clone o repositório.
git clone https://github.com/qbzzt/20240715-server-component.git cd 20240715-server-component -
Instale os pacotes necessários. Se você ainda não o tem, instale o Node primeiro (opens in a new tab).
npm install -
Edite
.envpara especificar a chave privada de uma conta que tenha ETH na rede de teste Holesky. Se você não tem ETH na Holesky, você pode usar este faucet (opens in a new tab).PRIVATE_KEY=0x <private key goes here> -
Inicie o servidor.
npm start -
Acesse um explorador de blocos (opens in a new tab) e, usando um endereço diferente daquele que possui a chave privada, modifique a saudação. Veja que a saudação é automaticamente modificada de volta.
Como isso funciona?
A maneira mais fácil de entender como escrever um componente de servidor é analisar o exemplo linha por linha.
src/app.ts
A grande maioria do programa está contida em src/app.ts (opens in a new tab).
Criando os objetos de pré-requisito
import {
createPublicClient,
createWalletClient,
getContract,
http,
Address,
} from "viem"
Estas são as entidades do Viem (opens in a new tab) que precisamos, funções e o tipo Address (opens in a new tab). Este servidor é escrito em TypeScript (opens in a new tab), que é uma extensão do JavaScript que o torna fortemente tipado (opens in a new tab).
import { privateKeyToAccount } from "viem/accounts"
Esta função (opens in a new tab) nos permite gerar as informações da carteira, incluindo o endereço, correspondentes a uma chave privada.
import { holesky } from "viem/chains"
Para usar uma blockchain no Viem, você precisa importar sua definição. Neste caso, queremos nos conectar à blockchain de teste Holesky (opens in a new tab).
// É assim que adicionamos as definições do .env ao process.env.
import * as dotenv from "dotenv"
dotenv.config()
É assim que lemos o .env para o ambiente. Precisamos dele para a chave privada (veja mais adiante).
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 um contrato, precisamos do seu endereço e da para ele. Nós fornecemos ambos aqui.
No JavaScript (e, portanto, no TypeScript), você não pode atribuir um novo valor a uma constante, mas você pode modificar o objeto que está armazenado nela. Ao usar o sufixo as const, estamos dizendo ao TypeScript que a própria lista é constante e não pode ser alterada.
const publicClient = createPublicClient({
chain: holesky,
transport: http(),
})
Crie um cliente público (opens in a new tab) do Viem. Clientes públicos não têm uma chave privada anexada e, portanto, não podem enviar transações. Eles podem chamar funções view (opens in a new tab), ler saldos de contas, etc.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
As variáveis de ambiente estão disponíveis em process.env (opens in a new tab). No entanto, o TypeScript é fortemente tipado. Uma variável de ambiente pode ser qualquer string, ou vazia, então o tipo para uma variável de ambiente é string | undefined. No entanto, uma chave é definida no Viem como 0x${string} (0x seguido por uma string). Aqui dizemos ao TypeScript que a variável de ambiente PRIVATE_KEY será desse tipo. Se não for, teremos um erro de tempo de execução.
A função privateKeyToAccount (opens in a new tab) então usa essa chave privada para criar um objeto de conta completo.
const walletClient = createWalletClient({
account,
chain: holesky,
transport: http(),
})
Em seguida, usamos o objeto de conta para criar um cliente de carteira (opens in a new tab). Este cliente tem uma chave privada e um endereço, então ele pode ser usado para enviar transações.
const greeter = getContract({
address: greeterAddress,
abi: greeterABI,
client: { public: publicClient, wallet: walletClient },
})
Agora que temos todos os pré-requisitos, podemos finalmente criar uma instância de contrato (opens in a new tab). Usaremos esta instância de contrato para nos comunicarmos com o contrato onchain.
Lendo da blockchain
console.log(`Current greeting:`, await greeter.read.greet())
As funções do contrato que são apenas de leitura (view (opens in a new tab) e pure (opens in a new tab)) estão disponíveis em read. Neste caso, nós o usamos para acessar a função greet (opens in a new tab), que retorna a saudação.
O JavaScript é single-threaded, então quando disparamos um processo de longa duração, precisamos especificar que o fazemos de forma assíncrona (opens in a new tab). Chamar a blockchain, mesmo para uma operação apenas de leitura, requer uma viagem de ida e volta entre o computador e um nó da blockchain. Essa é a razão pela qual especificamos aqui que o código precisa usar await (aguardar) pelo resultado.
Se você estiver interessado em como isso funciona, você pode ler sobre isso aqui (opens in a new tab), mas em termos práticos, tudo o que você precisa saber é que você usa await nos resultados se iniciar uma operação que leva muito tempo, e que qualquer função que faça isso deve ser declarada como async.
Emitindo transações
const setGreeting = async (greeting: string): Promise<any> => {
Esta é a função que você chama para emitir uma transação que altera a saudação. Como esta é uma operação longa, a função é declarada como async. Devido à implementação interna, qualquer função async precisa retornar um objeto Promise. Neste caso, Promise<any> significa que não especificamos o que exatamente será retornado no Promise.
const txHash = await greeter.write.setGreeting([greeting])
O campo write da instância do contrato tem todas as funções que gravam no estado da blockchain (aquelas que exigem o envio de uma transação), como setGreeting (opens in a new tab). Os parâmetros, se houver, são fornecidos como uma lista, e a função retorna o hash da transação.
console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)
return txHash
}
Relate o hash da transação (como parte de um URL para o explorador de blocos para visualizá-la) e retorne-o.
Respondendo a eventos
greeter.watchEvent.SetGreeting({
A função watchEvent (opens in a new tab) permite especificar que uma função deve ser executada quando um evento é emitido. Se você se importa apenas com um tipo de evento (neste caso, SetGreeting), você pode usar esta sintaxe para se limitar a esse tipo de evento.
onLogs: logs => {
A função onLogs é chamada quando há entradas de log. No Ethereum, "log" e "evento" geralmente são intercambiáveis.
console.log(
`Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`
)
Poderia haver vários eventos, mas por simplicidade, nos importamos apenas com o primeiro. logs[0].args são os argumentos do evento, neste caso sender e greeting.
if (logs[0].args.sender != account.address)
setGreeting(`${account.address} insists on it being Hello!`)
}
})
Se o remetente não for este servidor, use setGreeting para alterar a saudação.
package.json
Este arquivo (opens in a new tab) controla a configuração do Node.js (opens in a new tab). Este artigo explica apenas as definições importantes.
{
"main": "dist/index.js",
Esta definição especifica qual arquivo JavaScript executar.
"scripts": {
"start": "tsc && node dist/app.js",
},
Os scripts são várias ações do aplicativo. Neste caso, o único que temos é start, que compila e depois executa o servidor. O comando tsc faz parte do pacote typescript e compila TypeScript em JavaScript. Se você quiser executá-lo manualmente, ele está localizado em node_modules/.bin. O segundo comando executa o servidor.
"type": "module",
Existem vários tipos de aplicativos de nó JavaScript. O tipo module nos permite ter await no código de nível superior, o que é importante quando você faz operações lentas (e, portanto, assíncronas).
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
},
Estes são pacotes que são necessários apenas para o desenvolvimento. Aqui precisamos do typescript e, como o estamos usando com o Node.js, também estamos obtendo os tipos para variáveis e objetos do nó, como process. A notação ^<version> (opens in a new tab) significa essa versão ou uma versão superior que não tenha alterações de quebra (breaking changes). Veja aqui (opens in a new tab) para mais informações sobre o significado dos números de versão.
"dependencies": {
"dotenv": "^16.4.5",
"viem": "2.14.1"
}
}
Estes são pacotes que são necessários em tempo de execução, ao executar dist/app.js.
Conclusão
O servidor centralizado que criamos aqui faz o seu trabalho, que é atuar como um agente para um usuário. Qualquer outra pessoa que queira que o dapp continue funcionando e esteja disposta a gastar o gás pode executar uma nova instância do servidor com seu próprio endereço.
No entanto, isso só funciona quando as ações do servidor centralizado podem ser facilmente verificadas. Se o servidor centralizado tiver alguma informação de estado secreta ou executar cálculos difíceis, ele é uma entidade centralizada na qual você precisa confiar para usar o aplicativo, o que é exatamente o que as blockchains tentam evitar. Em um artigo futuro, planejo mostrar como usar provas de conhecimento zero para contornar esse problema.