Ethereum: de historia a labs
Artículo 14 de 48
29%
completado al llegar aquí
Sistemas distribuidosNota de investigación13 min de lectura
Solidity sin magia: contratos como máquinas de estado
Solidity leído como máquina de estado: storage, msg.sender, visibilidad, modifiers, errores, eventos, reverts atómicos y el patrón checks-effects-interactions.
Ruta Ethereum: 0/48
Prerequisitos recomendados
Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.
Piensa en un servicio REST con una base de datos detrás. Cada petición lee filas, valida, escribe y responde. Un contrato de Solidity se parece, con tres diferencias que cambian todo: la base de datos es pública, cada escritura cuesta dinero a quien llama, y cualquier llamada a otro servicio puede ejecutar código escrito por un desconocido antes de que tu función termine.
Solidity es un lenguaje de alto nivel que compila a bytecode de la EVM. Un contrato es código más datos que viven en una dirección de Ethereum; los datos persistentes se llaman storage. Cada cambio llega dentro de una transacción, y una transacción se aplica entera o no se aplica [1]. Este capítulo propone leer cualquier contrato con una sola pregunta: ¿qué transición de estado permite, a quién, y bajo qué condiciones?
Cinco preguntas antes de aprobar una función
0 de 5 revisados
Brújula de lectura
Actor, evidencia y estado
Actor
msg.senderLa cuenta o contrato que hizo la llamada actual; base de todo control de acceso.
Evidencia
Eventos y receiptLos logs quedan en el bloque; los contratos no pueden leerlos.
Estado
Storage del contratoPersistente y público, aunque la variable se declare private.
Preguntas guía
- Si una función revierte después de escribir en storage, ¿qué queda escrito?
- ¿Qué protege realmente la palabra
private? - ¿Por qué el orden de las líneas dentro de una función puede ser una vulnerabilidad?
- ¿Qué diferencia hay entre un error de negocio y un
Panic?
Un contrato es estado con dirección
Una variable de estado, como uint256 public number en el Counter del capítulo anterior, vive en storage y sobrevive entre transacciones. Las variables locales de una función, en cambio, viven en memoria o en la pila de la EVM y desaparecen al terminar la llamada. Esta separación es la primera regla del modelo mental: solo lo que está en storage forma parte del estado del contrato.
La segunda regla es que cada llamada tiene un autor identificable. msg.sender es la dirección desde la que llegó la llamada externa actual [1]. Si una cartera llama al contrato A, dentro de A msg.sender es la cartera; si A llama a B, dentro de B msg.sender es A. Todo control de acceso en Solidity se construye comparando msg.sender con algo guardado en storage.
La tercera regla es la atomicidad. Una transacción que cambia dos valores los cambia ambos o ninguno. Para un ingeniero de backend esto equivale a que cada llamada externa abre una transacción de base de datos con aislamiento total, y que cualquier error hace rollback completo. No hay estados intermedios visibles para otros usuarios.
Anatomía de una alcancía: visibilidad, modifiers, errores y eventos
El siguiente contrato es ilustrativo y compila con Solidity 0.8.24 o superior. Guarda saldos en ETH por usuario y permite al dueño pausar los depósitos. Es pequeño, pero contiene casi todas las piezas que aparecerán en los estándares de tokens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Alcancia {
address public immutable owner;
bool public paused;
mapping(address => uint256) public balanceOf;
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
error NotOwner(address caller);
error Paused();
error InsufficientBalance(uint256 requested, uint256 available);
error TransferFailed();
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner(msg.sender);
_;
}
function setPaused(bool value) external onlyOwner {
paused = value;
}
function deposit() external payable {
if (paused) revert Paused();
balanceOf[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
uint256 available = balanceOf[msg.sender]; // checks
if (amount > available) revert InsufficientBalance(amount, available);
balanceOf[msg.sender] = available - amount; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions
if (!ok) revert TransferFailed();
emit Withdrawn(msg.sender, amount);
}
}Visibilidad. Las funciones external forman parte de la interfaz del contrato y se llaman desde fuera; las public también pueden llamarse internamente. Para variables, public hace que el compilador genere un getter: por eso balanceOf(address) existe aunque no lo escribimos [2]. immutable fija owner en el constructor y copia su valor en el código desplegado, así que no ocupa un slot de storage.
Modifiers. onlyOwner envuelve la función: ejecuta su comprobación y, en el punto _;, inserta el cuerpo de la función decorada. Es una forma declarativa de repetir precondiciones. Conviene que contengan solo comprobaciones; un modifier que escribe estado o llama a otros contratos esconde efectos en un lugar donde el revisor no los busca.
Errores. error InsufficientBalance(uint256 requested, uint256 available) declara un error con nombre y datos, que se lanza con revert. La documentación los presenta como una forma económica en gas de explicar por qué falló una operación [2]. Para un cliente es lo más parecido a un código de error HTTP con cuerpo tipado.
Eventos. emit Deposited(...) escribe un registro en el log de la transacción, asociado a la dirección del contrato. Hasta tres parámetros pueden marcarse indexed para filtrarlos rápido. Los logs no son accesibles desde ningún contrato, ni siquiera desde el que los emitió: son evidencia para el mundo exterior (indexadores, frontends), no estado que el contrato pueda consultar.
Traza de ejecución: qué pasa cuando withdraw falla a mitad de camino
Supongamos que Ana tiene 5 ETH en la alcancía y llama withdraw(3). La función lee el saldo, comprueba que 3 no supera 5, escribe el nuevo saldo (2) y envía 3 ETH con una llamada de bajo nivel. Si todo va bien, el receipt queda con status 1 y el evento Withdrawn aparece en el log.
Ahora supongamos que la dirección de Ana es un contrato que rechaza ETH. La llamada call{value: amount} no revierte por sí sola: las funciones de bajo nivel call, delegatecall y staticcall devuelven false en lugar de propagar la excepción [3]. Por eso el código comprueba ok y lanza TransferFailed(). Olvidar esa comprobación es un error clásico: el saldo baja y el ETH nunca sale.
Cuando se ejecuta revert, la EVM detiene la ejecución, deshace todos los cambios de estado hechos hasta ese punto y devuelve los datos del error al llamante, sin consumir todo el gas restante [4]. El saldo vuelve a 5 y el evento desaparece. Lo que sí queda es la transacción en el bloque, con status 0 y el gas consumido pagado por Ana.
Una llamada a withdraw: checks, effects, interactions y el revert que lo deshace todo
Cómo leer esta figura
Diagrama de izquierda a derecha. Una caja de estado inicial con saldo de Ana igual a 5 recibe la llamada withdraw(3). Siguen tres cajas: checks, que pregunta si 3 es menor o igual que 5; effects, que asigna 2 al saldo de Ana; interactions, que ejecuta call con value 3 y donde corre código ajeno. Si todo tiene éxito se llega a una caja de estado final con saldo 2, evento emitido y receipt con status 1. Desde cada una de las tres fases baja una flecha roja a una caja de revert que indica que se deshacen storage, eventos y ETH movido, con receipt status 0 y el gas consumido pagado igual. Una línea punteada vuelve desde el revert al estado inicial. Una nota arriba dice que la transacción es atómica.
Hay dos familias de error. revert con un error propio, o require(condición, "mensaje"), señala un fallo esperado, como un saldo insuficiente; con un string, el dato devuelto es Error(string). assert y los fallos internos producen Panic(uint256) con un código [3]. En Solidity 0.8 la aritmética está comprobada por defecto: un desbordamiento revierte con Panic(0x11) salvo dentro de un bloque unchecked [6]. Un Panic en producción suele indicar un bug, no una entrada inválida.
Invariante: el estado se escribe antes de ceder el control
Fíjate en el orden de withdraw: primero comprueba, después escribe el saldo y solo al final envía ETH. No es estilo, es seguridad. Cualquier llamada a otro contrato, y cualquier envío de ETH, le cede el control a ese contrato, que puede volver a llamar a la función original antes de que termine [5]. A esto se le llama reentrancy (reentrada).
Imagina la versión con el orden invertido: enviar 3 ETH y después restar. Si el receptor es un contrato malicioso, su función de recepción vuelve a llamar withdraw(3). Como el saldo todavía dice 5, la comprobación pasa otra vez, y el ataque se repite hasta vaciar el contrato. La documentación oficial muestra exactamente este fallo con un contrato Fund y su corrección.
El patrón checks-effects-interactions fija el invariante: cuando el control sale del contrato, el storage ya refleja la operación completa. La documentación lo describe como una forma común de prevenir reentrancy y recuerda que el riesgo existe en cualquier llamada a otro contrato, no solo al enviar ETH [5]. Esto incluye las transferencias de tokens con hooks que veremos en ERC-721 y ERC-1155.
Riesgo operativo: lo que el lenguaje no esconde
private no significa secreto. Las variables private e internal solo impiden que otros contratos las lean o modifiquen; su valor sigue visible para cualquiera fuera de la cadena [2]. Con cast storage del capítulo anterior puedes leer cualquier slot. Una contraseña, una semilla aleatoria o una oferta sellada guardada en storage está publicada.
view y pure son promesas verificadas por el compilador: una función view no modifica estado (no escribe storage ni emite eventos) y una pure ni siquiera lo lee. Son útiles para el lector y para el cliente, que puede llamarlas con eth_call sin enviar transacción. No son una barrera de seguridad: una función sin esos modificadores puede ser igual de inofensiva o igual de peligrosa.
El costo también es parte del diseño. Cada escritura en storage la paga quien llama, y la documentación recomienda guardar solo lo que el contrato necesita para funcionar [1]. Datos de solo lectura para la interfaz suelen ir mejor en eventos o fuera de la cadena. El siguiente capítulo muestra cómo el compilador acomoda las variables en slots y por qué ese mapa importa.
Conexiones dentro de la serie
- Antes: Foundry, Anvil y Cast: taller local para Ethereum da el entorno para compilar y probar la alcancía.
- Base técnica: EVM: stack, memory, storage y calldata explica las zonas de datos que Solidity abstrae.
- Después: Storage layout: donde Solidity cobra la factura muestra en qué slot termina cada variable de este contrato.
- Más adelante: Reentrancy: la llamada externa que muerde de vuelta desarrolla el ataque que aquí solo se esboza.
Bibliografía académica
- [1]
Solidity Team, "Introduction to Smart Contracts," Solidity Documentation.
Contrato como código y datos en una dirección; transacciones atómicas; msg.sender.
- [2]
Solidity Team, "Contracts," Solidity Documentation.
Visibilidad y getters, constant/immutable, view/pure, modifiers, eventos e indexed, errores personalizados.
- [3]
Solidity Team, "Expressions and Control Structures," Solidity Documentation.
revert, require, assert, Panic y Error(string); propagación de excepciones y retorno false de call/delegatecall/staticcall.
- [4]
A. Beregszaszi and N. Mushegian, "EIP-140: REVERT instruction," Ethereum Improvement Proposals, no. 140, 2017.
REVERT aborta, revierte cambios de estado incluidos los logs y devuelve datos de error sin consumir todo el gas.
- [5]
Solidity Team, "Security Considerations," Solidity Documentation.
Información pública aunque sea private, reentrancy y patrón Checks-Effects-Interactions.
- [6]
Solidity Team, "Types," Solidity Documentation.
Aritmética comprobada por defecto y modo unchecked con aritmética envolvente.
Rutas vecinas
Si este tema te abrió hambre, sigue por aquí.
Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.
Series relacionadas
HotStuff: de cero a estado del arte
Ruta progresiva para entender consenso BFT moderno: quórums, sincronía parcial, locks, taxonomía one/two/three-chain, QC/highQC, multipipeline, view-change, responsiveness, AggQC, Fast-HotStuff, sync y frontera DAG-BFT.
Substrate: del runtime al consenso propio
Serie de 20 capítulos, de neófito a experto, sobre Substrate (Polkadot SDK): runtime en Wasm, FRAME, extrinsics, estado, pesos, actualizaciones sin fork, Aura, BABE, GRANDPA, sincronización, parachains, XCM, Sassafras, BEEFY y cómo conectar un consenso BFT propio a un nodo.
Ruta práctica: activos digitales en la operación
Nueve módulos, de menor a mayor dependencia, para decidir cómo usar custodia, wallets, llaves, firmas multifirma y MPC, AML/KYT y stablecoins con Fireblocks, BitGo, Kaleido, Circle y Tether.
Labs para convertir lectura en práctica
Prueba de trabajo y reajuste de dificultad
Laboratorio determinista para medir el costo de la prueba de trabajo y ver cómo el reajuste por altura de bloque estabiliza el tiempo entre bloques.
Consensus benchmark
Banco reproducible para comparar consenso BFT y documentar los trade-offs de HotStuff-2.
Ethereum local: Anvil, wallet y primera transacción
Laboratorio base para levantar una red local, crear cuentas, firmar transacciones y observar receipts sin gastar ETH real.
RPC y Cast: leer Ethereum con lupa
Ejercicios con JSON-RPC y Cast para consultar bloques, nonces, gas, receipts, logs y errores reproducibles.
Sigue leyendo
Ideas conectadas con este artículo
Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.
Storage layout: donde Solidity cobra la factura
Slots, packing, mappings, arrays, structs y por qué storage layout importa para gas y upgrades.
Ethereum: mapa de lectura antes de tocar Solidity
Ruta completa para aprender Ethereum desde historia y arquitectura hasta EVM, proof-of-stake, L2, seguridad, DeFi y labs reproducibles.
Antes de Ethereum: Bitcoin, scripts y la máquina mundial
Historia inicial de Ethereum: qué problema vio el whitepaper, por qué los contratos generales importaban y qué cambió frente a Bitcoin.
Frontier, Homestead y The DAO: la red aprendió con golpes
De mainnet inicial a The DAO: primeros forks, tensiones sociales y por qué la historia de Ethereum no es solo técnica.