Las integraciones Web3 basadas en blockchain
Integrar una aplicación con una cadena de bloques no consiste en añadir un botón que pague. Es escribir software que convive con un sistema que no te pertenece, que no puedes modificar y que cobra por cada operación. Este texto recorre qué ofrece realmente una blockchain, cuáles son las piezas que hay que integrar, cómo se diseña la comunicación entre frontend, backend y nodo, y qué errores aparecen en cuanto el proyecto sale del laboratorio.
Qué aporta una blockchain frente a una base de datos
Una base de datos relacional es rápida, barata y transaccional. Solo hay que mencionar estas cuatro palabras y casi todo el software empresarial se resuelve. Entonces, ¿por qué existiría otra opción?
Porque cambia una garantía fundamental. En una base de datos tradicional hay una entidad (o un grupo reducido de ellas) que autoriza las escrituras. Todos confían en ese punto. Una blockchain replica el registro completo en miles de nodos independientes y hace que escribir exija un acuerdo colectivo. A cambio de esa redundancia obtienes tres propiedades que un servidor central no puede darte por decreto:
- Distributed trust: no hace falta confiar en un único operador. El consenso se sostiene en el incentivo de quien verifica.
- Trazabilidad: cada cambio queda enlazado al anterior mediante un hash. No se puede alterar un registro antiguo sin romper toda la cadena que viene detrás.
- Soberanía del dato: los participantes guardan su propia copia y pueden verificarla sin pedir permiso a nadie.
El matiz importante es que esto tiene un precio. Escribir es carísimo comparado con un INSERT, es lento comparado con un índice en memoria, y el diseño de un contrato inteligente es un problema de seguridad, no un problema de negocio. La blockchain cobra sentido cuando el valor está en que varias partes que no se confían entre sí acuerden sobre un mismo estado. En cuanto todos los participantes son de la misma organización, casi siempre hay una respuesta más simple.
La arquitectura de lectura y escritura de Web3
La denominación Web3 resume en dos verbos lo que hace una aplicación conectada a una cadena:
- Leer el estado público de la red: saldos, contratos, eventos, historial de bloques.
- Escribir en ese estado: transferir fondos, invocar un contrato, acuñar un token.
Del lado de la lectura casi siempre hay un nodo o un servicio que responde consultas. Del lado de la escritura el problema es otro: la transacción tiene que ir firmada criptográficamente por quien posee la clave privada. Y esa clave privada, en la enorme mayoría de los casos, no está en tu servidor. Está en una extensión del navegador o en una aplicación móvil. Tu aplicación nunca ve la clave: solo pide que se firme algo.
De ahí sale la regla de oro del diseño: tu backend no firma transacciones de usuario. Tu backend verifica firmas, guarda datos, dispara eventos y sirve índices. La firma ocurre en el dispositivo de la persona.
JSON-RPC: la capa de transporte
La forma más extendida de hablar con un nodo es JSON-RPC 2.0 sobre HTTP. Es JSON con un método y un array de parámetros. Estas son las llamadas que aparecen en casi cualquier integración:
eth_chainId -> identificador de la red
eth_accounts -> cuentas que la wallet ha autorizado
eth_requestAccounts -> pide permiso al usuario (abre la wallet)
eth_getBalance -> saldo de una dirección
eth_call -> lectura de un contrato (no escribe nada)
eth_estimateGas -> estima la comisión de una escritura
eth_getBlockByNumber -> datos de un bloque
eth_getLogs -> eventos emitidos en un rango de bloques
eth_sendTransaction -> pide a la wallet que firme y envíe
eth_getFeeData -> comisión estimada
Los nombres exactos varían algo entre clientes, pero el patrón se mantiene. Hay una distinción que conviene interiorizar desde el principio:
eth_callno escribe nada. Ejecuta el contrato en un entorno de solo lectura y devuelve el resultado. Es unSELECT.eth_sendTransactionescribe. Modifica el estado de la red, consume gas y no se puede revertir.
Confundir ambos es un error clásico. Si envías una escritura donde creías que hacías una lectura, has creado un cambio irreversible en una red pública.
Para recibir eventos en lugar de consultar repetidamente, existen suscripciones WebSocket (los métodos eth_subscribe con newHeads o logs). Son más eficientes, pero requieren una conexión persistente, un proxy que la soporte y una lógica de reconexión.
La wallet del usuario: el estándar EIP-1193
Desde 2022 existe un estándar que unifica las wallets del navegador: EIP-1193. Antes cada extensión exponía una API propia y el código dépendía de la wallet concreta. Hoy se asume que existe window.ethereum y que expone un método request.
if (typeof window.ethereum === "undefined") {
throw new Error("No hay una wallet instalada en este navegador");
}
const provider = window.ethereum;
provider.on("accountsChanged", (accounts) => {
// El usuario cambió de cuenta o desconectó. Hay que reevaluar permisos.
console.log("cuentas activas", accounts);
});
provider.on("chainChanged", (chainId) => {
// Cambió de red. Si la app espera una red concreta, hay que avisar.
console.log("red actual", chainId);
});
const accounts = await provider.request({ method: "eth_requestAccounts" });
const chainId = await provider.request({ method: "eth_chainId" });
eth_requestAccounts es el que abre el popup de la wallet. Es la única forma de obtener una cuenta, y solo funciona desde un gesto del usuario: un clic. Invocarla automáticamente en un useEffect al cargar la página va a ser rechazada por casi todas las wallets y, en cualquier caso, es una mala práctica de experiencia.
Los dos eventos del ejemplo merecen más atención de la que suelen recibir. Si tu interfaz asume que la cuenta conectada es la misma que al montar el componente, vas a tener un bug de autorización en producción: el usuario puede cambiar de cuenta en cualquier momento desde la extensión.
Transacciones, nonces y comisión
Una transacción firmada incluye, como mínimo, el destino, el valor, un número secuencial (nonce) y una declaración de comisión. El nonce es la protección contra la repetición: cada cuenta tiene un contador que se incrementa con cada envío, y la red rechaza una transacción cuyo nonce ya se usó. Por eso mismo, si envías dos transacciones en paralelo con el mismo nonce, solo una prospera y la otra se queda atascada.
La comisión (gas) no es un precio único sino un cálculo. Desde EIP-1559 se compone de dos piezas:
- Base fee: lo fija el protocolo según lo congestionada que esté la red. Se quema, no va a nadie.
- Priority fee (tip): la propina que va al validador que incluye la transacción.
Con ethers v6 puedes inspeccionarlo antes de enviar:
import { BrowserProvider, formatUnits, parseEther } from "ethers";
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const feeData = await provider.getFeeData();
console.log("fee máximo (gwei):", formatUnits(feeData.maxFeePerGas ?? 0n, "gwei"));
console.log("tip (gwei):", formatUnits(feeData.maxPriorityFeePerGas ?? 0n, "gwei"));
const tx = await signer.sendTransaction({
to: "0x0000000000000000000000000000000000000000",
value: parseEther("0.01"),
});
const receipt = await tx.wait();
console.log("minado en el bloque", receipt.blockNumber);
console.log("gas realmente usado", receipt.gasUsed.toString());
parseEther e formatEther no son decorativos: los saldos son enteros muy grandes y los wei son la unidad mínima. Un 0.1 escrito directamente en JavaScript es un float que no representa ni de lejos esa cantidad. La aritmética con BigInt o las funciones de ethers no son una comodidad, son una necesidad.
Un detalle de diseño que se olvida: el usuario necesita una estimación de coste antes de firmar. Si envías la transacción a ciegas y la comisión resulta alta, la experiencia es terrible y el soporte se llena de tickets.
Contratos inteligentes
Un contrato inteligente es código que se despliega en una dirección y se ejecuta de forma determinista. Desde fuera se ve como una cuenta más. La diferencia es que su comportamiento lo determina su bytecode y no una clave privada.
Para interactuar necesitas la ABI: la descripción en JSON de las funciones y eventos que expone. No es documentación, es lo que la librería usa para codificar la llamada.
import { Contract, formatUnits } from "ethers";
const abi = [
"function balanceOf(address owner) view returns (uint256)",
"function decimals() view returns (uint8)",
"function transfer(address to, uint256 amount) returns (bool)",
"event Transfer(address indexed from, address indexed to, uint256 value)",
];
const contract = new Contract(contractAddress, abi, provider);
// Lectura: no cuesta gas, no modifica nada.
const raw = await contract.balanceOf(userAddress);
const decimals = await contract.decimals();
console.log("saldo:", formatUnits(raw, decimals));
// Escritura: requiere signer y abre la wallet.
const tx = await contract.transfer(destinatario, 1000n * 10n ** BigInt(decimals));
await tx.wait();
Tres cosas que conviene revisar siempre antes de tocar una red con valor real:
- La red es la que tú crees.
chainIdse compara contra un valor esperado y, si no coincide, se detiene el flujo. Firmar en una red de pruebas creyendo que es la principal es un incidente grave. - Los decimales no son un adorno. Un token con 6 decimales y otro con 18 se manejan de forma completamente distinta. El error típico es pasar la cantidad en unidades humanas a un contrato que espera unidades básicas.
- La transacción puede fallar y aun así costar gas. Si el contrato hace
revert, la comisión se consume igual. Estimar (estimateGaso el equivalente de la librería) detecta muchos fallos antes de gastar.
Los tokens estándar
No hace falta desplegar un contrato propio para casi nada. Existen estándares que ya resuelven el caso:
- ERC-20: token fungible, intercambiable, con
transfer,approveybalanceOf. Es la base de cualquier moneda o stablecoin. - ERC-721: token no fungible, cada identificador es único. Es lo que se usa para un NFT.
- ERC-1155: varios activos en un solo contrato, con la posibilidad de que algunos sean fungibles y otros no. Reduce mucho el coste cuando se emiten muchas piezas.
Elegir el estándar correcto no es una decisión estética. Cambia el gas, cambia la complejidad del marketplace y cambia lo fácil que resulta integrarse con herramientas de terceros que ya esperan ese formato.
Oráculos: el punto débil inevitable
Un contrato no puede consultar internet por sí mismo. Está aislado por diseño, y esa aislación es lo que lo hace predecible. El problema aparece en cuanto necesita un dato externo, por ejemplo el precio de un activo.
Un oráculo es la pieza que lleva ese dato a la cadena. El problema es que un oráculo es un punto de confianza que no se puede auditar con la misma facilidad que el contrato. Si el oráculo miente, el contrato que confía en él ejecuta lógica incorrecta con dinero real. Por eso los oráculos consolidados combinan agregaciones, redondeos, reportes de precio firmados y mecanismos de actualización con desfase.
La lección de arquitectura: cada dependencia externa que introduces en un contrato es una superficie de ataque. Un oráculo barato puede ser correcto el 99% del tiempo y ser exactamente el 1% que vacíe el contrato.
Puentes entre cadenas
Un puente permite mover valor de una red a otra. También es, con frecuencia, el componente con peor historial de seguridad de todo el ecosistema, por una razón estructural: un puente tiene que verificar algo de la red de origen y emitir algo en la de destino, y para hacerlo suele mantener reservas, contratos con permisos o una logística de confianza.
Si tu integración puede evitar el puente, evítalo. Si no puede, reduce la cantidad expuesta, implementa límites por transacción y trata cada puente como una dependencia externa más.
Autenticación por firma: el patrón que más cambia el diseño
Aquí está la sustitución más valiosa que ofrece una blockchain frente a un login clásico. En lugar de que el servidor guarde una contraseña y emita un token, el usuario firma un mensaje con su clave y el servidor verifica esa firma contra la dirección que dice representar. La clave nunca sale del dispositivo.
El estándar más extendido es EIP-4361 (Sign-In with Ethereum), que define un mensaje con dominio, dirección, cadena, nonce, fecha de emisión y una frase que el usuario lee antes de firmar.
import { SiweMessage } from "siwe";
async function loginConWallet(signer) {
const address = await signer.getAddress();
const nonce = await pedirNonceAlServidor();
const message = new SiweMessage({
domain: "miapp.com",
address,
statement: "Iniciar sesión en MiApp. Esta solicitud no genera costes.",
uri: "https://miapp.com",
version: "1",
chainId,
nonce,
});
const prepared = message.prepareMessage();
return signer.signMessage(prepared);
}
El servidor verifica la firma, consume el nonce y devuelve su propia sesión. El nonce es lo que impide que alguien reutilice una firma capturada. Y la statement es lo que la wallet le muestra al usuario: es el texto que decide si la persona entiende lo que está autorizando.
Con la misma lógica, pero con datos estructurados, está EIP-712. En lugar de firmar un texto opaco, defines un esquema con campos tipados y nombres legibles:
const domain = {
name: "MiApp",
version: "1",
chainId,
verifyingContract: "0x...",
};
const types = {
Order: [
{ name: "tokenId", type: "uint256" },
{ name: "price", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = {
tokenId: 42n,
price: parseEther("0.05"),
deadline: 1893456000n,
};
const signature = await signer.signTypedData(domain, types, value);
La diferencia práctica es enorme. Con una firma de texto libre, un atacante puede reutilizar el contenido con una aplicación falsa. Con EIP-712, la wallet decodifica y muestra los campos con sus nombres reales, y el dominio ata la firma a tu contrato. El phishing deja de funcionar porque el usuario ve "MiApp, Order, price" en lugar de una cadena de hexadecimal.
Índice fuera de la cadena
Consultar el estado de la cadena directamente desde el frontend tiene un problema de fondo: la red es lenta y las lecturas no son gratuitas. Si necesitas el historial de movimientos de 5000 usuarios, preguntar bloque a bloque no escala.
La respuesta estándar es mantener una base de datos propia que se sincronice con la cadena. El frontend lee de tu índice, que responde en milisegundos, y la cadena queda como fuente de verdad. Patrón típico:
- Un proceso (o un indexador dedicado) escucha eventos y bloques.
- Escribe los cambios relevantes en tu base de datos.
- La aplicación consulta tu base de datos.
- La cadena se usa para verificar, no para servir lecturas masivas.
Este patrón también resuelve el problema de la reindexación. Si tu base de datos se corrompe o cambia el esquema, la reconstruyes recorriendo la cadena de nuevo. Es lento, pero es una tarea de mantenimiento, no una dependencia permanente del tiempo de carga de cada visita.
Un ejemplo de integración en React
Con wagmi la conexión deja de ser código imperativo y pasa a ser un hook. El componente se ocupa de solicitar la cuenta, firmar y vigilar los cambios de red.
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
import { injected } from "wagmi/connectors";
export function WalletButton() {
const { address, isConnected, chainId } = useAccount();
const { connect, isPending } = useConnect();
const { disconnect } = useDisconnect();
const { signMessage } = useSignMessage();
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })} disabled={isPending}>
Conectar wallet
</button>
);
}
if (chainId !== 1) {
return <p>Necesitas estar en la red principal para continuar.</p>;
}
return (
<div>
<p>{address}</p>
<button
onClick={async () => {
const signature = await signMessage({
message: `Confirmo mi identidad en MiApp a las ${new Date().toISOString()}`,
});
await verificarEnElBackend(signature);
}}
>
Firmar para iniciar sesión
</button>
<button onClick={() => disconnect()}>Desconectar</button>
</div>
);
}
Lo interesante de este patrón es que la verificación del estado de la red, del permiso y de la firma vive en un solo sitio. Es la diferencia entre una integración que se rompe cuando el usuario cambia de pestaña y una que no.
Elegir las librerías
El ecosistema tiene varias piezas y conviene no mezclarlas sin criterio:
- ethers y viem cubren el núcleo: la abstracción de contratos, codificación, firma y envío. Ambas son sólidas. viem es más pequeña y está pensada para eliminar código muerto; ethers tiene una API más larga y muy extendida.
- wagmi es la capa de React sobre viem. Resuelve el estado de conexión, los hooks, la persistencia y los patrones de reintento.
- RainbowKit resuelve el selector de cuentas y el modal de conexión, con soporte de muchas wallets.
- hardhat o anvil permiten levantar una cadena local para desarrollar y ejecutar tests sin tocar la red real.
Una advertencia práctica: no es buena idea cargar dos abstracciones completas (por ejemplo ethers y viem) en la misma aplicación solo por comodidad. Duplican peso y duplican formas de hacer lo mismo.
Rendimiento, latencia e indexación
Tres reglas aparecen en cuanto el proyecto tiene usuarios reales:
La lectura no va contra el nodo. Un endpoint público aguanta desarrollo y poco más. En producción necesitas un proveedor dedicado o tu propio nodo, con límites de peticiones y reintentos. Y como se dijo antes, un índice propio para todo lo que se consulte con frecuencia.
La escritura no se espera de manera optimista sin más. Puedes mostrar el resultado antes de minar, pero necesitas un estado intermedio real ("pendiente") y una reconciliación cuando llegue la confirmación. Confundir tx.wait() con "la wallet envió el mensaje" y con "la red confirmó" son dos errores distintos, y el primero es muy común.
Los datos pesados no viven en la cadena. Una imagen no se guarda en un contrato. Se sube a almacenamiento descentralizado (IPFS o similar) y en la cadena se guarda solo el hash o la URI. Poner contenido multimedia en un contrato funciona técnicamente y es carísimo por byte.
Sobre la comisión: la variación entre un momento tranquilo y uno congestionado es enorme. Una integración que solo funciona con la comisión baja tiene un problema de disponibilidad, y conviene que el backend exponga el coste estimado en lugar de dejar que la transacción falle.
Seguridad
Este es el punto donde una integración Web3 se diferencia de una aplicación normal. Los errores no producen una excepción en un log: producen pérdida de fondos.
Nunca pongas una clave privada en el frontend. Ni en el bundle, ni en una variable de entorno de Vite, ni en un repositorio. Cualquier clave privada que llegue al navegador se considera filtrada. Si tu servidor necesita firmar (por ejemplo, una operación de minting), usa un KMS, un HSM o un servicio de custodia, y expón solo operaciones concretas.
Cuidado con los oráculos. Se cubrieron arriba y conviene repetirlo: cada dependencia externa es una superficie de ataque.
Protege los contratos. La reentrancy sigue siendo la vulnerabilidad clásica: un contrato que llama a otro antes de actualizar su propio estado permite que el segundo vuelva a llamarlo. El patrón de comprobaciones-efectos-interacciones y el uso de un guard como reentrancy lock siguen siendo la respuesta. Una auditoría externa no es un lujo en un contrato que mueve dinero.
Protege las firmas. Nonce de un solo uso, dominio correcto, EIP-712 en lugar de texto libre, y verificación de la cadena en el servidor. Una firma válida para un entorno no debe servir para staging.
Vigila la inyección del proveedor. El objeto window.ethereum lo puede redefinir cualquier script que se ejecute en tu página. Si tienes un XSS, un atacante puede interceptar las peticiones a la wallet. Por eso el CSP estricto y no insertar contenido de usuario con innerHTML no son buenas prácticas genéricas: aquí tienen consecuencias directas sobre el dinero.
Errores frecuentes
Un resumen de lo que más se repite en proyectos reales:
- Asumir que la cuenta no cambia. El usuario cambia de cuenta en la wallet en cualquier momento. Escucha
accountsChangedychainChangedy reevalúa el estado. - Olvidar el nonce. Sin un nonce de un solo uso, cualquier firma capturada se puede reutilizar.
- Confiar en la red del cliente. El
chainIdse verifica también en el servidor, nunca solo en el navegador. - Tratar los decimales como un detalle. Es la fuente número uno de errores de cálculo en integraciones con tokens.
- Ignorar el coste. Sin estimación previa, la transacción falla o sale carísima.
- Reintentar a ciegas. Si no controlas el nonce, reintentar una escritura que quizá sí se confirmou genera transacciones duplicadas.
- Indexar en el momento de la consulta. El historial se lee de tu base de datos, no de la red.
Cómo probar sin gastar
La red de pruebas sigue siendo imprescindible. Un flujo de desarrollo razonable:
- Levanta una cadena local con anvil o hardhat, que arrancan en milisegundos y permiten controlar el tiempo y los bloques.
- Despliega tus contratos ahí y genera las funciones de llamada a partir de la ABI.
- Ejecuta los tests contra la cadena local, con casos de revert, de cambio de red y de wallet desconectada.
- Cuando todo pasa, replica en una red de pruebas pública con faucet para poder contrastar el comportamiento real.
- Solo entonces toca la red principal, y con una transacción de importe pequeño para validar el recorrido completo.
Los tests de integración contra la red local son mucho más baratos de escribir y de reejecutar que cualquier test manual contra una red pública, y detectan la mayoría de los errores de codificación de antes de gastar una sola comisión.
Conclusión
La integración Web3 no empieza con un contrato inteligente. Empieza por decidir qué estado necesita estar en la cadena y qué estado no. Una vez hecho ese reparto, casi todo lo demás tiene una respuesta conocida: un índice propio para leer, una wallet para escribir, un patrón de firma para autenticar, y una capa de pruebas locales para desarrollar sin pagar.
El error más común no es técnico: es poner en la cadena todo lo que se puede, cuando lo que se necesita es poner en la cadena solo lo que varios participantes deben acordar sin confiar en un tercero. La tecnología es capaz de casi cualquier cosa. La dificultad está en saber qué parte de esa capacidad conviene usar.
© Arturo Juárez Monroy