Fintech y banca
Smart contracts en Ethereum: EVM, Solidity, estándares y seguridad
Avanzado11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Un smart contract es un pequeño programa que se ejecuta en un ordenador público, que cualquiera puede invocar, que custodia valor real y que rara vez puedes parchear. Esta guía cubre el modelo de ejecución, un contrato leído línea a línea, los estándares de tokens, las herramientas, la integración desde el backend y las clases de vulnerabilidades que explican la mayoría de las pérdidas.
Lo que aprenderás
- Explicar la diferencia entre cuentas externas y cuentas de contrato, así como las transacciones, el gas y el coste del almacenamiento
- Leer un contrato en Solidity y razonar sobre él, incluidos los eventos, los modifiers y la visibilidad
- Implementar y consumir correctamente las interfaces ERC-20 y ERC-721
- Llamar a contratos desde un servicio backend gestionando las reorganizaciones y los nonces
- Reconocer y mitigar las vulnerabilidades de reentrada, control de acceso, oráculos y ordenación
Escribir un smart contract no se parece a casi nada más en el desarrollo de software. El programa se ejecuta en una máquina que no administras, su bytecode es público, cualquier persona del mundo puede llamar a cualquier función que hayas expuesto, el estado que guarda suele valer dinero y, una vez desplegado, normalmente no puedes parchearlo. El resultado es una disciplina más cercana a escribir firmware de aviónica que a un servicio web: código pequeño, explícito, probado de forma exhaustiva y paranoico con cada llamada externa.
Esta guía da por sabida la mecánica del registro que se explica en los fundamentos de blockchain y pasa a la capa programable: cómo ejecuta Ethereum el código, en qué se compila realmente Solidity, qué estándares debes implementar para ser interoperable, cómo habla un backend normal con una cadena y qué clases de vulnerabilidades explican la mayor parte del dinero perdido hasta ahora.
Cuentas, transacciones y gas
Ethereum tiene dos tipos de cuenta que comparten un mismo espacio de direcciones. Una cuenta externa (externally owned account) está controlada por una clave privada, tiene un saldo y un nonce, y es lo único capaz de iniciar una transacción. Una cuenta de contrato contiene código y almacenamiento, y solo actúa cuando alguien la llama. La dirección de un contrato desplegado se deriva de forma determinista del desplegador y su nonce, y por eso las direcciones de despliegue se pueden predecir.
Una transacción lleva un remitente, un destinatario, un valor, calldata, un nonce, un límite de gas y parámetros de comisión. El nonce es un contador estrictamente creciente por remitente que impide la repetición y obliga a un orden: una transacción con nonce 8 no puede ejecutarse antes que la de nonce 7, y un nonce atascado bloquea todo lo que viene detrás, algo que cualquier backend que envía transacciones acabará descubriendo en el peor momento.
La ejecución ocurre en la Ethereum Virtual Machine, una máquina de pila con palabras de 256 bits. Cada opcode tiene un coste en gas, y el remitente paga lo que se ejecuta. El gas cumple dos funciones: pone precio a los recursos escasos de la red y acota la ejecución, de modo que un bucle infinito simplemente se queda sin gas en lugar de detener la cadena. Las comisiones se dividen en una base fee fijada por el protocolo, que se quema, y una propina de prioridad para el proponente del bloque, y el remitente declara el máximo que está dispuesto a pagar.
Approximate relative costs (EVM, order-of-magnitude guidance)
arithmetic / comparison ~3 gas cheap, ignore it
memory expansion grows quadratically past a point
keccak256 hashing ~30 + 6 per word
reading a cold storage slot ~2,100 gas
writing a new non-zero slot ~20,000 gas <- dominates everything
updating an existing slot ~5,000 gas
emitting an event (per topic) ~375 gas <- cheap history
external call ~2,600 gas + whatever it does
Rule of thumb: storage is permanent obligation for every node,
so it is priced accordingly. Compute is nearly free by comparison.
De aquí se siguen tres consecuencias inmediatas. Mantén el estado persistente al mínimo y empaqueta los valores pequeños relacionados en un solo slot. Usa eventos en lugar de arrays en almacenamiento para registrar el historial que solo necesitan los lectores fuera de la cadena. Y trata cualquier bucle no acotado sobre un array que crece como una futura denegación de servicio, porque una función que acaba superando el límite de gas del bloque queda permanentemente imposible de llamar.
Si una transacción revierte, todos los cambios de estado se deshacen, pero el gas consumido se paga igualmente. La atomicidad sale gratis; la comisión, no.
Un contrato, línea a línea
Solidity es un lenguaje de tipado estático orientado a contratos. Los fragmentos siguientes usan resaltado de JavaScript, ya que Solidity comparte su sintaxis de llaves.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// A minimal escrow: a payer locks funds for a payee, released by an arbiter.
contract Escrow {
address public immutable payer;
address public immutable payee;
address public immutable arbiter;
uint256 public immutable deadline;
bool private released;
event Funded(address indexed from, uint256 amount);
event Released(address indexed to, uint256 amount);
event Refunded(address indexed to, uint256 amount);
error NotAuthorised();
error AlreadySettled();
error TooEarly(uint256 deadlineAt);
modifier onlyArbiter() {
if (msg.sender != arbiter) revert NotAuthorised();
_;
}
constructor(address _payee, address _arbiter, uint256 lockSeconds) payable {
payer = msg.sender;
payee = _payee;
arbiter = _arbiter;
deadline = block.timestamp + lockSeconds;
emit Funded(msg.sender, msg.value);
}
function release() external onlyArbiter {
if (released) revert AlreadySettled();
released = true; // effects before interaction
uint256 amount = address(this).balance;
emit Released(payee, amount);
(bool ok, ) = payee.call{value: amount}("");
require(ok, "transfer failed");
}
function refund() external {
if (msg.sender != payer) revert NotAuthorised();
if (released) revert AlreadySettled();
if (block.timestamp < deadline) revert TooEarly(deadline);
released = true;
uint256 amount = address(this).balance;
emit Refunded(payer, amount);
(bool ok, ) = payer.call{value: amount}("");
require(ok, "transfer failed");
}
}
Leámoslo en orden. El pragma fija un rango de versiones del compilador; en producción es preferible fijar una versión exacta en lugar de usar un caret. Las variables immutable quedan fijadas en la construcción y se guardan en el código, no en el almacenamiento, así que leerlas es barato. public en una variable de estado genera un getter; nada más en public implica seguridad, porque todo el estado de la cadena es legible sean cuales sean las palabras clave de visibilidad: en un contrato no hay secretos.
Las declaraciones event definen las entradas de log que consumen los indexadores; los parámetros indexed se convierten en topics consultables, y dispones de tres. Los tipos error personalizados son más baratos que los mensajes de revert y mucho más fáciles de tratar programáticamente. El modifier extrae una comprobación de autorización, y _; marca el punto en el que se ejecuta el cuerpo de la función.
msg.sender es quien llama directamente y msg.value el ether adjunto. La línea más importante del archivo es released = true;, colocada antes de la llamada saliente: es el orden checks-effects-interactions que veremos más abajo. Fíjate también en que payee.call{value: amount}("") es la forma actual de enviar ether, porque los helpers antiguos reenviaban una asignación fija de gas que falla cuando el destinatario es un contrato, y en que hay que comprobar su resultado booleano: una llamada de bajo nivel que falla no revierte automáticamente.
Estándares de tokens
En Ethereum, la interoperabilidad se basa en un pequeño conjunto de interfaces de aplicación publicadas como ERC. Dos de ellas dominan.
ERC-20 describe los tokens fungibles: saldos, transferencias y una allowance para gasto delegado.
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
El patrón de allowance es la razón por la que toda aplicación descentralizada te pide «aprobar» antes de poder mover tus tokens: el contrato llama a transferFrom contra la allowance que concediste. Aquí viven dos trampas clásicas. Una aprobación ilimitada significa que un bug posterior en el contrato aprobado puede vaciar el saldo, así que es preferible aprobar importes exactos o usar aprobaciones firmadas de tipo permit. Y decimals es un metadato, no una regla del protocolo: existen tokens con 6, 8 o 18 decimales, así que cualquier código que suponga 18 fijo acabará escalando mal un importe en un factor de un billón.
ERC-721 describe los tokens no fungibles, donde cada identificador es único y tiene un propietario individual, con ownerOf, safeTransferFrom y un tokenURI que apunta a los metadatos. Las variantes safe llaman a un hook de recepción en los destinatarios que son contratos para no enviar tokens a un contrato incapaz de gestionarlos; ese hook es en sí mismo una llamada externa y, por tanto, una superficie de reentrada. ERC-1155 combina ambos modelos para contratos multitoken.
Usa implementaciones de referencia auditadas para estas interfaces en lugar de escribir las tuyas. Un contrato de token es un problema resuelto, y la versión resuelta la ha revisado más gente de la que tu equipo podría permitirse.
Herramientas y flujo de trabajo
El ciclo de desarrollo moderno es más o menos este.
Elige un framework
Foundry ejecuta tests escritos en el propio Solidity, lo que mantiene el test y el contrato en un mismo modelo mental y sitúa el fuzzing y los tests de invariantes en primer plano. Hardhat se basa en JavaScript o TypeScript y encaja de forma natural en una base de código Node.js. Muchos equipos usan ambos.Escribe los tests antes que el contrato
Los tests basados en propiedades y los de invariantes encajan especialmente bien en este dominio: afirma que el suministro total siempre es igual a la suma de los saldos, que ningún camino permite a quien llama retirar más de lo que depositó, y deja que el fuzzer busque el contraejemplo. La disciplina descrita en desarrollo guiado por pruebas se aplica directamente.Ejecuta análisis estático y cobertura en CI
Linters, un analizador al estilo de Slither y snapshots de gas deberían ejecutarse en cada pull request, exactamente igual que en cualquier otro pipeline de integración continua. Haz que el build falle por hallazgos nuevos, no por el histórico acumulado.Despliega en una testnet
Las redes de prueba reproducen la mecánica de mainnet con ether sin valor obtenido de un faucet. Despliega con el script real, no con uno simplificado, para que el propio despliegue quede probado.Verifica el código fuente
Publicar el código fuente y la configuración del compilador en un explorador de bloques permite a cualquiera reproducir el bytecode. Un contrato no verificado que custodia fondos de usuarios debe considerarse poco fiable por defecto.Audita antes de que llegue el valor
La revisión externa debe ser proporcional al valor en riesgo. Publica el código, luego un bug bounty y después el dinero, en ese orden.
Hablar con una cadena desde un backend
La mayoría de los productos reales son servicios corrientes que leen y escriben en una cadena mediante JSON-RPC, con una librería como ethers.js, viem o web3j.
import { JsonRpcProvider, Contract, Wallet } from "ethers";
const provider = new JsonRpcProvider(process.env.RPC_URL);
const signer = new Wallet(process.env.SIGNER_KEY, provider); // use a KMS/HSM signer in production
const token = new Contract(TOKEN_ADDRESS, IERC20_ABI, signer);
// Read: free, served by the node, no transaction.
const balance = await token.balanceOf(userAddress);
// Write: costs gas, returns once mined, may still be reorganised.
const tx = await token.transfer(recipient, 1_000_000n, { gasLimit: 120_000n });
const receipt = await tx.wait(5); // wait for 5 confirmations
if (receipt.status !== 1) throw new Error("transaction reverted");
Operar esto en producción obliga a respetar unas cuantas realidades. La gestión de nonces debe estar centralizada: dos workers que firman con la misma clave colisionarán, así que serializa los envíos por clave e implementa el reemplazo por comisión (replace-by-fee) para las transacciones atascadas. No guardes nunca una clave privada en la memoria de la aplicación ni en una variable de entorno si puedes evitarlo: usa un módulo hardware o un servicio de firma gestionado, con límites de gasto y claves separadas por entorno. Estima el gas, pero ponle un tope, porque una estimación hecha sobre un estado desactualizado puede fallar por mucho. Trata el endpoint RPC como poco fiable: usa al menos dos proveedores y compara las alturas de bloque. Y indexa los eventos con un cursor persistido, esperando un margen de confirmaciones y usando como clave de los handlers el hash de la transacción más el índice del log, para que las repeticiones sean inocuas.
Actualización de contratos
El bytecode desplegado no puede cambiar, así que los sistemas actualizables ponen un proxy delante. El proxy guarda el almacenamiento y la dirección de un contrato de implementación, y reenvía cada llamada con delegatecall, que ejecuta el código de la implementación sobre el almacenamiento del proxy. Cambiar la dirección de implementación modifica el comportamiento conservando el estado y la dirección.
La disciplina que exige no perdona. La disposición del almacenamiento solo puede crecer añadiendo al final entre versiones, porque la implementación lee los slots por posición; insertar una variable en medio corrompe en silencio todo lo que viene después. Los constructores no se ejecutan para la implementación, así que la inicialización pasa a una función initialize protegida para que no pueda llamarse dos veces. Los selectores de función no deben colisionar entre el proxy y la implementación. Y la clave de actualización se convierte en el secreto más valioso del sistema, por lo que los proyectos serios la colocan tras una cartera multifirma y un timelock, para que los usuarios vean venir un cambio y puedan salir antes.
La actualizabilidad también debilita la promesa central: un contrato que se puede sustituir solo es tan fiable como sus administradores. Por eso algunos protocolos despliegan contratos inmutables y gestionan los cambios mediante migración: contratos nuevos, y los usuarios se mudan. Es más honesto y bastante más trabajo.
Las clases de vulnerabilidades que importan
La mayoría de las pérdidas se concentran en un puñado de patrones.
Reentrada. Una llamada externa cede el control a otro contrato, que vuelve a llamar al tuyo antes de que la primera invocación haya terminado de actualizar el estado; así, una función de retirada que envía ether y después pone el saldo a cero puede vaciarse en bucle. La solución es estructural: checks, effects, interactions, es decir, validar las entradas, actualizar tu estado y solo entonces llamar hacia fuera. Un reentrancy guard añade defensa en profundidad; ten en cuenta que los hooks de recepción de ERC-721 y los callbacks de transferencia de tokens son llamadas externas aunque no lo parezcan.
Control de acceso. Autorización ausente o incorrecta en funciones privilegiadas, inicializadores que siguen siendo invocables, tx.origin usado para autenticar (identifica al remitente original de la transacción, así que cualquier contrato al que llame el usuario puede suplantarlo). Usa msg.sender con una comprobación de rol explícita y enumera cada punto de entrada privilegiado durante la revisión.
Aritmética y conversiones. Desde Solidity 0.8 el overflow revierte por defecto, pero los bloques unchecked explícitos y los downcasts como de uint256 a uint128 siguen truncando en silencio. La división entera siempre trunca, así que el orden importa: multiplica antes de dividir y decide de forma deliberada de qué lado del error de redondeo queda el protocolo.
Manipulación de oráculos. Un contrato que lee un precio de un pool on-chain puede ser atacado moviendo ese pool dentro de una sola transacción, a menudo financiada con un flash loan. Usa medias ponderadas en el tiempo o una red de oráculos descentralizada con varios informantes independientes, y no te fíes nunca del precio spot de un pool con poca liquidez.
Ordenación de transacciones y front-running. Las transacciones pendientes son públicas antes de incluirse, y los proponentes las ordenan para sacar beneficio. Una operación sin límite de slippage puede sufrir un ataque sándwich; un compromiso revelado demasiado pronto puede copiarse. Entre las mitigaciones están los esquemas commit-reveal, los parámetros explícitos de slippage y de plazo, y los canales de envío privados.
Denegación de servicio por dependencia externa. Empujar fondos hacia una lista de destinatarios falla por completo si uno solo de ellos revierte. Es preferible un patrón de pull payments, en el que cada parte retira su propio saldo.
Gestión de firmas. Repetición entre cadenas o contratos cuando se omite el domain separator, firmas maleables y nonces ausentes. Sigue el estándar de datos estructurados tipados e incluye un chain id, una dirección de contrato y un nonce en todo lo que firmes.
El enfoque general de seguridad de la arquitectura de seguridad de aplicaciones sigue siendo válido (mínimo privilegio, defensa en profundidad, fronteras de confianza explícitas), con la diferencia de que aquí el atacante tiene financiación, está automatizado y lee tu código fuente antes de que termines de desplegarlo.
Puntos clave
- Solo las cuentas controladas por una clave inician transacciones; los contratos reaccionan, y cada operación se mide en gas, con el almacenamiento mucho más caro que el cálculo.
- Lee Solidity pensando en la frontera de confianza: todo el estado es público, las llamadas externas ceden el control y las llamadas de bajo nivel no revierten cuando fallan.
- Implementa ERC-20 y ERC-721 a partir de referencias auditadas, y nunca supongas 18 decimales ni concedas allowances ilimitadas por defecto.
- Prueba con fuzzing e invariantes, haz análisis estático en CI, verifica el código fuente publicado y audita antes de que el contrato custodie valor real.
- Los backends necesitan gestión centralizada de nonces, firma respaldada por hardware, proveedores RPC redundantes e indexación idempotente de eventos con un margen de confirmaciones.
- La actualización mediante proxy exige una disposición de almacenamiento que solo crezca por el final y traslada la confianza a quien tenga la clave de actualización: un timelock y una multifirma son el mínimo.
- La reentrada, los fallos de control de acceso, las conversiones silenciosas, los oráculos manipulables y la ordenación de transacciones explican la mayoría de los exploits; checks-effects-interactions y los pull payments evitan buena parte de ellos.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Se puede modificar un contrato ya desplegado?
- No directamente: el bytecode desplegado es inmutable. Los equipos que necesitan poder cambiarlo despliegan un proxy que guarda el estado y delega la ejecución en una dirección de implementación intercambiable. Así se gana la posibilidad de actualizar, a cambio de un mecanismo de gobernanza, disciplina con la disposición del almacenamiento y una suposición de confianza adicional, ya que quien controla la actualización controla, en la práctica, los fondos.
- ¿Por qué el almacenamiento es tan caro en comparación con el cálculo?
- Cada slot de almacenamiento debe conservarlo cada nodo para siempre, así que su coste refleja una obligación permanente para toda la red y no un cálculo puntual. Escribir un slot nuevo distinto de cero cuesta mucho más que la aritmética o la memoria, liberar slots da derecho a reembolsos, y las optimizaciones habituales son empaquetar variables, usar eventos para el historial y mantener los datos voluminosos fuera de la cadena.
- ¿Sigo necesitando SafeMath en un Solidity moderno?
- No. Desde la versión 0.8.0 la aritmética revierte por defecto ante overflow y underflow, así que la librería sobra. Cuando hayas demostrado una cota y quieras ahorrar gas, un bloque unchecked explícito documenta la decisión. Las conversiones entre anchos de entero siguen truncando en silencio, de modo que los downcasts siguen siendo un punto que revisar con cuidado.
- ¿Cómo puede un servicio backend seguir los eventos on-chain de forma fiable?
- Consulta o suscríbete a los logs, pero persiste un cursor con el último bloque procesado y espera un margen de seguridad de confirmaciones antes de actuar, porque los bloques recientes pueden reorganizarse. Haz que los handlers sean idempotentes usando como clave el hash de la transacción y el índice del log, y concilia periódicamente releyendo una ventana de bloques en lugar de fiarte solo de un flujo en tiempo real.