«Contrato inteligente» sugiere un documento legal que se ejecuta solo. Lo que existe realmente en Ethereum y sus muchas cadenas compatibles es más modesto y más interesante: un programa guardado en la cadena que cada nodo ejecuta de forma idéntica, cuyas únicas entradas son transacciones y cuyas únicas salidas son cambios en un estado compartido. Entender la máquina que lo ejecuta explica casi todo lo sorprendente de los contratos, desde por qué los bucles son caros hasta por qué una sola comprobación ausente puede vaciar una tesorería.

Dos tipos de cuenta

El estado de Ethereum es un mapa de direcciones a cuentas. Una cuenta de propiedad externa la controla una clave privada y tiene saldo y nonce. Una cuenta de contrato tiene, además, código inmutable y un almacenamiento persistente: 2256 slots de 32 bytes cada uno, casi todos a cero. Nadie tiene una clave de una cuenta de contrato; solo actúa cuando una transacción u otro contrato la llama. Ese es todo el modelo de seguridad en una línea: un contrato solo puede hacer lo que su código permite, y su código es público.

Qué es la EVM

La Máquina Virtual de Ethereum es una máquina de pila deliberadamente simple y determinista. Cada operación, llamada opcode, saca argumentos de una pila de palabras de 256 bits y empuja resultados. Hay opcodes para aritmética, comparaciones, saltos dentro del código, lectura de la transacción (quién llamó, cuánto ether, qué datos), lectura y escritura de tres tipos de memoria y llamadas a otros contratos. No hay números de coma flotante, ni hilos, ni reloj más allá de la marca de tiempo del bloque, ni aleatoriedad, ni acceso a nada fuera de la cadena. Esas omisiones son lo que hace que miles de nodos produzcan el mismo resultado con la misma entrada.

Tres lugares donde viven los datos

ÁreaDuraciónCosteUso
PilaEjecución actualCasi gratisValores de trabajo; límite de profundidad 1.024
MemoriaLlamada actualBarata, crece de forma cuadráticaArrays temporales, construir datos de retorno
AlmacenamientoPara siempre (hasta sobrescribirse)Caro: escribir un valor nuevo distinto de cero cuesta decenas de miles de gasSaldos, propietarios, cualquier estado que deba perdurar

El calldata, los bytes de entrada de la transacción, es una cuarta área, de solo lectura. Leer de ella es barato, por eso las funciones bien escritas reciben argumentos grandes como calldata en lugar de copiarlos a memoria.

Gas: por qué cada instrucción tiene precio

Cada opcode tiene un coste en gas, aproximadamente proporcional al trabajo que impone a cada nodo del mundo: sumar dos números cuesta 3 gas, escribir un slot de almacenamiento nuevo cuesta 20.000 y el mero hecho de enviar una transacción cuesta 21.000 de base. Una transacción declara un límite de gas; la ejecución se detiene con un error de out of gas si se supera y todo lo que hizo la transacción se revierte, pero el gas consumido se paga igual. No es un modelo de comisiones añadido para recaudar. Sin él, un solo bucle infinito congelaría todos los nodos, así que el gas es el mecanismo que hace seguro ejecutar un programa no confiable en un ordenador compartido. También explica los instintos de diseño de quienes desarrollan contratos: los bucles sobre arrays sin límite son peligrosos, las escrituras en almacenamiento se minimizan y se usan eventos en lugar de almacenamiento cuando los datos solo se leen fuera de la cadena.

Siguiendo una transacción

Tomemos una llamada a la función transfer(address to, uint256 amount) de un contrato de token.

  1. Codificación. La billetera calcula el selector de función: los primeros cuatro bytes del keccak-256 de la cadena de firma transfer(address,uint256), que es 0xa9059cbb. Añade los dos argumentos, cada uno rellenado a 32 bytes, siguiendo las reglas de codificación del ABI. Esos son los «datos de entrada» que muestra un explorador de bloques.
  2. Despacho. El bytecode del contrato empieza con un despachador generado por el compilador: lee los primeros cuatro bytes del calldata, los compara con el selector de cada función pública y salta al código correspondiente. Si no hay coincidencia, ejecuta la función fallback si existe, o revierte.
  3. Ejecución. La función de transferencia carga el saldo del llamante desde un slot de almacenamiento, comprueba que es suficiente, resta, carga el slot del destinatario, suma y escribe ambos de vuelta. Solidity calcula el slot de balances[addr] como el hash de la dirección y la posición del mapping, que es como un mapping puede direccionar 2256 claves sin reservar nada.
  4. Evento. La función emite Transfer(from, to, amount). Los logs se escriben en el recibo de la transacción, no en el almacenamiento del contrato; los contratos no pueden leerlos, pero son baratos e indexados, así que billeteras y exploradores dependen de ellos.
  5. Confirmar o revertir. Si la ejecución llega al final sin error, los cambios de almacenamiento pasan a formar parte del nuevo estado global. Si falla algún require o se agota el gas, todos los cambios de esta transacción se descartan como si nunca se hubiera ejecutado. La atomicidad es la propiedad que permite intentar con seguridad transacciones DeFi complejas de varios pasos: o ocurre todo el intercambio-y-devolución, o no ocurre nada.

Contratos que llaman a contratos

Un contrato puede llamar a otro con CALL, que ejecuta el código del llamado con el almacenamiento del llamado y puede enviar ether, o con STATICCALL, que prohíbe cambios de estado y es lo que usan las funciones de vista. La tercera forma, DELEGATECALL, ejecuta el código del llamado en el contexto de almacenamiento del llamante, como si el código se hubiera pegado dentro del llamante. Existe para compartir código de bibliotecas, y es el mecanismo detrás de los proxies actualizables: un contrato proxy delgado custodia el almacenamiento y delega cada llamada a una dirección de implementación que puede cambiar. Los usuarios interactúan con la dirección del proxy para siempre mientras la lógica detrás evoluciona. El coste es que «código inmutable» pasa a ser «código que el administrador puede sustituir», por eso la pregunta quién tiene la clave de actualización importa tanto como la auditoría.

Los fallos que más han costado

La mayoría de los fallos catastróficos de contratos no son exóticos. Salen de un puñado de patrones, todos consecuencia de la máquina descrita arriba.

  • Reentrada. Un contrato envía ether a una dirección antes de actualizar su propia contabilidad. Si el destinatario es un contrato, su código se ejecuta de inmediato y puede volver a llamar al emisor, que todavía muestra el saldo antiguo, y retirar otra vez. Así se vació The DAO en 2016. El remedio es una disciplina, checks-effects-interactions: verificar, actualizar el estado y solo entonces llamar fuera; o usar un guardián de reentrada.
  • Aritmética sin comprobar. Los enteros de la EVM se desbordan en silencio. Antes de Solidity 0.8, una resta por debajo de cero producía un número enorme salvo que el código lo comprobara; varios contratos de tokens acuñaron un suministro prácticamente ilimitado por esta vía. Los compiladores modernos revierten en caso de desbordamiento por defecto, pero los bloques unchecked escritos a mano reintroducen el riesgo.
  • Control de acceso ausente. Una función que debería ser solo del propietario pero es pública. Suena demasiado simple para ocurrir y ocurre cada año, incluso en funciones inicializadoras de proxies que cualquiera puede llamar una vez para convertirse en propietario.
  • Manipulación del oráculo de precios. Un contrato lee un precio de un pool en cadena que un atacante puede mover con una operación grande en la misma transacción, a menudo financiada con un préstamo flash, y después usa el precio distorsionado para pedir prestado o liquidar. La máquina hizo exactamente lo que se le dijo; el error fue confiar en una entrada manipulable.
  • Delegatecall a código no confiable. Como delegatecall ejecuta código ajeno contra tu almacenamiento, un delegatecall a una dirección controlada por un atacante le entrega el contrato. Un famoso incidente de 2017 en una biblioteca de billeteras bloqueó permanentemente cientos de miles de ether por un error relacionado.

Cómo leer un contrato con esto en mente

Cuando abras el código fuente de un contrato verificado, busca primero dónde salen ether o tokens del contrato y comprueba qué estado se actualizó antes de esa línea. Localiza cada función que pueda cambiar un propietario, una dirección de implementación o una comisión, y mira quién puede llamarla. Rastrea cada precio o saldo externo que lea el contrato y pregúntate si el llamante podría haberlo cambiado en la misma transacción. Esas tres pasadas cubren la mayoría de los exploits históricos y llevan menos tiempo que leer la web de marketing.

Sigue leyendo: los recibos, logs y motivos de reversión que produce esta ejecución se descifran campo a campo en Cómo leer un explorador de bloques. Cómo usan los rollups estos mismos contratos para heredar la seguridad de la capa base se explica en Layer 2 explicado. Para el lado de los incidentes, nuestras notas siguen los exploits actuales a medida que ocurren.

Preguntas frecuentes

¿Qué es la EVM? La Máquina Virtual de Ethereum es el ordenador determinista basado en pila que ejecuta cada nodo de Ethereum para correr el código de los contratos. No tiene acceso a nada fuera de la cadena, que es lo que permite a miles de nodos independientes llegar exactamente al mismo resultado.

¿Por qué escribir en almacenamiento cuesta tanto gas? Cada escritura en almacenamiento debe conservarla cada nodo completo para siempre, así que el protocolo la tarifa muy por encima del cómputo. Escribir un valor nuevo distinto de cero en un slot vacío cuesta del orden de veinte mil gas, frente a unos pocos gas por una operación aritmética.

¿Se puede cambiar un contrato inteligente después de desplegarlo? El código en una dirección es inmutable. Los contratos actualizables lo sortean con un proxy que delega las llamadas a una dirección de implementación que el administrador puede cambiar, así que en la práctica el comportamiento que ven los usuarios puede cambiar. Comprueba quién controla la clave de actualización.

¿Qué es la reentrada? Un fallo en el que un contrato llama a una dirección externa antes de actualizar su propio estado, permitiendo que el contrato llamado vuelva a entrar y repita la acción con datos obsoletos. Se evita actualizando el estado antes de hacer llamadas externas o usando un guardián de reentrada.