Ir para o conteúdo
·33 min de leitura

Ethereum para Desenvolvedores Web: Smart Contracts Sem o Hype

Os conceitos de Ethereum que todo dev web precisa: contas, transações, smart contracts, ABI encoding, ethers.js, WAGMI e leitura de dados on-chain sem rodar seu próprio nó.

Compartilhar:X / TwitterLinkedIn

A maioria do conteúdo "Ethereum para desenvolvedores" cai em duas categorias: analogias simplificadas demais que não te ajudam a construir nada, ou especificações profundas de protocolo que assumem que você já sabe o que é uma Merkle Patricia Trie. Nenhuma é útil se você é um desenvolvedor web que quer ler o saldo de um token, deixar um usuário assinar uma transação ou exibir metadados de NFT em um app React.

Este post é o meio-termo prático. Vou explicar exatamente o que acontece quando seu frontend conversa com o Ethereum, quais são as partes móveis, e como as ferramentas modernas (ethers.js, viem, WAGMI) mapeiam para conceitos que você já entende de construir aplicações web.

Sem metáforas sobre máquinas de venda. Sem "imagine um mundo onde..." Apenas o modelo técnico e o código.

O Modelo Mental#

Ethereum é uma máquina de estados replicada. Cada nó na rede mantém uma cópia idêntica do estado — um enorme armazenamento chave-valor que mapeia endereços para dados de conta. Quando você "envia uma transação", está propondo uma transição de estado. Se validadores suficientes concordam que é válida, o estado atualiza. É isso.

O estado em si é direto. É um mapeamento de endereços de 20 bytes para objetos de conta. Cada conta tem quatro campos:

  • nonce: Quantas transações esta conta enviou (para EOAs) ou quantos contratos ela criou (para contas de contrato). Isso previne ataques de replay.
  • balance: Quanto ETH esta conta possui, em wei (1 ETH = 10^18 wei). Sim, 18 casas decimais. Chegaremos nisso.
  • codeHash: Hash do código EVM associado a esta conta. Para EOAs, é o hash de uma string vazia. Para contratos, é o hash do bytecode.
  • storageRoot: Raiz de uma Merkle trie que contém os dados de armazenamento do contrato. Para EOAs, isso está vazio.

As duas primeiras são as que importam para desenvolvimento web diário. As duas últimas importam quando você está interagindo com contratos.

Dois Tipos de Contas#

Contas de Propriedade Externa (EOAs) são controladas por uma chave privada. São o que o MetaMask gerencia. Podem iniciar transações. Não têm código. Quando alguém diz "carteira", quer dizer uma EOA.

Contas de Contrato são controladas pelo seu código. Não podem iniciar transações — só podem executar em resposta a serem chamadas. Têm código e armazenamento. Quando alguém diz "smart contract", quer dizer isso. O código é imutável uma vez deployado (com algumas exceções via padrões de proxy, que é toda uma outra discussão).

A percepção crítica: toda mudança de estado no Ethereum começa com uma EOA assinando uma transação. Contratos podem chamar outros contratos, mas a cadeia de execução sempre começa com um humano (ou um bot) com uma chave privada.

Gas: Computação Tem um Preço#

Toda operação na EVM custa gas. Somar dois números custa 3 gas. Armazenar uma palavra de 32 bytes custa 20.000 gas (primeira vez) ou 5.000 gas (atualização). Ler armazenamento custa 2.100 gas (frio) ou 100 gas (quente, já acessado nesta transação).

Você não paga gas em "unidades de gas." Você paga em ETH. O custo total é:

custoTotal = gasUsado * precoGas

Após o EIP-1559 (upgrade London), a precificação de gas se tornou um sistema de duas partes:

custoTotal = gasUsado * (taxaBase + taxaPrioridade)
  • taxaBase: Definida pelo protocolo baseada na congestão da rede. É queimada (destruída).
  • taxaPrioridade (gorjeta): Vai para o validador. Gorjeta maior = inclusão mais rápida.
  • maxFeePerGas: O máximo que você está disposto a pagar por unidade de gas.
  • maxPriorityFeePerGas: A gorjeta máxima por unidade de gas.

Se taxaBase + taxaPrioridade > maxFeePerGas, sua transação espera até a taxaBase cair. É por isso que transações "ficam presas" durante alta congestão.

A implicação prática para desenvolvedores web: ler dados é gratuito. Escrever dados custa dinheiro. Esta é a diferença arquitetural mais importante entre Web2 e Web3. Todo SELECT é gratuito. Todo INSERT, UPDATE, DELETE custa dinheiro real. Projete seus dApps de acordo.

Transações#

Uma transação é uma estrutura de dados assinada. Aqui estão os campos que importam:

typescript
interface Transaction {
  // Quem recebe esta transação — um endereço EOA ou de contrato
  to: string;      // Endereço hex de 20 bytes, ou null para deploy de contrato
  // Quanto ETH enviar (em wei)
  value: bigint;   // Pode ser 0n para chamadas puras de contrato
  // Dados de chamada de função codificados, ou vazio para transferências simples de ETH
  data: string;    // Bytes codificados em hex, "0x" para transferências simples
  // Contador sequencial, previne ataques de replay
  nonce: number;   // Deve ser exatamente igual ao nonce atual do remetente
  // Limite de gas — máximo de gas que esta tx pode consumir
  gasLimit: bigint;
  // Parâmetros de taxa EIP-1559
  maxFeePerGas: bigint;
  maxPriorityFeePerGas: bigint;
  // Identificador de chain (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
  chainId: number;
}

O Ciclo de Vida de uma Transação#

  1. Construção: Sua app constrói o objeto de transação. Se você está chamando uma função de contrato, o campo data contém a chamada de função codificada em ABI (mais sobre isso abaixo).

  2. Assinatura: A chave privada assina a transação codificada em RLP, produzindo componentes de assinatura v, r, s. Isso prova que o remetente autorizou esta transação específica. O endereço do remetente é derivado da assinatura — não está explicitamente na transação.

  3. Broadcasting: A transação assinada é enviada a um nó RPC via eth_sendRawTransaction. O nó a valida (nonce correto, saldo suficiente, assinatura válida) e a adiciona ao seu mempool.

  4. Mempool: A transação fica em um pool de transações pendentes. Validadores selecionam transações para incluir no próximo bloco, geralmente preferindo gorjetas mais altas. É aqui que front-running acontece — outros atores podem ver sua transação pendente e submeter a sua própria com gorjeta mais alta para executar antes da sua.

  5. Inclusão: Um validador inclui sua transação em um bloco. A EVM a executa. Se tem sucesso, mudanças de estado são aplicadas. Se reverte, mudanças de estado são revertidas — mas você ainda paga pelo gas consumido até o ponto de reversão.

  6. Finalidade: No Ethereum proof-of-stake, um bloco se torna "finalizado" após duas épocas (~12.8 minutos). Antes da finalidade, reorganizações de chain são teoricamente possíveis (embora raras). A maioria dos apps trata 1-2 confirmações de bloco como "bom o suficiente" para operações não críticas.

Veja como enviar uma transferência simples de ETH com ethers.js v6:

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
 
const tx = await wallet.sendTransaction({
  to: "0xRecipientAddress...",
  value: ethers.parseEther("0.1"), // Converte "0.1" para wei (100000000000000000n)
});
 
console.log("Tx hash:", tx.hash);
 
// Esperar pela inclusão em um bloco
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = sucesso, 0 = revert

E o mesmo com viem:

typescript
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
 
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
 
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
const hash = await client.sendTransaction({
  to: "0xRecipientAddress...",
  value: parseEther("0.1"),
});
 
console.log("Tx hash:", hash);

Note a diferença: ethers retorna um objeto TransactionResponse com um método .wait(). Viem retorna apenas o hash — você usa uma chamada separada publicClient.waitForTransactionReceipt({ hash }) para esperar pela confirmação. Essa separação de responsabilidades é intencional no design do viem.

Smart Contracts#

Um smart contract é bytecode deployado mais armazenamento persistente em um endereço específico. Quando você "chama" um contrato, está enviando uma transação (ou fazendo uma chamada somente leitura) com o campo data definido para uma invocação de função codificada.

Bytecode e ABI#

O bytecode é o código EVM compilado. Você não interage com ele diretamente. É o que a EVM executa.

A ABI (Application Binary Interface) é uma descrição JSON da interface do contrato. Diz à sua biblioteca cliente como codificar chamadas de função e decodificar valores de retorno. Pense nela como uma spec OpenAPI para um contrato.

Aqui está um fragmento de uma ABI de token ERC-20:

typescript
const ERC20_ABI = [
  // Funções somente leitura (view/pure — sem custo de gas quando chamadas externamente)
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function totalSupply() view returns (uint256)",
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
 
  // Funções que mudam estado (requerem transação, custam gas)
  "function transfer(address to, uint256 amount) returns (bool)",
  "function approve(address spender, uint256 amount) returns (bool)",
  "function transferFrom(address from, address to, uint256 amount) returns (bool)",
 
  // Eventos (emitidos durante execução, armazenados em logs de transação)
  "event Transfer(address indexed from, address indexed to, uint256 value)",
  "event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;

Ethers.js aceita este formato de "ABI legível por humanos". Viem também pode usá-lo, mas frequentemente você trabalhará com a ABI JSON completa gerada pelo compilador Solidity. Ambos são equivalentes — o formato legível é apenas mais conveniente para interfaces comuns.

Como Chamadas de Função São Codificadas#

Esta é a parte que a maioria dos tutoriais pula, e é a parte que vai te economizar horas de debugging.

Quando você chama transfer("0xBob...", 1000000), o campo data da transação é definido como:

0xa9059cbb                                                         // Seletor de função
0000000000000000000000000xBob...000000000000000000000000             // address, preenchido até 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240     // uint256 amount (1000000 em hex)

O seletor de função são os primeiros 4 bytes do hash Keccak-256 da assinatura da função:

keccak256("transfer(address,uint256)") = 0xa9059cbb...
seletor = primeiros 4 bytes = 0xa9059cbb

Os bytes restantes são os argumentos codificados em ABI, cada um preenchido até 32 bytes. Este esquema de codificação é determinístico — a mesma chamada de função sempre produz o mesmo calldata.

Por que isso importa? Porque quando você vê dados brutos de transação no Etherscan e começa com 0xa9059cbb, você sabe que é uma chamada transfer. Quando sua transação reverte e a mensagem de erro é apenas um blob hex, você pode decodificá-la usando a ABI. E quando está construindo lotes de transações ou interagindo com contratos multicall, você estará codificando calldata manualmente.

Veja como codificar e decodificar manualmente com ethers.js:

typescript
import { ethers } from "ethers";
 
const iface = new ethers.Interface(ERC20_ABI);
 
// Codificar uma chamada de função
const calldata = iface.encodeFunctionData("transfer", [
  "0xBobAddress...",
  1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
 
// Decodificar calldata de volta para nome da função e args
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name);       // "transfer"
console.log(decoded.args[0]);    // "0xBobAddress..."
console.log(decoded.args[1]);    // 1000000n (BigInt)
 
// Decodificar dados de retorno de uma função
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // true

Slots de Armazenamento#

O armazenamento de contrato é um armazenamento chave-valor onde tanto chaves quanto valores são de 32 bytes. Solidity atribui slots de armazenamento sequencialmente começando do 0. A primeira variável de estado declarada vai no slot 0, a próxima no slot 1, e assim por diante. Mappings e arrays dinâmicos usam um esquema baseado em hash.

Você pode ler o armazenamento de qualquer contrato diretamente, mesmo que a variável esteja marcada como private em Solidity. "Private" só significa que outros contratos não podem lê-la — qualquer pessoa pode lê-la via eth_getStorageAt:

typescript
// Lendo o slot de armazenamento 0 de um contrato
const slot0 = await provider.getStorage(
  "0xContractAddress...",
  0
);
console.log(slot0); // Valor hex bruto de 32 bytes

É assim que exploradores de blocos mostram estado "interno" de contratos. Não há controle de acesso em leituras de armazenamento. Privacidade em uma blockchain pública é fundamentalmente limitada.

Eventos e Logs#

Eventos são a maneira do contrato emitir dados estruturados que são armazenados em logs de transação mas não no armazenamento do contrato. São mais baratos que escritas de armazenamento (375 gas para o primeiro tópico + 8 gas por byte de dados, vs 20.000 gas para uma escrita de armazenamento) e são projetados para serem consultados eficientemente.

Um evento pode ter até 3 parâmetros indexed (armazenados como "tópicos") e qualquer número de parâmetros não indexados (armazenados como "dados"). Parâmetros indexados podem ser filtrados — você pode perguntar "me dê todos os eventos Transfer onde to é este endereço." Parâmetros não indexados não podem ser filtrados; você tem que buscar todos os eventos correspondentes e filtrar no lado do cliente.

typescript
// Escutando eventos Transfer em tempo real com ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
 
contract.on("Transfer", (from, to, value, event) => {
  console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} tokens`);
  console.log("Block:", event.log.blockNumber);
  console.log("Tx hash:", event.log.transactionHash);
});
 
// Consultando eventos históricos
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=qualquer, to=específico
const events = await contract.queryFilter(filter, 19000000, 19100000); // intervalo de blocos
 
for (const event of events) {
  console.log("From:", event.args.from);
  console.log("Value:", event.args.value.toString());
}

O mesmo com viem:

typescript
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
// Logs históricos
const logs = await client.getLogs({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  args: {
    to: "0xMyAddress...",
  },
  fromBlock: 19000000n,
  toBlock: 19100000n,
});
 
for (const log of logs) {
  console.log("From:", log.args.from);
  console.log("To:", log.args.to);
  console.log("Value:", log.args.value);
}
 
// Observação em tempo real
const unwatch = client.watchEvent({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  onLogs: (logs) => {
    for (const log of logs) {
      console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
    }
  },
});
 
// Chame unwatch() para parar de escutar

Lendo Dados On-Chain#

É aqui que Ethereum se torna prático para desenvolvedores web. Você não precisa rodar um nó. Não precisa minerar. Nem precisa de uma carteira. Ler dados do Ethereum é gratuito, sem permissão, e funciona via uma API JSON-RPC simples.

JSON-RPC: A API HTTP do Ethereum#

Todo nó Ethereum expõe uma API JSON-RPC. É literalmente HTTP POST com corpos JSON. Não há nada específico de blockchain na camada de transporte.

typescript
// Isso é o que sua biblioteca faz por baixo
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "eth_call",
    params: [
      {
        to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
        data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
        // balanceOf(vitalik.eth)
      },
      "latest",
    ],
  }),
});
 
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }

Isso é um eth_call bruto. Ele simula uma execução de transação sem realmente submetê-la. Sem custo de gas. Sem mudança de estado. Apenas lê o valor de retorno. É assim que funções view e pure funcionam de fora — usam eth_call em vez de eth_sendRawTransaction.

Os Dois Métodos RPC Críticos#

eth_call: Simula execução. Gratuito. Sem mudança de estado. Usado para todas as operações de leitura — verificar saldos, ler preços, chamar funções view. Pode ser chamado em qualquer bloco histórico especificando um número de bloco em vez de "latest".

eth_sendRawTransaction: Submete uma transação assinada para inclusão em um bloco. Custa gas. Muda estado (se bem-sucedida). Usado para todas as operações de escrita — transferências, aprovações, swaps, mints.

Todo o resto na API JSON-RPC é ou uma variante desses dois ou um método utilitário (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).

Providers: Seu Portal para a Chain#

Você não roda seu próprio nó. Quase ninguém faz para desenvolvimento de aplicações. Em vez disso, você usa um serviço de provider:

  • Alchemy: O mais popular. Ótimo dashboard, suporte a webhooks, APIs aprimoradas para NFTs e metadados de tokens. Tier gratuito: ~300M unidades de computação/mês.
  • Infura: O original. Propriedade da ConsenSys. Confiável. Tier gratuito: 100K requisições/dia.
  • QuickNode: Bom para multi-chain. Modelo de precificação ligeiramente diferente.
  • Endpoints RPC públicos: https://rpc.ankr.com/eth, https://cloudflare-eth.com. Gratuitos mas com rate limiting e ocasionalmente não confiáveis. Ok para desenvolvimento, perigosos para produção.
  • Tenderly: Excelente para simulação e debugging. O RPC deles inclui um simulador de transações embutido.

Para produção, sempre configure pelo menos dois providers como fallbacks. Downtime de RPC é real e vai acontecer no pior momento possível.

typescript
import { ethers } from "ethers";
 
// ethers.js v6 fallback provider
const provider = new ethers.FallbackProvider([
  {
    provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
    priority: 1,
    stallTimeout: 2000,
    weight: 2,
  },
  {
    provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
    priority: 2,
    stallTimeout: 2000,
    weight: 1,
  },
]);

Lendo Estado de Contrato Gratuitamente#

Este é o poder que a maioria dos desenvolvedores Web2 não percebe: você pode ler qualquer dado público de qualquer contrato no Ethereum sem pagar nada, sem carteira, e sem nenhuma autenticação além de uma API key para seu provider RPC.

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
// Interface ERC-20 — apenas as funções de leitura
const erc20 = new ethers.Contract(
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  [
    "function name() view returns (string)",
    "function symbol() view returns (string)",
    "function decimals() view returns (uint8)",
    "function totalSupply() view returns (uint256)",
    "function balanceOf(address) view returns (uint256)",
  ],
  provider // Nota: provider, não signer. Somente leitura.
);
 
const [name, symbol, decimals, totalSupply] = await Promise.all([
  erc20.name(),
  erc20.symbol(),
  erc20.decimals(),
  erc20.totalSupply(),
]);
 
console.log(`${name} (${symbol})`);           // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`);          // 6 (NÃO 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
 
// Verificar o saldo de um endereço específico
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);

Sem carteira. Sem gas. Sem transação. Apenas um eth_call JSON-RPC por baixo. Isso é idêntico em conceito a fazer uma requisição GET para uma API REST. A blockchain é o banco de dados, o contrato é a API, e eth_call é sua query SELECT.

ethers.js v6#

ethers.js é o jQuery do Web3 — foi a primeira biblioteca que a maioria dos desenvolvedores aprendeu, e ainda é a mais amplamente usada. A versão 6 é uma melhoria significativa sobre a v5, com suporte nativo a BigInt (finalmente), módulos ESM e uma API mais limpa.

As Três Abstrações Centrais#

Provider: Uma conexão somente leitura com a blockchain. Pode chamar funções view, buscar blocos, ler logs. Não pode assinar ou enviar transações.

typescript
import { ethers } from "ethers";
 
// Conectar a um nó
const provider = new ethers.JsonRpcProvider("https://...");
 
// Consultas básicas
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");

Signer: Uma abstração sobre uma chave privada. Pode assinar transações e mensagens. Um Signer está sempre conectado a um Provider.

typescript
// De uma chave privada (lado servidor, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
 
// De uma carteira de navegador (lado cliente)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
 
// Obter o endereço
const address = await signer.getAddress();

Contract: Um proxy JavaScript para um contrato deployado. Métodos no objeto Contract correspondem a funções na ABI. Funções view retornam valores. Funções que mudam estado retornam um TransactionResponse.

typescript
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
 
// Ler (gratuito, retorna valor diretamente)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance é um bigint: 1000000000n (1000 USDC com 6 decimais)
 
// Para escrever, conecte com um signer
const usdcWithSigner = usdc.connect(signer);
 
// Escrever (custa gas, retorna TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Esperar pela inclusão no bloco
 
if (receipt.status === 0) {
  throw new Error("Transaction reverted");
}

TypeChain para Type Safety#

Interações brutas com ABI são baseadas em strings. Você pode errar o nome de uma função, passar tipos de argumentos errados ou interpretar mal valores de retorno. TypeChain gera tipos TypeScript a partir dos seus arquivos ABI:

typescript
// Sem TypeChain — sem verificação de tipos
const balance = await contract.balanceOf("0x...");
// balance é 'any'. Sem autocomplete. Fácil de usar errado.
 
// Com TypeChain — type safety completo
import { USDC__factory } from "./typechain";
 
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance é BigNumber. Autocomplete funciona. Erros de tipo capturados em compile time.

Para novos projetos, considere usar a inferência de tipos embutida do viem a partir de ABIs. Ela alcança o mesmo resultado sem um passo separado de geração de código.

Escutando Eventos#

Streaming de eventos em tempo real é crítico para dApps responsivos. ethers.js usa provedores WebSocket para isso:

typescript
// WebSocket para eventos em tempo real
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
 
// Escutar todos os eventos Transfer
contract.on("Transfer", (from, to, value, event) => {
  console.log(`Transfer: ${from} -> ${to}`);
  console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
 
// Escutar transferências PARA um endereço específico
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
  console.log(`Incoming transfer: ${ethers.formatUnits(value, 6)} USDC from ${from}`);
});
 
// Limpar quando terminar
contract.removeAllListeners();

WAGMI + Viem: O Stack Moderno#

WAGMI (We're All Gonna Make It) é uma biblioteca de React hooks para Ethereum. Viem é o cliente TypeScript subjacente que ele usa. Juntos, em grande parte substituíram ethers.js + web3-react como o stack padrão para desenvolvimento frontend de dApps.

Por que a mudança? Três razões: inferência completa de TypeScript a partir de ABIs (sem codegen necessário), tamanho de bundle menor, e React hooks que lidam com o gerenciamento bagunçado de estado assíncrono de interações com carteira.

Configuração#

typescript
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
 
export const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    injected(),
    walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
  ],
  transports: {
    [mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
    [sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
  },
});
typescript
// app/providers.tsx
"use client";
 
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
 
const queryClient = new QueryClient();
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Lendo Dados de Contrato#

useReadContract é o hook que você mais usará. Ele encapsula eth_call com cache React Query, refetching, e estados de loading/error:

typescript
"use client";
 
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
 
const ERC20_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "owner", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
] as const;
 
function TokenBalance({ address }: { address: `0x${string}` }) {
  const { data: balance, isLoading, error } = useReadContract({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
    abi: ERC20_ABI,
    functionName: "balanceOf",
    args: [address],
  });
 
  if (isLoading) return <span>Carregando...</span>;
  if (error) return <span>Erro: {error.message}</span>;
 
  // balance é tipado como bigint porque a ABI diz uint256
  return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}

Note o as const na ABI. Isso é crítico. Sem ele, TypeScript perde os tipos literais e balance se torna unknown em vez de bigint. Todo o sistema de inferência de tipos depende de const assertions.

Escrevendo em Contratos#

useWriteContract lida com o ciclo de vida completo: prompt da carteira, assinatura, broadcasting e rastreamento de confirmação.

typescript
"use client";
 
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
 
function SendTokens() {
  const { writeContract, data: hash, isPending, error } = useWriteContract();
 
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash,
  });
 
  function handleSend() {
    writeContract({
      address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      abi: ERC20_ABI,
      functionName: "transfer",
      args: [
        "0xRecipientAddress...",
        parseUnits("100", 6), // 100 USDC
      ],
    });
  }
 
  return (
    <div>
      <button onClick={handleSend} disabled={isPending}>
        {isPending ? "Confirme na carteira..." : "Enviar 100 USDC"}
      </button>
 
      {hash && <p>Transação: {hash}</p>}
      {isConfirming && <p>Aguardando confirmação...</p>}
      {isSuccess && <p>Transferência confirmada!</p>}
      {error && <p>Erro: {error.message}</p>}
    </div>
  );
}

Observando Eventos#

useWatchContractEvent configura uma assinatura WebSocket para monitoramento de eventos em tempo real:

typescript
"use client";
 
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
 
interface TransferEvent {
  from: string;
  to: string;
  value: string;
}
 
function LiveTransfers() {
  const [transfers, setTransfers] = useState<TransferEvent[]>([]);
 
  useWatchContractEvent({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    abi: ERC20_ABI,
    eventName: "Transfer",
    onLogs(logs) {
      const newTransfers = logs.map((log) => ({
        from: log.args.from as string,
        to: log.args.to as string,
        value: formatUnits(log.args.value as bigint, 6),
      }));
      setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
    },
  });
 
  return (
    <ul>
      {transfers.map((t, i) => (
        <li key={i}>
          {t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
        </li>
      ))}
    </ul>
  );
}

Padrões de Conectar Carteira#

Conectar a carteira de um usuário é o "login" do Web3. Exceto que não é login. Não há sessão, não há cookie, não há estado no lado do servidor. A conexão de carteira dá ao seu app permissão para ler o endereço do usuário e solicitar assinaturas de transação. Só isso.

A Interface Provider EIP-1193#

Toda carteira expõe uma interface padrão definida pelo EIP-1193. É um objeto com um método request:

typescript
interface EIP1193Provider {
  request(args: { method: string; params?: unknown[] }): Promise<unknown>;
  on(event: string, handler: (...args: unknown[]) => void): void;
  removeListener(event: string, handler: (...args: unknown[]) => void): void;
}

MetaMask injeta isso como window.ethereum. Outras carteiras ou injetam sua própria propriedade ou também usam window.ethereum (o que causa conflitos — o problema da "guerra de carteiras", parcialmente resolvido pelo EIP-6963).

typescript
// Interação de baixo nível com carteira (você não deveria fazer isso diretamente, mas é útil entender)
 
// Solicitar acesso à conta
const accounts = await window.ethereum.request({
  method: "eth_requestAccounts",
});
console.log("Connected address:", accounts[0]);
 
// Obter a chain atual
const chainId = await window.ethereum.request({
  method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
 
// Escutar mudanças de conta (usuário troca contas no MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
  if (accounts.length === 0) {
    console.log("Carteira desconectada");
  } else {
    console.log("Trocou para:", accounts[0]);
  }
});
 
// Escutar mudanças de chain (usuário troca de rede)
window.ethereum.on("chainChanged", (chainId: string) => {
  // A abordagem recomendada é recarregar a página
  window.location.reload();
});

EIP-6963: Descoberta Multi-Carteira#

A antiga abordagem window.ethereum quebra quando usuários têm múltiplas carteiras instaladas. Qual recebe window.ethereum? A última a injetar? A primeira? É uma condição de corrida.

EIP-6963 corrige isso com um protocolo de descoberta baseado em eventos do navegador:

typescript
// Descobrindo todas as carteiras disponíveis
interface EIP6963ProviderDetail {
  info: {
    uuid: string;
    name: string;
    icon: string;
    rdns: string;  // Nome de domínio reverso, ex: "io.metamask"
  };
  provider: EIP1193Provider;
}
 
const wallets: EIP6963ProviderDetail[] = [];
 
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
  wallets.push(event.detail);
});
 
// Solicitar que todas as carteiras se anunciem
window.dispatchEvent(new Event("eip6963:requestProvider"));
 
// Agora 'wallets' contém todas as carteiras instaladas com seus nomes e ícones
// Você pode mostrar uma UI de seleção de carteira

WAGMI lida com tudo isso para você. Quando usa o conector injected(), ele automaticamente usa EIP-6963 se disponível e faz fallback para window.ethereum.

WalletConnect#

WalletConnect é um protocolo que conecta carteiras mobile a dApps desktop via um servidor relay. O usuário escaneia um QR code com sua carteira mobile, estabelecendo uma conexão criptografada. Requisições de transação são retransmitidas do seu dApp para o telefone.

Com WAGMI, é apenas mais um conector:

typescript
import { walletConnect } from "wagmi/connectors";
 
const connector = walletConnect({
  projectId: "YOUR_PROJECT_ID", // Obtenha em cloud.walletconnect.com
  showQrModal: true,
});

Lidando com Troca de Chain#

Usuários frequentemente estão na rede errada. Seu dApp está na Mainnet, eles estão conectados na Sepolia. Ou estão na Polygon e você precisa da Mainnet. WAGMI fornece useSwitchChain:

typescript
"use client";
 
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
 
function NetworkGuard({ children }: { children: React.ReactNode }) {
  const { chain } = useAccount();
  const { switchChain, isPending } = useSwitchChain();
 
  if (!chain) return <p>Por favor conecte sua carteira</p>;
 
  if (chain.id !== mainnet.id) {
    return (
      <div>
        <p>Por favor mude para Ethereum Mainnet</p>
        <button
          onClick={() => switchChain({ chainId: mainnet.id })}
          disabled={isPending}
        >
          {isPending ? "Trocando..." : "Trocar Rede"}
        </button>
      </div>
    );
  }
 
  return <>{children}</>;
}

IPFS e Metadados#

NFTs não armazenam imagens on-chain. A blockchain armazena uma URI que aponta para um arquivo JSON de metadados, que por sua vez contém uma URL para a imagem. O padrão, definido pela função tokenURI do ERC-721:

Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"

Esse arquivo JSON segue um schema padrão:

json
{
  "name": "Cool NFT #42",
  "description": "Um NFT muito legal",
  "image": "ipfs://QmImageHash...",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Legendary" }
  ]
}

CID IPFS vs URL#

Endereços IPFS usam Content Identifiers (CIDs) — hashes do próprio conteúdo. ipfs://QmXyz... significa "o conteúdo cujo hash é QmXyz...". Isso é armazenamento endereçado por conteúdo: a URI é derivada do conteúdo, então o conteúdo nunca pode mudar sem mudar a URI. Esta é a garantia de imutabilidade em que NFTs confiam (quando realmente usam IPFS — muitos usam URLs centralizadas, o que é um sinal de alerta).

Para exibir conteúdo IPFS em um navegador, você precisa de um gateway que traduz URIs IPFS para HTTP:

typescript
function ipfsToHttp(uri: string): string {
  if (uri.startsWith("ipfs://")) {
    const cid = uri.replace("ipfs://", "");
    return `https://ipfs.io/ipfs/${cid}`;
    // Ou use um gateway dedicado:
    // return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
  }
  return uri;
}
 
// Buscando metadados de NFT
async function getNftMetadata(
  contractAddress: string,
  tokenId: bigint,
  provider: ethers.Provider
) {
  const contract = new ethers.Contract(
    contractAddress,
    ["function tokenURI(uint256 tokenId) view returns (string)"],
    provider
  );
 
  const tokenUri = await contract.tokenURI(tokenId);
  const httpUri = ipfsToHttp(tokenUri);
 
  const response = await fetch(httpUri);
  const metadata = await response.json();
 
  return {
    name: metadata.name,
    description: metadata.description,
    image: ipfsToHttp(metadata.image),
    attributes: metadata.attributes,
  };
}

Serviços de Pinning#

IPFS é uma rede peer-to-peer. O conteúdo só permanece disponível enquanto alguém está hospedando ("pinning") ele. Se você faz upload de uma imagem de NFT para IPFS e depois desliga seu nó, o conteúdo desaparece.

Serviços de pinning mantêm seu conteúdo disponível:

  • Pinata: O mais popular. API simples. Tier gratuito generoso (1GB). Gateways dedicados para carregamento mais rápido.
  • NFT.Storage: Gratuito, apoiado pela Protocol Labs (os criadores do IPFS). Projetado especificamente para metadados de NFT. Usa Filecoin para persistência de longo prazo.
  • Web3.Storage: Similar ao NFT.Storage, mais de propósito geral.
typescript
// Upload para Pinata
async function pinToIpfs(file: File): Promise<string> {
  const formData = new FormData();
  formData.append("file", file);
 
  const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PINATA_JWT}`,
    },
    body: formData,
  });
 
  const result = await response.json();
  return `ipfs://${result.IpfsHash}`; // Retorna CID
}

O Problema da Indexação#

Aqui está o segredo sujo do desenvolvimento blockchain: você não pode consultar dados históricos eficientemente de um nó RPC.

Quer todos os eventos Transfer de um token no último ano? Vai precisar escanear milhões de blocos com eth_getLogs, paginando em chunks de 2.000-10.000 blocos (o máximo varia por provider). São milhares de chamadas RPC. Vai levar minutos a horas e queimar sua cota de API.

Quer todos os tokens de um endereço específico? Não há uma única chamada RPC para isso. Você precisaria escanear todo evento Transfer de todo contrato ERC-20, rastreando saldos. Isso não é viável.

Quer todos os NFTs de uma carteira? Mesmo problema. Precisa escanear todo evento Transfer ERC-721 em todo contrato NFT.

A blockchain é uma estrutura de dados otimizada para escrita. É excelente em processar novas transações. É terrível em responder queries históricas. Este é o descompasso fundamental entre o que UIs de dApps precisam e o que a chain fornece nativamente.

O Protocolo The Graph#

The Graph é um protocolo de indexação descentralizado. Você escreve um "subgraph" — um schema e um conjunto de handlers de eventos — e The Graph indexa a chain e serve os dados via uma API GraphQL.

graphql
# Schema do subgraph (schema.graphql)
type Transfer @entity {
  id: Bytes!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
}
 
type Account @entity {
  id: Bytes!
  balance: BigInt!
  transfersFrom: [Transfer!]! @derivedFrom(field: "from")
  transfersTo: [Transfer!]! @derivedFrom(field: "to")
}
typescript
// Consultando um subgraph do seu frontend
const SUBGRAPH_URL =
  "https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
 
async function getRecentTransfers(address: string) {
  const query = `
    query GetTransfers($address: Bytes!) {
      transfers(
        where: { from: $address }
        orderBy: blockNumber
        orderDirection: desc
        first: 100
      ) {
        id
        from
        to
        value
        blockNumber
        timestamp
      }
    }
  `;
 
  const response = await fetch(SUBGRAPH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables: { address } }),
  });
 
  const { data } = await response.json();
  return data.transfers;
}

O tradeoff: The Graph adiciona latência (tipicamente 1-2 blocos atrás do head da chain) e outra dependência. A rede descentralizada também tem custos de indexação (você paga em tokens GRT). Para projetos menores, o serviço hospedado (Subgraph Studio) é gratuito.

APIs Aprimoradas Alchemy e Moralis#

Se você não quer manter um subgraph, tanto Alchemy quanto Moralis oferecem APIs pré-indexadas que respondem queries comuns diretamente:

typescript
// Alchemy: Obter todos os saldos de tokens ERC-20 para um endereço
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "alchemy_getTokenBalances",
      params: ["0xAddress...", "erc20"],
    }),
  }
);
 
// Retorna TODOS os saldos de tokens ERC-20 em uma chamada
// vs. escanear o balanceOf() de todo possível contrato ERC-20
typescript
// Alchemy: Obter todos os NFTs de um endereço
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
 
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
  console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}

Essas APIs são proprietárias e centralizadas. Você está trocando descentralização por experiência de desenvolvedor. Para a maioria dos dApps, é um tradeoff que vale a pena. Seus usuários não se importam se a visualização do portfólio vem de um subgraph ou do banco de dados da Alchemy. Eles se importam que carregue em 200ms em vez de 30 segundos.

Armadilhas Comuns#

Após lançar vários dApps em produção e debugar código de outras equipes, estes são os erros que vejo repetidamente. Cada um deles me mordeu pessoalmente.

BigInt em Todo Lugar#

Ethereum lida com números muito grandes. Saldos de ETH são em wei (10^18). Supplies de tokens podem ser 10^27 ou mais. Number do JavaScript só pode representar inteiros com segurança até 2^53 - 1 (cerca de 9 * 10^15). Isso não é suficiente para quantidades em wei.

typescript
// ERRADO — perda silenciosa de precisão
const balance = 1000000000000000000; // 1 ETH em wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — parece certo, mas...
 
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance);          // 100000000000000000000 — ERRADO! Arredondado para cima.
console.log(largeBalance === 100000000000000000000); // true — corrupção de dados
 
// CORRETO — use BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — correto
 
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correto

Regras para BigInt em código de dApp:

  1. Nunca converta quantidades em wei para Number. Use BigInt em todo lugar, converta para strings legíveis apenas para exibição.
  2. Nunca use Math.floor, Math.round, etc. em BigInts. Não funcionam. Use divisão inteira: amount / 10n ** 6n.
  3. JSON não suporta BigInt. Se serializar estado que inclui BigInts, você precisa de um serializador customizado: JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v).
  4. Use funções de formatação de bibliotecas. ethers.formatEther(), ethers.formatUnits(), formatEther() e formatUnits() do viem. Elas lidam com a conversão corretamente.
typescript
import { formatUnits, parseUnits } from "viem";
 
// Exibição: BigInt → string legível
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
 
// Input: string legível → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
 
// USDC tem 6 decimais, não 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"

Operações Assíncronas de Carteira#

Toda interação com carteira é assíncrona e pode falhar de maneiras que seu app precisa lidar graciosamente:

typescript
// O usuário pode rejeitar qualquer prompt de carteira
try {
  const tx = await writeContract({
    address: contractAddress,
    abi: ERC20_ABI,
    functionName: "approve",
    args: [spenderAddress, amount],
  });
} catch (error) {
  if (error.code === 4001) {
    // Usuário rejeitou a transação na carteira
    // Isso é normal — não é um erro para reportar
    showToast("Transação cancelada");
  } else if (error.code === -32603) {
    // Erro interno JSON-RPC — frequentemente significa que a transação reverteria
    showToast("Transação falharia. Verifique seu saldo.");
  } else {
    // Erro inesperado
    console.error("Erro na transação:", error);
    showToast("Algo deu errado. Por favor tente novamente.");
  }
}

Armadilhas assíncronas chave:

  • Prompts de carteira são bloqueantes do lado do usuário. O await no seu código pode levar 30 segundos enquanto o usuário lê os detalhes da transação no MetaMask. Não mostre um spinner de carregamento que os faça pensar que algo quebrou.
  • O usuário pode trocar de conta durante a interação. Você solicita aprovação da Conta A, o usuário troca para Conta B, depois aprova. Agora Conta B aprovou mas você está prestes a enviar uma transação da Conta A. Sempre re-verifique a conta conectada antes de operações críticas.
  • Padrões de escrita em duas etapas são comuns. Muitas operações DeFi requerem approve + execute. O usuário precisa assinar duas transações. Se aprovam mas não executam, você precisa verificar o estado de allowance e pular o passo de aprovação da próxima vez.

Erros de Rede Incompatível#

Este desperdiça mais tempo de debugging do que qualquer outro problema. Seu contrato está na Mainnet. Sua carteira está na Sepolia. Seu provider RPC aponta para Polygon. Três redes diferentes, três estados diferentes, três blockchains completamente não relacionadas. E a mensagem de erro geralmente é inútil — "execution reverted" ou "contract not found."

typescript
// Verificação defensiva de chain
import { useAccount, useChainId } from "wagmi";
 
function useRequireChain(requiredChainId: number) {
  const chainId = useChainId();
  const { isConnected } = useAccount();
 
  if (!isConnected) {
    return { ready: false, error: "Por favor conecte sua carteira" };
  }
 
  if (chainId !== requiredChainId) {
    return {
      ready: false,
      error: `Por favor mude para ${getChainName(requiredChainId)}. Você está na ${getChainName(chainId)}.`,
    };
  }
 
  return { ready: true, error: null };
}

Front-Running em DeFi#

Quando você submete um swap em uma DEX, sua transação pendente é visível no mempool. Um bot pode ver sua operação, fazer front-run empurrando o preço para cima, deixar sua operação executar a um preço pior, e então vender imediatamente depois por lucro. Isso é chamado de "ataque sanduíche."

Como desenvolvedor frontend, você não pode prevenir isso inteiramente, mas pode mitigar:

typescript
// Definindo tolerância de slippage em um swap estilo Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% de tolerância de slippage
 
// Usando um deadline para prevenir transações pendentes de longa duração
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minutos
 
await router.swapExactTokensForTokens(
  amountIn,
  amountOutMin,  // Output mínimo aceitável — reverte se receberíamos menos
  [tokenA, tokenB],
  userAddress,
  deadline,       // Reverte se não executar em 20 minutos
);

Para transações de alto valor, considere usar o Flashbots Protect RPC, que envia transações diretamente para construtores de blocos em vez do mempool público. Isso previne ataques sanduíche inteiramente porque bots nunca veem sua transação pendente:

typescript
// Usando Flashbots Protect como seu endpoint RPC
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");

Confusão com Decimais#

Nem todos os tokens têm 18 decimais. USDC e USDT têm 6. WBTC tem 8. Alguns tokens têm 0, 2 ou decimais arbitrários. Sempre leia o decimals() do contrato antes de formatar valores:

typescript
async function formatTokenAmount(
  tokenAddress: string,
  rawAmount: bigint,
  provider: ethers.Provider
): Promise<string> {
  const contract = new ethers.Contract(
    tokenAddress,
    ["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
    provider
  );
 
  const [decimals, symbol] = await Promise.all([
    contract.decimals(),
    contract.symbol(),
  ]);
 
  return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
 
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"

Falhas de Estimativa de Gas#

Quando estimateGas falha, geralmente significa que a transação reverteria. Mas a mensagem de erro frequentemente é apenas "cannot estimate gas" sem indicação do porquê. Use eth_call para simular a transação e obter o motivo real da reversão:

typescript
import { createPublicClient, http, decodeFunctionResult } from "viem";
 
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
  try {
    await client.call({
      account: tx.from,
      to: tx.to,
      data: tx.data,
      value: tx.value,
    });
    return null; // Sem erro — transação teria sucesso
  } catch (error) {
    // Decodificar o motivo da reversão
    if (error.data) {
      // Strings de revert comuns
      if (error.data.startsWith("0x08c379a0")) {
        // Error(string) — revert padrão com mensagem
        const reason = decodeAbiParameters(
          [{ type: "string" }],
          `0x${error.data.slice(10)}`
        );
        return `Revert: ${reason[0]}`;
      }
    }
    return error.message;
  }
}

Juntando Tudo#

Aqui está um componente React completo e mínimo que conecta uma carteira, lê um saldo de token e envia uma transferência. Este é o esqueleto de todo dApp:

typescript
"use client";
 
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
 
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
 
const USDC_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "", type: "bool" }],
  },
] as const;
 
export function TokenDashboard() {
  const { address, isConnected } = useAccount();
  const { connect } = useConnect();
  const { disconnect } = useDisconnect();
 
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
 
  // Ler saldo — só roda quando address está definido
  const { data: balance, refetch: refetchBalance } = useReadContract({
    address: USDC_ADDRESS,
    abi: USDC_ABI,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });
 
  // Escrita: transferir tokens
  const {
    writeContract,
    data: txHash,
    isPending: isSigning,
    error: writeError,
  } = useWriteContract();
 
  // Esperar pela confirmação
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  });
 
  // Rebuscar saldo após confirmação
  if (isSuccess) {
    refetchBalance();
  }
 
  if (!isConnected) {
    return (
      <button onClick={() => connect({ connector: injected() })}>
        Conectar Carteira
      </button>
    );
  }
 
  return (
    <div>
      <p>Conectado: {address}</p>
      <p>
        Saldo USDC:{" "}
        {balance !== undefined ? formatUnits(balance, 6) : "Carregando..."}
      </p>
 
      <div>
        <input
          placeholder="Endereço do destinatário (0x...)"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          placeholder="Quantidade (ex: 100)"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button
          onClick={() => {
            writeContract({
              address: USDC_ADDRESS,
              abi: USDC_ABI,
              functionName: "transfer",
              args: [recipient as `0x${string}`, parseUnits(amount, 6)],
            });
          }}
          disabled={isSigning || isConfirming}
        >
          {isSigning
            ? "Confirme na carteira..."
            : isConfirming
              ? "Confirmando..."
              : "Enviar USDC"}
        </button>
      </div>
 
      {writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
      {isSuccess && <p style={{ color: "green" }}>Transferência confirmada!</p>}
      {txHash && (
        <a
          href={`https://etherscan.io/tx/${txHash}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          Ver no Etherscan
        </a>
      )}
 
      <button onClick={() => disconnect()}>Desconectar</button>
    </div>
  );
}

Para Onde Ir Daqui#

Este post cobriu os conceitos e ferramentas essenciais para desenvolvedores web entrando no Ethereum. Há muito mais profundidade em cada área:

  • Solidity: Se você quer escrever contratos, não apenas interagir com eles. A documentação oficial e os cursos do Patrick Collins são os melhores pontos de partida.
  • Padrões ERC: ERC-20 (tokens fungíveis), ERC-721 (NFTs), ERC-1155 (multi-token), ERC-4626 (vaults tokenizados). Cada um define uma interface padrão que todos os contratos naquela categoria implementam.
  • Layer 2s: Arbitrum, Optimism, Base, zkSync. Mesma experiência de desenvolvedor, custos de gas menores, suposições de confiança ligeiramente diferentes. Seu código ethers.js e viem funciona de forma idêntica — apenas mude o chain ID e URL RPC.
  • Account Abstraction (ERC-4337): A próxima evolução de UX de carteira. Carteiras de smart contract que suportam patrocínio de gas, recuperação social e transações em lote. É para onde o padrão "conectar carteira" está indo.
  • MEV e ordenação de transações: Se você está construindo DeFi, entender Maximal Extractable Value não é opcional. Os docs do Flashbots são o recurso canônico.

O ecossistema blockchain se move rápido, mas os fundamentos neste post — contas, transações, codificação ABI, chamadas RPC, indexação de eventos — não mudaram desde 2015 e não vão mudar tão cedo. Aprenda-os bem e todo o resto é apenas superfície de API.

Posts Relacionados