Durante años, hablar con un contrato inteligente desde JavaScript significaba instalar web3.js. Ese reinado terminó: ChainSafe anunció el 15 de enero de 2025 el cierre de la librería y archivó el repositorio a principios de marzo. Hoy el trabajo se reparte entre viem y ethers v6, dos librerías con filosofías distintas pero con el mismo modelo mental: un cliente que lee, otro que firma y un ABI que describe el contrato.
Este artículo recorre el ciclo completo contra la testnet Sepolia usando un contrato ERC-20: leer estado, enviar una transacción, esperar su confirmación y escuchar eventos. Cada ejemplo aparece en viem y en ethers para poder comparar la ergonomía real de ambas.
Qué ha pasado con web3.js
La información verificable es la siguiente. ChainSafe publicó en su blog «Web3.js to sunset» el 15 de enero de 2025. En él explica que las librerías se archivarían el 4 de marzo de 2025, que el acceso a npm pasaría a la Ethereum Foundation y que los desarrolladores debían migrar a alternativas que siguen creciendo, citando de forma expresa viem y ethers.js. El repositorio en GitHub figura como archivado y de solo lectura desde el 5 de marzo de 2025.
La consecuencia práctica se ve en npm: la última versión estable de web3 es la 4.16.0, publicada en diciembre de 2024. No habrá correcciones de seguridad ni soporte para cambios de red futuros. Un proyecto nuevo no debería empezar con ella, y uno existente tiene que planificar la migración. Las dos librerías sucesoras siguen vivas: ethers está en la serie 6.17 y viem en la 2.57 (versiones a 4 de noviembre de 2026).
viem frente a ethers v6
Aspecto | viem | ethers v6 |
Enfoque | Funciones sueltas y clientes configurables; muy modular | Clases orientadas a objetos (Provider, Signer, Contract) |
Tipado del ABI | Inferencia estricta desde el ABI: nombres de función, argumentos y retorno tipados | Los métodos del contrato se resuelven en ejecución; en TypeScript suelen ser |
Números |
|
|
Formato de ABI | JSON con | JSON o texto legible (human-readable ABI) |
Simulación previa |
|
|
Ecosistema | Base de wagmi (hooks para React) | Muy extendida en Hardhat y en código heredado |
Para proyectos nuevos en TypeScript, la inferencia de viem es una ventaja difícil de igualar: un error en el nombre de una función o en el tipo de un argumento falla al compilar, no en la red. ethers sigue siendo una elección sólida cuando ya hay código escrito con él o cuando se prefiere su API orientada a objetos. Si ya trabajas con el sistema de tipos de TypeScript a fondo, repasa antes 20 tips de TypeScript: as const y satisfies son la base de lo que viem hace con los ABI.
Entorno: Sepolia y un contrato ERC-20
Sepolia es la testnet que la documentación de Ethereum recomienda hoy como opción por defecto para desarrollo de aplicaciones. Holesky quedó deprecada en septiembre de 2025 (la sustituye Hoodi, orientada a validadores y pruebas de protocolo), así que no conviene apuntar a ella. El identificador de cadena de Sepolia es 11155111.
Para las lecturas se usa el token LINK de Chainlink desplegado en Sepolia, en 0x779877A7B0D9E8603169DdbD7836e478b4624789. Una llamada directa al nodo confirma que es un ERC-20 estándar: nombre «ChainLink Token», símbolo LINK, 18 decimales y un suministro total de 1.000.000.000 de unidades. Para escribir hacen falta ETH de prueba (de un faucet de Sepolia) para el gas y algo de LINK de prueba para transferir; sirve igual un ERC-20 propio desplegado con OpenZeppelin.
Instalación y variables de entorno:
npm install viem ethers # .env (nunca lo subas al repositorio) SEPOLIA_RPC_URL=https://TU-PROVEEDOR/sepolia PRIVATE_KEY=0x...clave_de_una_cartera_solo_de_pruebas WALLET_ADDRESS=0x...tu_direccion_publica
Con Node.js 20.6 o superior se cargan con node --env-file=.env script.js, sin dependencias extra.
El ABI: la pieza que lo conecta todo
El ABI (Application Binary Interface) es la descripción de las funciones y eventos del contrato. Sin él, la librería no sabe cómo codificar una llamada ni cómo decodificar la respuesta. En viem hay dos maneras de declararlo, y las dos conservan los tipos. La más cómoda para un contrato pequeño es el formato de texto con parseAbi:
// abi.ts
import { parseAbi } from 'viem'
export const tokenAbi = parseAbi([
'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)',
'function transfer(address to, uint256 value) returns (bool)',
'function approve(address spender, uint256 value) returns (bool)',
'event Transfer(address indexed from, address indexed to, uint256 value)',
'event Approval(address indexed owner, address indexed spender, uint256 value)',
])
La alternativa es pegar el ABI en JSON que genera el compilador de Solidity y añadir as const al final del array. Sin ese as const, TypeScript ensancha los literales y viem pierde la inferencia: es el error más habitual al empezar. ethers admite el mismo formato de texto en un array de cadenas, sin necesidad de ninguna función auxiliar.
Leer estado del contrato con viem
Las lecturas no cuestan gas ni requieren clave: basta un cliente público.
// client.ts
import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
const rpcUrl = process.env.SEPOLIA_RPC_URL
export const publicClient = createPublicClient({
chain: sepolia,
transport: http(rpcUrl),
})
export const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
export const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(rpcUrl),
})
Si http() no recibe URL, viem usa el RPC público de la cadena, pensado para pruebas rápidas; su propia documentación recomienda un endpoint autenticado para evitar límites de peticiones.
// leer.ts
import { formatUnits } from 'viem'
import { publicClient } from './client'
import { tokenAbi } from './abi'
const LINK = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
const [symbol, decimals, totalSupply] = await Promise.all([
publicClient.readContract({ address: LINK, abi: tokenAbi, functionName: 'symbol' }),
publicClient.readContract({ address: LINK, abi: tokenAbi, functionName: 'decimals' }),
publicClient.readContract({ address: LINK, abi: tokenAbi, functionName: 'totalSupply' }),
])
const saldo = await publicClient.readContract({
address: LINK,
abi: tokenAbi,
functionName: 'balanceOf',
args: [process.env.WALLET_ADDRESS as `0x${string}`],
})
console.log(symbol, decimals) // 'LINK' 18
console.log(formatUnits(totalSupply, decimals)) // '1000000000'
console.log(`Saldo: ${formatUnits(saldo, decimals)} ${symbol}`)
Los tipos de salida salen del ABI: symbol es string, decimals es number (un uint8) y totalSupply es bigint. Si se escribe functionName: 'balanceOff', el compilador falla. Cada valor uint256 llega como bigint: nunca como number, porque 18 decimales desbordan la precisión de un doble. Para mostrarlo se usa formatUnits, y para convertir lo que escribe el usuario, parseUnits. Esta es la regla que más errores ahorra: trabaja siempre en la unidad mínima y convierte solo al pintar.
Si una pantalla necesita muchas lecturas, viem puede agruparlas en una sola petición mediante Multicall3 (publicClient.multicall, o la opción batch: { multicall: true } al crear el cliente), lo que reduce latencia y consumo de cuota del proveedor.
Lo mismo con ethers v6
// leer-ethers.ts
import { Contract, JsonRpcProvider, formatUnits } from 'ethers'
const LINK = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
const abi = [
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 value) returns (bool)',
'event Transfer(address indexed from, address indexed to, uint256 value)',
]
const provider = new JsonRpcProvider(process.env.SEPOLIA_RPC_URL)
const token = new Contract(LINK, abi, provider)
const symbol: string = await token.symbol()
const decimals: bigint = await token.decimals()
const saldo: bigint = await token.balanceOf(process.env.WALLET_ADDRESS)
console.log(`Saldo: ${formatUnits(saldo, decimals)} ${symbol}`)
Aquí aparece la diferencia más visible. El constructor new Contract(address, abi, runner) admite un Provider (solo lectura) o un Signer (puede enviar transacciones). Los métodos se generan dinámicamente a partir del ABI, de modo que TypeScript no los conoce y hay que anotar el tipo del resultado a mano. Fíjate además en que en ethers v6 incluso un uint8 como decimals llega como bigint, y formatUnits lo acepta tal cual.
Escribir: enviar una transacción
Una escritura cambia el estado de la cadena, consume gas y necesita una cuenta que firme. La buena práctica en ambas librerías es simular antes de enviar: así un revert se detecta sin gastar gas.
// transferir.ts (viem)
import { parseUnits, parseEventLogs, BaseError } from 'viem'
import { publicClient, walletClient, account } from './client'
import { tokenAbi } from './abi'
const LINK = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
const destino = '0x0000000000000000000000000000000000000001'
try {
const { request } = await publicClient.simulateContract({
account,
address: LINK,
abi: tokenAbi,
functionName: 'transfer',
args: [destino, parseUnits('1', 18)],
})
const hash = await walletClient.writeContract(request)
console.log('Transacción enviada:', hash)
const recibo = await publicClient.waitForTransactionReceipt({ hash })
console.log('Estado:', recibo.status) // 'success' o 'reverted'
const eventos = parseEventLogs({
abi: tokenAbi,
logs: recibo.logs,
eventName: 'Transfer',
})
for (const e of eventos) {
console.log(`${e.args.from} -> ${e.args.to}: ${e.args.value}`)
}
} catch (error) {
if (error instanceof BaseError) console.error(error.shortMessage)
else throw error
}
Hay tres pasos distintos que conviene no mezclar. simulateContract ejecuta la llamada en el nodo sin enviarla y devuelve la petición ya preparada. writeContract la firma y la difunde, pero solo devuelve el hash: la transacción todavía no está minada. waitForTransactionReceipt espera a que entre en un bloque y devuelve el recibo, donde status distingue éxito de reversión. Un hash devuelto no significa que la operación haya funcionado.
La versión con ethers condensa los pasos en el propio objeto de la transacción:
// transferir-ethers.ts
import { Contract, JsonRpcProvider, Wallet, parseUnits } from 'ethers'
const LINK = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
const abi = ['function transfer(address to, uint256 value) returns (bool)']
const provider = new JsonRpcProvider(process.env.SEPOLIA_RPC_URL)
const signer = new Wallet(process.env.PRIVATE_KEY!, provider)
const token = new Contract(LINK, abi, signer)
const destino = '0x0000000000000000000000000000000000000001'
const importe = parseUnits('1', 18)
// Simulación: lanza error si la transacción revertiría
await token.transfer.staticCall(destino, importe)
const tx = await token.transfer(destino, importe)
console.log('Transacción enviada:', tx.hash)
const recibo = await tx.wait() // 1 confirmación por defecto
console.log('Estado:', recibo?.status === 1 ? 'success' : 'reverted')
En ethers, transfer.staticCall cumple el papel de simulateContract, y tx.wait() devuelve el recibo tras el número de confirmaciones indicado (una por defecto). En redes con reorganizaciones, o en operaciones de valor alto, conviene esperar más de una: en viem se pasa confirmations a waitForTransactionReceipt, y en ethers, a wait(n).
Eventos: histórico y tiempo real
Los eventos son el registro que deja un contrato y la forma barata de reconstruir qué ha pasado. Solo los argumentos marcados como indexed se pueden usar como filtro; en el Transfer de un ERC-20 son from y to, pero no value.
// eventos.ts (viem)
import { formatUnits } from 'viem'
import { publicClient } from './client'
import { tokenAbi } from './abi'
const LINK = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
// Histórico: últimos 500 bloques
const ultimo = await publicClient.getBlockNumber()
const logs = await publicClient.getContractEvents({
address: LINK,
abi: tokenAbi,
eventName: 'Transfer',
fromBlock: ultimo - 500n,
toBlock: ultimo,
strict: true,
})
for (const log of logs) {
console.log(log.args.from, '->', log.args.to, formatUnits(log.args.value, 18))
}
// Tiempo real
const dejarDeEscuchar = publicClient.watchContractEvent({
address: LINK,
abi: tokenAbi,
eventName: 'Transfer',
onLogs: (nuevos) => nuevos.forEach((l) => console.log('Nuevo Transfer', l.transactionHash)),
onError: (error) => console.error(error),
})
// Más adelante: dejarDeEscuchar()
Con strict: true viem descarta los registros que no encajan con el ABI y garantiza que args.from, args.to y args.value estén definidos. Sin él, algunos campos pueden ser undefined. Sobre watchContractEvent: con transporte HTTP funciona por sondeo (polling), con un filtro del nodo si este soporta eth_newFilter; con WebSocket usa una suscripción real. Los registros llegan agrupados por intervalo de sondeo.
El equivalente en ethers:
// eventos-ethers.ts
import { Contract, EventLog, JsonRpcProvider, formatUnits } from 'ethers'
const provider = new JsonRpcProvider(process.env.SEPOLIA_RPC_URL)
const abi = ['event Transfer(address indexed from, address indexed to, uint256 value)']
const token = new Contract('0x779877A7B0D9E8603169DdbD7836e478b4624789', abi, provider)
// Histórico filtrado por destinatario (segundo argumento indexado)
const ultimo = await provider.getBlockNumber()
const filtro = token.filters.Transfer(null, process.env.WALLET_ADDRESS)
const logs = await token.queryFilter(filtro, ultimo - 500, ultimo)
for (const log of logs) {
if (log instanceof EventLog) {
const [from, to, value] = log.args
console.log(from, '->', to, formatUnits(value, 18))
}
}
// Tiempo real
await token.on('Transfer', (from, to, value, evento) => {
console.log(from, to, formatUnits(value, 18), evento.log.transactionHash)
})
Dos límites prácticos valen para ambas librerías. Muchos proveedores restringen el rango de bloques en una consulta de logs y devuelven un error si se pide demasiado, así que el histórico largo se pagina por tramos. Y el consumo en tiempo real depende del plan del proveedor: para un servicio en producción suele compensar un WebSocket o un indexador, en lugar de sondear sin parar.
En el navegador: la cartera del usuario
En una aplicación web no se maneja ninguna clave privada: firma la cartera del usuario (MetaMask u otra) mediante el proveedor inyectado window.ethereum. En viem se pasa custom(window.ethereum) como transporte del cliente de cartera, y las cuentas se piden con requestAddresses(). En ethers se crea un BrowserProvider(window.ethereum) y se obtiene el firmante con await provider.getSigner(). El resto del código es idéntico al de Node.js. Si la interfaz es React, wagmi (construido sobre viem) ofrece hooks ya resueltos para conexión, cambio de red y escrituras.
Claves privadas: lo que no debe pasar
- Una clave privada controla todos los fondos de su dirección y no se puede revocar. Quien la vea, la tiene.
- Usa para pruebas una cartera creada solo para ese fin, con fondos de testnet. Jamás reutilices en Sepolia una clave que también opere en la red principal.
- Mantén
.enven.gitignoredesde el primer commit. Los bots rastrean GitHub en busca de claves en cuestión de minutos. - No pongas una clave en código de navegador, ni en variables expuestas por el empaquetador (las que empiezan por
VITE_oNEXT_PUBLIC_acaban en el JavaScript público). - En producción, firma con un servicio de gestión de claves (KMS), un módulo de hardware o una cartera de firma múltiple, y limita con
approvelos importes a lo estrictamente necesario en lugar de aprobaciones ilimitadas.
Migrar de web3.js sin dolor
El mapa mental es directo. Donde web3.js tenía new web3.eth.Contract(abi, address) con .methods.foo().call() y .send(), viem separa readContract y writeContract, y ethers usa contract.foo() para ambos casos según el tipo de función. Los suscriptores de eventos se convierten en watchContractEvent o contract.on. La mayor diferencia es mental: ninguna de las dos librerías mantiene un objeto global con todo; se construyen clientes pequeños y se pasan donde hacen falta. Las guías oficiales de ChainSafe enlazadas en su anuncio detallan la equivalencia método a método.
Cuál elegir
Para un proyecto nuevo en TypeScript, viem es la opción con mejor tipado, API pequeña y modular, y la que sustenta wagmi. Para un código que ya funciona con ethers, no hay motivo urgente para cambiar: la serie 6 está mantenida y cubre el ciclo completo que se ha visto aquí. Lo que no tiene justificación es empezar nada nuevo sobre web3.js. Sea cual sea la elección, las ideas se repiten: ABI tipado, simulación antes de enviar, valores en bigint, confirmación explícita del recibo y claves fuera del repositorio.
Imagen: Pexels / Jievani
