Ethereum para desarrolladores web: Smart Contracts sin el hype
Los conceptos de Ethereum que todo desarrollador web necesita: cuentas, transacciones, smart contracts, codificación ABI, ethers.js, WAGMI y lectura de datos on-chain sin correr tu propio nodo.
La mayoría del contenido "Ethereum para desarrolladores" cae en dos categorías: analogías sobresimplificadas que no te ayudan a construir nada, o especificaciones profundas del protocolo que asumen que ya sabes qué es un Merkle Patricia Trie. Ninguna es útil si eres un desarrollador web que quiere leer el balance de un token, permitir que un usuario firme una transacción, o mostrar metadatos de NFT en una app React.
Este post es el punto medio práctico. Voy a explicar exactamente qué sucede cuando tu frontend habla con Ethereum, cuáles son las partes móviles, y cómo las herramientas modernas (ethers.js, viem, WAGMI) se mapean a conceptos que ya entiendes de construir aplicaciones web.
Sin metáforas sobre máquinas expendedoras. Sin "imagina un mundo donde..." Solo el modelo técnico y el código.
El modelo mental#
Ethereum es una máquina de estados replicada. Cada nodo en la red mantiene una copia idéntica del estado — un almacén masivo de clave-valor que mapea direcciones a datos de cuentas. Cuando "envías una transacción," estás proponiendo una transición de estado. Si suficientes validadores acuerdan que es válida, el estado se actualiza. Eso es todo.
El estado en sí es sencillo. Es un mapeo de direcciones de 20 bytes a objetos de cuenta. Cada cuenta tiene cuatro campos:
- nonce: Cuántas transacciones ha enviado esta cuenta (para EOAs) o cuántos contratos ha creado (para cuentas de contrato). Esto previene ataques de replay.
- balance: Cantidad de ETH, denominado en wei (1 ETH = 10^18 wei). Siempre un entero grande.
- codeHash: Hash del bytecode EVM. Para wallets normales (EOAs), es el hash de bytes vacíos. Para contratos, es el hash del código desplegado.
- storageRoot: Hash raíz del trie de almacenamiento de la cuenta. Solo los contratos tienen almacenamiento significativo.
Hay dos tipos de cuentas, y la distinción importa para todo lo que sigue:
Cuentas de Propiedad Externa (EOAs) están controladas por una clave privada. Son lo que MetaMask gestiona. Pueden iniciar transacciones. No tienen código. Cuando alguien dice "wallet," se refiere a una EOA.
Cuentas de Contrato están controladas por su código. No pueden iniciar transacciones — solo pueden ejecutarse en respuesta a ser llamadas. Tienen código y almacenamiento. Cuando alguien dice "smart contract," se refiere a esto. El código es inmutable una vez desplegado (con algunas excepciones mediante patrones proxy, lo cual es toda otra discusión).
La idea clave: cada cambio de estado en Ethereum comienza con una EOA firmando una transacción. Los contratos pueden llamar a otros contratos, pero la cadena de ejecución siempre empieza con un humano (o un bot) con una clave privada.
Gas: El cómputo tiene un precio#
Cada operación en la EVM cuesta gas. Sumar dos números cuesta 3 gas. Almacenar una palabra de 32 bytes cuesta 20,000 gas (primera vez) o 5,000 gas (actualización). Leer almacenamiento cuesta 2,100 gas (frío) o 100 gas (caliente, ya accedido en esta transacción).
No pagas gas en "unidades de gas." Pagas en ETH. El costo total es:
costoTotal = gasUsado * precioGas
Después de EIP-1559 (upgrade London), el precio del gas se convirtió en un sistema de dos partes:
costoTotal = gasUsado * (baseFee + priorityFee)
- baseFee: Establecido por el protocolo basándose en la congestión de la red. Se quema (destruye).
- priorityFee (propina): Va al validador. Mayor propina = inclusión más rápida.
- maxFeePerGas: El máximo que estás dispuesto a pagar por unidad de gas.
- maxPriorityFeePerGas: La propina máxima por unidad de gas.
Si baseFee + priorityFee > maxFeePerGas, tu transacción espera hasta que baje el baseFee. Por eso las transacciones "se atascan" durante alta congestión.
La implicación práctica para desarrolladores web: leer datos es gratis. Escribir datos cuesta dinero. Esta es la diferencia arquitectónica más importante entre Web2 y Web3. Cada SELECT es gratis. Cada INSERT, UPDATE, DELETE cuesta dinero real. Diseña tus dApps en consecuencia.
Transacciones#
Una transacción es una estructura de datos firmada. Aquí están los campos que importan:
interface Transaction {
// Quién recibe esta transacción — una dirección EOA o una dirección de contrato
to: string; // dirección hex de 20 bytes, o null para despliegue de contrato
// Cuánto ETH enviar (en wei)
value: bigint; // Puede ser 0n para llamadas puras a contrato
// Datos de llamada a función codificados, o vacío para transferencias simples de ETH
data: string; // Bytes codificados en hex, "0x" para transferencias simples
// Contador secuencial, previene ataques de replay
nonce: number; // Debe ser exactamente igual al nonce actual del remitente
// Límite de gas — máximo gas que esta tx puede consumir
gasLimit: bigint;
// Parámetros de tarifa EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Identificador de cadena (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}El ciclo de vida de una transacción#
-
Construcción: Tu app construye el objeto de transacción. Si estás llamando a una función de contrato, el campo
datacontiene la llamada a función codificada en ABI (más sobre esto abajo). -
Firma: La clave privada firma la transacción codificada en RLP, produciendo los componentes de firma
v,r,s. Esto prueba que el remitente autorizó esta transacción específica. La dirección del remitente se deriva de la firma — no está explícitamente en la transacción. -
Difusión: La transacción firmada se envía a un nodo RPC vía
eth_sendRawTransaction. El nodo la valida (nonce correcto, balance suficiente, firma válida) y la añade a su mempool. -
Mempool: La transacción permanece en un pool de transacciones pendientes. Los validadores seleccionan transacciones para incluir en el siguiente bloque, generalmente prefiriendo propinas más altas. Aquí es donde ocurre el front-running — otros actores pueden ver tu transacción pendiente y enviar la suya con una propina más alta para ejecutar antes que la tuya.
-
Inclusión: Un validador incluye tu transacción en un bloque. La EVM la ejecuta. Si tiene éxito, se aplican los cambios de estado. Si revierte, los cambios de estado se deshacen — pero sigues pagando por el gas consumido hasta el punto de revert.
-
Finalidad: En Ethereum proof-of-stake, un bloque se vuelve "finalizado" después de dos épocas (~12.8 minutos). Antes de la finalidad, las reorganizaciones de cadena son teóricamente posibles (aunque raras). La mayoría de apps tratan 1-2 confirmaciones de bloque como "suficiente" para operaciones no críticas.
Así se ve el envío de una transferencia simple de ETH con ethers.js v6:
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"), // Convierte "0.1" a wei (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// Esperar inclusión en un bloque
const receipt = await tx.wait();
console.log("Número de bloque:", receipt.blockNumber);
console.log("Gas usado:", receipt.gasUsed.toString());
console.log("Estado:", receipt.status); // 1 = éxito, 0 = revertY lo mismo con viem:
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);Nota la diferencia: ethers devuelve un objeto TransactionResponse con un método .wait(). Viem devuelve solo el hash — usas una llamada separada publicClient.waitForTransactionReceipt({ hash }) para esperar la confirmación. Esta separación de responsabilidades es intencional en el diseño de viem.
Smart Contracts#
Un smart contract es bytecode desplegado más almacenamiento persistente en una dirección específica. Cuando "llamas" a un contrato, estás enviando una transacción (o haciendo una llamada de solo lectura) con el campo data configurado como una invocación de función codificada.
Bytecode y ABI#
El bytecode es el código EVM compilado. No interactúas con él directamente. Es lo que la EVM ejecuta.
El ABI (Application Binary Interface) es una descripción JSON de la interfaz del contrato. Le dice a tu biblioteca cliente cómo codificar llamadas a funciones y decodificar valores de retorno. Piensa en él como una spec OpenAPI para un contrato.
Aquí hay un fragmento de un ABI de token ERC-20:
const ERC20_ABI = [
// Funciones de solo lectura (view/pure — sin costo de gas cuando se llaman 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)",
// Funciones que cambian estado (requieren una transacción, cuestan 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 la ejecución, almacenados en logs de transacción)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js acepta este formato de "ABI legible por humanos". Viem también puede usarlo, pero a menudo trabajarás con el ABI JSON completo generado por el compilador de Solidity. Ambos son equivalentes — el formato legible por humanos es simplemente más conveniente para interfaces comunes.
Cómo se codifican las llamadas a funciones#
Esta es la parte que la mayoría de tutoriales omiten, y es la parte que te ahorrará horas de depuración.
Cuando llamas transfer("0xBob...", 1000000), el campo data de la transacción se configura como:
0xa9059cbb // Selector de función
0000000000000000000000000xBob...000000000000000000000000 // address, rellenado a 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 en hex)
El selector de función son los primeros 4 bytes del hash Keccak-256 de la firma de la función:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = primeros 4 bytes = 0xa9059cbb
Los bytes restantes son los argumentos codificados en ABI, cada uno rellenado a 32 bytes. Este esquema de codificación es determinista — la misma llamada a función siempre produce el mismo calldata.
¿Por qué importa esto? Porque cuando ves datos de transacción en bruto en Etherscan y empiezan con 0xa9059cbb, sabes que es una llamada a transfer. Cuando tu transacción revierte y el mensaje de error es solo un blob hex, puedes decodificarlo usando el ABI. Y cuando estás construyendo lotes de transacciones o interactuando con contratos multicall, estarás codificando calldata manualmente.
Así se codifica y decodifica manualmente con ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Codificar una llamada a función
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Decodificar calldata de vuelta a nombre de función y argumentos
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 los datos de retorno de una función
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueSlots de almacenamiento#
El almacenamiento de contrato es un almacén clave-valor donde tanto claves como valores son de 32 bytes. Solidity asigna slots de almacenamiento secuencialmente empezando desde 0. La primera variable de estado declarada va en el slot 0, la siguiente en el slot 1, y así sucesivamente. Los mappings y arrays dinámicos usan un esquema basado en hash.
Puedes leer el almacenamiento de cualquier contrato directamente, incluso si la variable está marcada como private en Solidity. "Private" solo significa que otros contratos no pueden leerla — cualquiera puede leerla vía eth_getStorageAt:
// Leyendo el slot de almacenamiento 0 de un contrato
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Valor hex bruto de 32 bytesAsí es como los exploradores de bloques muestran el estado "interno" del contrato. No hay control de acceso en las lecturas de almacenamiento. La privacidad en una blockchain pública es fundamentalmente limitada.
Eventos y logs#
Los eventos son la forma del contrato de emitir datos estructurados que se almacenan en los logs de transacción pero no en el almacenamiento del contrato. Son más baratos que las escrituras en almacenamiento (375 gas por el primer topic + 8 gas por byte de datos, vs 20,000 gas por una escritura en almacenamiento) y están diseñados para consultarse eficientemente.
Un evento puede tener hasta 3 parámetros indexed (almacenados como "topics") y cualquier número de parámetros no indexados (almacenados como "data"). Los parámetros indexados se pueden filtrar — puedes preguntar "dame todos los eventos Transfer donde to sea esta dirección." Los parámetros no indexados no se pueden filtrar; tienes que obtener todos los eventos coincidentes y filtrar del lado del cliente.
// Escuchando eventos Transfer en tiempo real con 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("Bloque:", event.log.blockNumber);
console.log("Tx hash:", event.log.transactionHash);
});
// Consultando eventos históricos
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=cualquiera, to=específico
const events = await contract.queryFilter(filter, 19000000, 19100000); // rango de bloques
for (const event of events) {
console.log("De:", event.args.from);
console.log("Valor:", event.args.value.toString());
}Lo mismo con viem:
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("De:", log.args.from);
console.log("A:", log.args.to);
console.log("Valor:", log.args.value);
}
// Observación en tiempo 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}`);
}
},
});
// Llama a unwatch() para dejar de escucharLeyendo datos on-chain#
Aquí es donde Ethereum se vuelve práctico para desarrolladores web. No necesitas correr un nodo. No necesitas minar. Ni siquiera necesitas un wallet. Leer datos de Ethereum es gratis, sin permisos, y funciona vía una simple API JSON-RPC.
JSON-RPC: La API HTTP de Ethereum#
Cada nodo de Ethereum expone una API JSON-RPC. Es literalmente HTTP POST con cuerpos JSON. No hay nada específico de blockchain en la capa de transporte.
// Esto es lo que tu biblioteca hace internamente
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" }Eso es un eth_call en bruto. Simula la ejecución de una transacción sin enviarla realmente. Sin costo de gas. Sin cambio de estado. Solo lee el valor de retorno. Así es como las funciones view y pure funcionan desde el exterior — usan eth_call en lugar de eth_sendRawTransaction.
Los dos métodos RPC críticos#
eth_call: Simula la ejecución. Gratis. Sin cambio de estado. Se usa para todas las operaciones de lectura — verificar balances, leer precios, llamar funciones view. Se puede llamar en cualquier bloque histórico especificando un número de bloque en lugar de "latest".
eth_sendRawTransaction: Envía una transacción firmada para inclusión en un bloque. Cuesta gas. Cambia estado (si tiene éxito). Se usa para todas las operaciones de escritura — transferencias, aprobaciones, swaps, mints.
Todo lo demás en la API JSON-RPC es una variante de estos dos o un método utilitario (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).
Proveedores: Tu puerta de entrada a la cadena#
No corres tu propio nodo. Casi nadie lo hace para desarrollo de aplicaciones. En su lugar, usas un servicio de proveedor:
- Alchemy: El más popular. Gran dashboard, soporte de webhooks, APIs mejoradas para NFTs y metadatos de tokens. Tier gratuito: ~300M unidades de cómputo/mes.
- Infura: El original. Propiedad de ConsenSys. Fiable. Tier gratuito: 100K requests/día.
- QuickNode: Bueno para multi-chain. Modelo de precios ligeramente diferente.
- Endpoints RPC públicos:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratis pero con límite de tasa y ocasionalmente poco fiables. Bien para desarrollo, peligroso para producción. - Tenderly: Excelente para simulación y depuración. Su RPC incluye un simulador de transacciones integrado.
Para producción, siempre configura al menos dos proveedores como fallback. El tiempo de inactividad del RPC es real y sucederá en el peor momento posible.
import { ethers } from "ethers";
// ethers.js v6 proveedor de fallback
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,
},
]);Leyendo estado del contrato gratis#
Este es el movimiento poderoso que la mayoría de desarrolladores Web2 no se dan cuenta: puedes leer cualquier dato público de cualquier contrato en Ethereum sin pagar nada, sin wallet, y sin ninguna autenticación más allá de una clave API para tu proveedor RPC.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Interfaz ERC-20 — solo las funciones de lectura
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, no signer. Solo lectura.
);
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(`Decimales: ${decimals}`); // 6 (¡NO 18!)
console.log(`Supply total: ${ethers.formatUnits(totalSupply, decimals)}`);
// Verificar el balance de una dirección específica
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);Sin wallet. Sin gas. Sin transacción. Solo un eth_call JSON-RPC internamente. Esto es idéntico en concepto a hacer una solicitud GET a una API REST. La blockchain es la base de datos, el contrato es la API, y eth_call es tu consulta SELECT.
ethers.js v6#
ethers.js es el jQuery de Web3 — fue la primera biblioteca que la mayoría de desarrolladores aprendieron, y sigue siendo la más usada. La versión 6 es una mejora significativa sobre la v5, con soporte nativo de BigInt (por fin), módulos ESM, y una API más limpia.
Las tres abstracciones centrales#
Provider: Una conexión de solo lectura a la blockchain. Puede llamar funciones view, obtener bloques, leer logs. No puede firmar ni enviar transacciones.
import { ethers } from "ethers";
// Conectar a un nodo
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: Una abstracción sobre una clave privada. Puede firmar transacciones y mensajes. Un Signer siempre está conectado a un Provider.
// Desde una clave privada (servidor, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// Desde un wallet del navegador (lado cliente)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Obtener la dirección
const address = await signer.getAddress();Contract: Un proxy JavaScript para un contrato desplegado. Los métodos del objeto Contract corresponden a funciones en el ABI. Las funciones view devuelven valores. Las funciones que cambian estado devuelven un TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Lectura (gratis, devuelve el valor directamente)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance es un bigint: 1000000000n (1000 USDC con 6 decimales)
// Para escribir, conectar con un signer
const usdcWithSigner = usdc.connect(signer);
// Escritura (cuesta gas, devuelve TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Esperar inclusión en bloque
if (receipt.status === 0) {
throw new Error("La transacción revirtió");
}TypeChain para seguridad de tipos#
Las interacciones ABI en bruto son tipadas por string. Puedes escribir mal el nombre de una función, pasar tipos de argumentos incorrectos, o malinterpretar valores de retorno. TypeChain genera tipos TypeScript desde tus archivos ABI:
// Sin TypeChain — sin verificación de tipos
const balance = await contract.balanceOf("0x...");
// balance es 'any'. Sin autocompletado. Fácil de usar mal.
// Con TypeChain — seguridad de tipos completa
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance es BigNumber. Autocompletado funciona. Errores de tipo detectados en compilación.Para proyectos nuevos, considera usar la inferencia de tipos integrada de viem desde ABIs en su lugar. Logra el mismo resultado sin un paso separado de generación de código.
Escuchando eventos#
El streaming de eventos en tiempo real es crítico para dApps responsivas. ethers.js usa proveedores WebSocket para esto:
// WebSocket para eventos en tiempo 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);
// Escuchar todos los eventos Transfer
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Cantidad: ${ethers.formatUnits(value, 6)} USDC`);
});
// Escuchar transferencias A una dirección específica
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Transferencia entrante: ${ethers.formatUnits(value, 6)} USDC de ${from}`);
});
// Limpiar cuando termines
contract.removeAllListeners();WAGMI + Viem: El stack moderno#
WAGMI (We're All Gonna Make It) es una biblioteca de hooks de React para Ethereum. Viem es el cliente TypeScript subyacente que usa. Juntos, han reemplazado en gran parte a ethers.js + web3-react como el stack estándar para desarrollo frontend de dApps.
¿Por qué el cambio? Tres razones: inferencia completa de tipos TypeScript desde ABIs (sin necesidad de codegen), tamaño de bundle más pequeño, y hooks de React que manejan la compleja gestión de estado asíncrono de las interacciones con wallets.
Configuración#
// 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"),
},
});// 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>
);
}Leyendo datos del contrato#
useReadContract es el hook que más usarás. Envuelve eth_call con caché de React Query, refetching, y estados de carga/error:
"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>Cargando...</span>;
if (error) return <span>Error: {error.message}</span>;
// balance está tipado como bigint porque el ABI dice uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Nota el as const en el ABI. Esto es crítico. Sin él, TypeScript pierde los tipos literales y balance se convierte en unknown en lugar de bigint. Todo el sistema de inferencia de tipos depende de las aserciones const.
Escribiendo en contratos#
useWriteContract maneja el ciclo de vida completo: prompt del wallet, firma, difusión, y seguimiento de confirmación.
"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 ? "Confirmar en wallet..." : "Enviar 100 USDC"}
</button>
{hash && <p>Transacción: {hash}</p>}
{isConfirming && <p>Esperando confirmación...</p>}
{isSuccess && <p>¡Transferencia confirmada!</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}Observando eventos#
useWatchContractEvent configura una suscripción WebSocket para monitoreo de eventos en tiempo real:
"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>
);
}Patrones de conexión de wallet#
Conectar el wallet de un usuario es el "login" de Web3. Excepto que no es login. No hay sesión, no hay cookie, no hay estado del lado del servidor. La conexión del wallet le da a tu app permiso para leer la dirección del usuario y solicitar firmas de transacciones. Eso es todo.
La interfaz de proveedor EIP-1193#
Cada wallet expone una interfaz estándar definida por EIP-1193. Es un objeto con un método request:
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 inyecta esto como window.ethereum. Otros wallets o inyectan su propia propiedad o también usan window.ethereum (lo que causa conflictos — el problema de "la guerra de wallets", parcialmente resuelto por EIP-6963).
// Interacción de bajo nivel con wallet (no deberías hacer esto directamente, pero es útil entenderlo)
// Solicitar acceso a la cuenta
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Dirección conectada:", accounts[0]);
// Obtener la cadena actual
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Escuchar cambios de cuenta (el usuario cambia de cuenta en MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Wallet desconectado");
} else {
console.log("Cambiado a:", accounts[0]);
}
});
// Escuchar cambios de cadena (el usuario cambia de red)
window.ethereum.on("chainChanged", (chainId: string) => {
// El enfoque recomendado es recargar la página
window.location.reload();
});EIP-6963: Descubrimiento multi-wallet#
El viejo enfoque window.ethereum se rompe cuando los usuarios tienen múltiples wallets instalados. ¿Cuál obtiene window.ethereum? ¿El último en inyectar? ¿El primero? Es una condición de carrera.
EIP-6963 arregla esto con un protocolo de descubrimiento basado en eventos del navegador:
// Descubriendo todos los wallets disponibles
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Nombre de dominio inverso, ej., "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Solicitar a todos los wallets que se anuncien
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Ahora 'wallets' contiene todos los wallets instalados con sus nombres e iconos
// Puedes mostrar una UI de selección de walletWAGMI maneja todo esto por ti. Cuando usas el conector injected(), automáticamente usa EIP-6963 si está disponible y hace fallback a window.ethereum.
WalletConnect#
WalletConnect es un protocolo que conecta wallets móviles con dApps de escritorio mediante un servidor relay. El usuario escanea un código QR con su wallet móvil, estableciendo una conexión encriptada. Las solicitudes de transacción se retransmiten desde tu dApp a su teléfono.
Con WAGMI, es simplemente otro conector:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Obtener de cloud.walletconnect.com
showQrModal: true,
});Manejo de cambio de cadena#
Los usuarios a menudo están en la red incorrecta. Tu dApp está en Mainnet, ellos están conectados a Sepolia. O están en Polygon y necesitas Mainnet. WAGMI proporciona useSwitchChain:
"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 conecta tu wallet</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Por favor cambia a Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Cambiando..." : "Cambiar red"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS y metadatos#
Los NFTs no almacenan imágenes on-chain. La blockchain almacena una URI que apunta a un archivo JSON de metadatos, que a su vez contiene una URL a la imagen. El patrón estándar, definido por la función tokenURI de ERC-721:
Contract.tokenURI(42) -> "ipfs://QmXyz.../42.json"
Ese archivo JSON sigue un esquema estándar:
{
"name": "Cool NFT #42",
"description": "Un NFT muy cool",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}CID de IPFS vs URL#
Las direcciones IPFS usan Identificadores de Contenido (CIDs) — hashes del contenido en sí. ipfs://QmXyz... significa "el contenido cuyo hash es QmXyz...". Esto es almacenamiento direccionado por contenido: la URI se deriva del contenido, por lo que el contenido nunca puede cambiar sin cambiar la URI. Esta es la garantía de inmutabilidad en la que confían los NFTs (cuando realmente usan IPFS — muchos usan URLs centralizadas en su lugar, lo cual es una señal de alerta).
Para mostrar contenido IPFS en un navegador, necesitas un gateway que traduzca URIs IPFS a HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// O usa un gateway dedicado:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Obteniendo metadatos 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,
};
}Servicios de pinning#
IPFS es una red peer-to-peer. El contenido solo permanece disponible mientras alguien lo esté alojando ("pinning"). Si subes una imagen de NFT a IPFS y luego apagas tu nodo, el contenido desaparece.
Los servicios de pinning mantienen tu contenido disponible:
- Pinata: El más popular. API simple. Tier gratuito generoso (1GB). Gateways dedicados para carga más rápida.
- NFT.Storage: Gratis, respaldado por Protocol Labs (los creadores de IPFS). Diseñado específicamente para metadatos de NFT. Usa Filecoin para persistencia a largo plazo.
- Web3.Storage: Similar a NFT.Storage, más de propósito general.
// Subiendo a 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}`; // Devuelve CID
}El problema de la indexación#
Aquí está el secreto sucio del desarrollo blockchain: no puedes consultar datos históricos eficientemente desde un nodo RPC.
¿Quieres todos los eventos Transfer de un token en el último año? Necesitarás escanear millones de bloques con eth_getLogs, paginando en trozos de 2,000-10,000 bloques (el máximo varía por proveedor). Son miles de llamadas RPC. Tomará minutos a horas y consumirá tu cuota de API.
¿Quieres todos los tokens propiedad de una dirección específica? No hay una sola llamada RPC para esto. Necesitarías escanear cada evento Transfer de cada contrato ERC-20, rastreando balances. Eso no es factible.
¿Quieres todos los NFTs en un wallet? Mismo problema. Necesitas escanear cada evento Transfer de ERC-721 a través de cada contrato de NFT.
La blockchain es una estructura de datos optimizada para escritura. Es excelente procesando nuevas transacciones. Es terrible respondiendo consultas históricas. Este es el desajuste fundamental entre lo que las UIs de dApps necesitan y lo que la cadena proporciona nativamente.
El protocolo The Graph#
The Graph es un protocolo de indexación descentralizado. Escribes un "subgraph" — un esquema y un conjunto de manejadores de eventos — y The Graph indexa la cadena y sirve los datos mediante una API GraphQL.
# Esquema del 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")
}// Consultando un subgraph desde tu 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;
}La contrapartida: The Graph añade latencia (típicamente 1-2 bloques detrás del head de la cadena) y otra dependencia. La red descentralizada también tiene costos de indexación (pagas en tokens GRT). Para proyectos más pequeños, el servicio alojado (Subgraph Studio) es gratis.
APIs mejoradas de Alchemy y Moralis#
Si no quieres mantener un subgraph, tanto Alchemy como Moralis ofrecen APIs pre-indexadas que responden consultas comunes directamente:
// Alchemy: Obtener todos los balances de tokens ERC-20 para una dirección
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"],
}),
}
);
// Devuelve TODOS los balances de tokens ERC-20 en una llamada
// vs. escanear el balanceOf() de cada posible contrato ERC-20// Alchemy: Obtener todos los NFTs propiedad de una dirección
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}`);
}Estas APIs son propietarias y centralizadas. Estás intercambiando descentralización por experiencia de desarrollo. Para la mayoría de dApps, es una contrapartida que vale la pena. A tus usuarios no les importa si su vista de portafolio viene de un subgraph o de la base de datos de Alchemy. Les importa que cargue en 200ms en lugar de 30 segundos.
Errores comunes#
Después de enviar varias dApps a producción y depurar código de otros equipos, estos son los errores que veo repetidamente. Cada uno me ha mordido personalmente.
BigInt en todas partes#
Ethereum trabaja con números muy grandes. Los balances de ETH están en wei (10^18). Los supplies de tokens pueden ser 10^27 o más. El Number de JavaScript solo puede representar de forma segura enteros hasta 2^53 - 1 (aproximadamente 9 * 10^15). Eso no es suficiente para cantidades en wei.
// MAL — pérdida silenciosa de precisión
const balance = 1000000000000000000; // 1 ETH en wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — parece correcto, pero...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — ¡MAL! Redondeado.
console.log(largeBalance === 100000000000000000000); // true — corrupción de datos
// BIEN — usar BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — correcto
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correctoReglas para BigInt en código de dApps:
- Nunca conviertas cantidades en wei a
Number. UsaBigInten todas partes, convierte a strings legibles solo para mostrar. - Nunca uses
Math.floor,Math.round, etc. en BigInts. No funcionan. Usa división entera:amount / 10n ** 6n. - JSON no soporta BigInt. Si serializas estado que incluye BigInts, necesitas un serializador personalizado:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Usa funciones de formateo de la biblioteca.
ethers.formatEther(),ethers.formatUnits(),formatEther()deviem,formatUnits(). Manejan la conversión correctamente.
import { formatUnits, parseUnits } from "viem";
// Mostrar: BigInt -> string legible
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Entrada: string legible -> BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC tiene 6 decimales, no 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Operaciones asíncronas del wallet#
Cada interacción con el wallet es asíncrona y puede fallar de formas que tu app necesita manejar de manera elegante:
// El usuario puede rechazar cualquier prompt del wallet
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// El usuario rechazó la transacción en su wallet
// Esto es normal — no es un error para reportar
showToast("Transacción cancelada");
} else if (error.code === -32603) {
// Error interno JSON-RPC — a menudo significa que la transacción revertiría
showToast("La transacción fallaría. Verifica tu balance.");
} else {
// Error inesperado
console.error("Error de transacción:", error);
showToast("Algo salió mal. Por favor intenta de nuevo.");
}
}Trampas clave de async:
- Los prompts del wallet son bloqueantes del lado del usuario. El
awaiten tu código puede tardar 30 segundos mientras el usuario lee los detalles de la transacción en MetaMask. No muestres un spinner de carga que les haga pensar que algo está roto. - El usuario puede cambiar de cuenta a mitad de interacción. Solicitas aprobación de la Cuenta A, el usuario cambia a la Cuenta B, luego aprueba. Ahora la Cuenta B aprobó pero estás a punto de enviar una transacción desde la Cuenta A. Siempre re-verifica la cuenta conectada antes de operaciones críticas.
- Los patrones de escritura de dos pasos son comunes. Muchas operaciones DeFi requieren
approve+execute. El usuario necesita firmar dos transacciones. Si aprueban pero no ejecutan, necesitas verificar el estado del allowance y saltar el paso de aprobación la próxima vez.
Errores de desajuste de red#
Este desperdicia más tiempo de depuración que cualquier otro problema. Tu contrato está en Mainnet. Tu wallet está en Sepolia. Tu proveedor RPC apunta a Polygon. Tres redes diferentes, tres estados diferentes, tres blockchains completamente no relacionadas. Y el mensaje de error suele ser poco útil — "execution reverted" o "contract not found."
// Verificación defensiva de cadena
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Por favor conecta tu wallet" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Por favor cambia a ${getChainName(requiredChainId)}. Estás en ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Front-running en DeFi#
Cuando envías un swap en un DEX, tu transacción pendiente es visible en el mempool. Un bot puede ver tu operación, hacer front-run empujando el precio hacia arriba, dejar que tu operación se ejecute a un peor precio, y luego vender inmediatamente después para obtener ganancia. Esto se llama un "ataque sandwich."
Como desarrollador frontend, no puedes prevenir esto completamente, pero puedes mitigarlo:
// Estableciendo tolerancia de slippage en un swap estilo Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% de tolerancia al slippage
// Usando un deadline para prevenir transacciones pendientes de larga duración
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minutos
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Salida mínima aceptable — revertir si obtendríamos menos
[tokenA, tokenB],
userAddress,
deadline, // Revertir si no se ejecuta dentro de 20 minutos
);Para transacciones de alto valor, considera usar Flashbots Protect RPC, que envía transacciones directamente a los constructores de bloques en lugar del mempool público. Esto previene ataques sandwich completamente porque los bots nunca ven tu transacción pendiente:
// Usando Flashbots Protect como tu endpoint RPC
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Confusión de decimales#
No todos los tokens tienen 18 decimales. USDC y USDT tienen 6. WBTC tiene 8. Algunos tokens tienen 0, 2, o decimales arbitrarios. Siempre lee los decimals() del contrato antes de formatear cantidades:
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"Fallos de estimación de gas#
Cuando estimateGas falla, usualmente significa que la transacción revertiría. Pero el mensaje de error a menudo es simplemente "cannot estimate gas" sin indicación del por qué. Usa eth_call para simular la transacción y obtener la razón real del revert:
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; // Sin error — la transacción tendría éxito
} catch (error) {
// Decodificar la razón del revert
if (error.data) {
// Strings de revert comunes
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — revert estándar con mensaje
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Uniendo todo#
Aquí hay un componente React completo y mínimo que conecta un wallet, lee un balance de token, y envía una transferencia. Este es el esqueleto de toda dApp:
"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("");
// Leer balance — solo se ejecuta cuando address está definida
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Escribir: transferir tokens
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Esperar confirmación
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Recargar balance después de confirmación
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Conectar Wallet
</button>
);
}
return (
<div>
<p>Conectado: {address}</p>
<p>
Balance USDC:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Cargando..."}
</p>
<div>
<input
placeholder="Dirección del destinatario (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Cantidad (ej., 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
? "Confirmar en wallet..."
: isConfirming
? "Confirmando..."
: "Enviar USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>¡Transferencia confirmada!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Ver en Etherscan
</a>
)}
<button onClick={() => disconnect()}>Desconectar</button>
</div>
);
}Hacia dónde ir desde aquí#
Este post cubrió los conceptos esenciales y herramientas para desarrolladores web entrando en Ethereum. Hay mucha más profundidad en cada área:
- Solidity: Si quieres escribir contratos, no solo interactuar con ellos. Los docs oficiales y los cursos de Patrick Collins son los mejores puntos de partida.
- Estándares ERC: ERC-20 (tokens fungibles), ERC-721 (NFTs), ERC-1155 (multi-token), ERC-4626 (vaults tokenizados). Cada uno define una interfaz estándar que todos los contratos en esa categoría implementan.
- Layer 2s: Arbitrum, Optimism, Base, zkSync. Misma experiencia de desarrollo, menores costos de gas, supuestos de confianza ligeramente diferentes. Tu código de ethers.js y viem funciona idénticamente — solo cambia el chain ID y la URL del RPC.
- Account Abstraction (ERC-4337): La próxima evolución del UX de wallets. Wallets de smart contract que soportan patrocinio de gas, recuperación social, y transacciones agrupadas. Hacia aquí se dirige el patrón de "conectar wallet".
- MEV y ordenamiento de transacciones: Si estás construyendo DeFi, entender el Valor Máximo Extraíble no es opcional. Los docs de Flashbots son el recurso canónico.
El ecosistema blockchain se mueve rápido, pero los fundamentos en este post — cuentas, transacciones, codificación ABI, llamadas RPC, indexación de eventos — no han cambiado desde 2015 y no cambiarán pronto. Aprende estos bien y todo lo demás es solo superficie de API.