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 . Este capítulo propone leer cualquier contrato con una sola pregunta: ¿qué transición de estado permite, a quién, y bajo qué condiciones?

Lectura de una función

Cinco preguntas antes de aprobar una función

0 de 5 revisados

Sirven igual para leer código propio que para revisar un contrato de terceros.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

msg.sender

La cuenta o contrato que hizo la llamada actual; base de todo control de acceso.

Evidencia

Eventos y receipt

Los logs quedan en el bloque; los contratos no pueden leerlos.

Estado

Storage del contrato

Persistente 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 . 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.

solidity
// 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 . 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 . 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 . 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 . 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.

Figura 1

Una llamada a withdraw: checks, effects, interactions y el revert que lo deshace todo

Fuentes:

[3][4][5]
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.

Visual original de TrautsLab. Las tres fases del patrón checks-effects-interactions y la rama de revert, que devuelve el storage al estado inicial.

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 . En Solidity 0.8 la aritmética está comprobada por defecto: un desbordamiento revierte con Panic(0x11) salvo dentro de un bloque unchecked . 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 . 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 . 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 . 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 . 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

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Solidity Team, "Introduction to Smart Contracts," Solidity Documentation.

    official docsID: SOLIDITY-INTROAbrir fuente (abre en otra pestaña)

    Contrato como código y datos en una dirección; transacciones atómicas; msg.sender.

  2. [2]

    Solidity Team, "Contracts," Solidity Documentation.

    official docsID: SOLIDITY-CONTRACTSAbrir fuente (abre en otra pestaña)

    Visibilidad y getters, constant/immutable, view/pure, modifiers, eventos e indexed, errores personalizados.

  3. [3]

    Solidity Team, "Expressions and Control Structures," Solidity Documentation.

    official docsID: SOLIDITY-CONTROLAbrir fuente (abre en otra pestaña)

    revert, require, assert, Panic y Error(string); propagación de excepciones y retorno false de call/delegatecall/staticcall.

  4. [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. [5]

    Solidity Team, "Security Considerations," Solidity Documentation.

    official docsID: SOLIDITY-SECURITYAbrir fuente (abre en otra pestaña)

    Información pública aunque sea private, reentrancy y patrón Checks-Effects-Interactions.

  6. [6]

    Solidity Team, "Types," Solidity Documentation.

    official docsID: SOLIDITY-TYPESAbrir fuente (abre en otra pestaña)

    Aritmética comprobada por defecto y modo unchecked con aritmética envolvente.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

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.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.