¿Cómo funciona la EVM? Descifrando la máquina virtual Ethereum paso a paso

1. Descripción general

La EVM (Ethereum Virtual Machine) es la “CPU” que ejecuta todos los contratos inteligentes en Ethereum. Entender cómo la EVM ejecuta bytecode te ayuda a leer trazas de transacciones, depurar errores de out of gas, y escribir contratos Solidity más eficientes en gas. Este artículo va desde el modelo de cuenta, la arquitectura basada en stack, hasta cómo se calcula el gas, incluyendo ejemplos en Solidity y código Java (Web3j) para que veas el flujo completo desde bytecode hasta el resultado de la ejecución.

2. ¿Qué es la EVM?

La EVM es una máquina virtual basada en stack, determinista (el mismo input siempre produce el mismo output), que se ejecuta en cada nodo Ethereum. Cada nodo reejecutar exactamente las transacciones en un bloque para llegar a un consenso sobre el estado final.

La diferencia más grande en comparación con la JVM o una CPU normal: la EVM no tiene concepto de threads, no tiene I/O, no puede llamar APIs externas. Todo lo que necesita (datos, saldos, código de otros contratos) está en el world state — un árbol Merkle Patricia Trie que almacena todas las cuentas.

3. Modelo de Cuentas (Account Model)

Ethereum tiene dos tipos de cuentas:

  • EOA (Externally Owned Account): controlada por una clave privada, sin código.
  • Contract Account: tiene code (bytecode EVM) y storage propio.
Account {
  nonce:   número de transacciones enviadas (EOA) o contratos creados
  balance: saldo de ETH (en unidades de wei)
  storage: Merkle Trie key-value de 256-bit (solo contract account)
  codeHash: hash del bytecode (solo contract account)
}

Cuando envías una transacción a una dirección con código, la EVM carga ese code y comienza la ejecución — este es el momento en que la “máquina virtual” entra en juego.

4. Arquitectura de la máquina virtual: Stack, Memory, Storage, Calldata

La EVM tiene cuatro regiones de datos principales, cada una diferente en cuanto a costo de gas y alcance de vida.

4.1. Stack

El Stack contiene palabras de 256 bits, máximo 1024 elementos. Toda operación aritmética (ADD, MUL, SUB…) toma operandos del stack y devuelve el resultado al stack.

PUSH1 0x05   ; stack: [5]
PUSH1 0x03   ; stack: [5, 3]
ADD          ; stack: [8]

Las tres líneas de opcode anteriores ejecutan 5 + 3: dos PUSH1 empujan los números 5 y 3 al stack, ADD toma los dos elementos superiores, los suma, y devuelve el resultado 8 al stack.

4.2. Memory

Memory es un área temporal de array de bytes, que solo existe durante la ejecución de una llamada (call). Se usa para datos grandes como structs o arrays antes de devolverlos o escribirlos en storage.

4.3. Storage

Storage es key-value (uint256 -> uint256) persistente, asociado a cada contrato, escrito en el state trie de la blockchain. Esta es la región más cara en gas porque debe ser consensuada y almacenada permanentemente.

4.4. Calldata

Calldata son los datos de entrada de la transacción — solo lectura, no cuesta gas “asignar” como en memory.

Nota: confundir memory y storage en Solidity es el error más común de los principiantes — declarar variables innecesariamente como storage hace que el costo de gas se dispare.

5. Bytecode y Opcode

Solidity compila a bytecode, una secuencia hexadecimal. Cada byte (o un byte + operando) corresponde a un opcode — una instrucción de bajo nivel que la EVM entiende directamente.

Ejemplo de un contrato que suma dos números y guarda el resultado:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public count;

    function increment() external {
        count += 1;
    }
}

Compilar con solc:

solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE

Este segmento de opcode lee count del storage (SLOAD), suma 1 (ADD), luego lo escribe de vuelta (SSTORE) — exactamente la lógica de count += 1.

Para ver claramente cómo la EVM decodifica bytecode en opcodes, escribo un decodificador Java simple (sin necesidad de un nodo real, solo para ilustrar el mecanismo):

public class OpcodeDecoder {

    private static final Map<Integer, String> OPCODES = Map.of(
        0x00, "STOP",
        0x01, "ADD",
        0x54, "SLOAD",
        0x55, "SSTORE",
        0x60, "PUSH1"
    );

    public List<String> decode(String hexBytecode) {
        byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
        List<String> ops = new ArrayList<>();
        int i = 0;
        while (i < code.length) {
            int opcode = code[i] & 0xFF;
            String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
            ops.add(name);
            // PUSH1 tiene 1 byte de operando que sigue, necesita saltar sobre él
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES mapea códigos hex a nombres de opcode (la tabla real tiene 140+ códigos).
  • decode lee cada byte, busca el nombre del opcode; si es PUSH1, salta 1 byte más porque este opcode viene con un operando.
  • El resultado es una lista de opcodes en el orden exacto en que la EVM los ejecutará.

Prueba con JUnit 5 + AssertJ:

@Test
void decode_shouldReturnPushAddSstoreSequence() {
    OpcodeDecoder decoder = new OpcodeDecoder();

    List<String> ops = decoder.decode("0x600101550054");

    assertThat(ops).containsExactly(
        "PUSH1", "ADD", "SSTORE", "SLOAD"
    );
}

Esta prueba afirma que el decodificador lee el orden correcto de opcodes del bytecode sin procesar — exactamente el primer paso que la EVM realiza antes de ejecutar cada instrucción.

6. Gas: unidad de medida del costo de ejecución

Cada opcode tiene un costo de gas fijo (según el EIP actual), que refleja la carga en la CPU/storage del nodo. Una transacción debe incluir gasLimit; si se ejecuta más allá de ese límite, la EVM revierte todos los cambios de estado pero aun así retiene el gas consumido (para evitar spam).

ADD      -> 3 gas
SLOAD    -> 2100 gas (cold) / 100 gas (warm)
SSTORE   -> 20000 gas (crear) / 2900 gas (actualizar)
PUSH1    -> 3 gas

Una utilidad pequeña para estimar el gas de una secuencia de opcodes:

public class GasEstimator {

    private static final Map<String, Long> COST = Map.of(
        "ADD", 3L,
        "PUSH1", 3L,
        "SLOAD", 2100L,
        "SSTORE", 20000L
    );

    public long estimate(List<String> opcodes) {
        return opcodes.stream()
            .mapToLong(op -> COST.getOrDefault(op, 0L))
            .sum();
    }
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
    GasEstimator estimator = new GasEstimator();
    List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");

    long gas = estimator.estimate(ops);

    assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}

estimate suma el gas de cada opcode según la tabla de precios; el resultado 22106 muestra que solo una instrucción count += 1 consume la mayor parte del gas en SSTORE — esta es la razón por la que los desarrolladores de Solidity siempre aconsejan reducir la cantidad de escrituras en storage.

7. Ciclo de vida del procesamiento de una transacción en la EVM

Cuando una transacción llega a un contrato, la EVM procede de la siguiente manera:

  1. Verifica que nonce, balance sea suficiente para pagar gasLimit * gasPrice.
  2. Carga el calldata y el code del contrato destino.
  3. Inicializa stack y memory vacíos; lee el storage actual.
  4. Ejecuta cada opcode según el program counter (PC), restando el gas correspondiente en cada paso.
  5. Si encuentra REVERT o se queda sin gas → revierte todos los cambios de estado, pero el gas usado se sigue restando.
  6. Si tiene éxito → confirma el nuevo storage en el world state, emite receipt junto con logs (eventos).
Transaction
   │
   ▼
Validar nonce y balance
   │
   ▼
Cargar code + calldata ──► Ejecutar opcodes (PC aumenta)
   │                            │
   │                      restar gas cada paso
   │                            │
   ▼                            ▼
 Sin gas / REVERT         Éxito
   │                            │
   ▼                            ▼
Revertir state            Confirmar state + logs

8. Ejemplo práctico: llamar un contrato desde Spring Boot con Web3j

Para interactuar realmente con la EVM (a través de un nodo local como Ganache/Anvil o testnet), necesitas la librería Web3j:

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.11.1</version>
</dependency>

Un servicio que lee el valor count de un contrato desplegado:

@Service
public class CounterService {

    private final Web3j web3j;
    private final String contractAddress;

    public CounterService(Web3j web3j,
                           @Value("${evm.counter.address}") String contractAddress) {
        this.web3j = web3j;
        this.contractAddress = contractAddress;
    }

    public BigInteger readCount() throws IOException {
        Function function = new Function(
            "count",
            Collections.emptyList(),
            List.of(new TypeReference<Uint256>() {})
        );
        String encoded = FunctionEncoder.encode(function);

        EthCall response = web3j.ethCall(
            Transaction.createEthCallTransaction(null, contractAddress, encoded),
            DefaultBlockParameterName.LATEST
        ).send();

        List<Type> results = FunctionReturnDecoder.decode(
            response.getValue(), function.getOutputParameters()
        );
        return (BigInteger) results.get(0).getValue();
    }
}
  • Function describe la función count() a llamar y el tipo de dato de retorno (Uint256).
  • FunctionEncoder.encode lo convierte en calldata hex (function selector + params) — exactamente el dato que la EVM cargará en el stack durante la ejecución.
  • ethCall envía una solicitud eth_call — ejecuta la EVM en modo lectura, no crea una transacción real, no cuesta gas.
  • FunctionReturnDecoder.decode decodifica la salida hex devuelta en BigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

Nota: eth_call no escribe state, así que no necesita gasLimit real y no devuelve transactionHash. Si necesitas escribir datos (llamar increment()), debes usar eth_sendRawTransaction con la clave privada firmando la transacción — y solo entonces se consume gas real.

9. Errores comunes y cuándo ser cauteloso

  • Out of gas: gasLimit estimado muy bajo para bucles o múltiples escrituras en storage. Siempre prueba con eth_estimateGas antes de enviar una transacción real.
  • Reentrancy: la EVM permite que el contrato A llame al contrato B, y B llame de nuevo a A antes de que A actualice el state — resultando en retiros múltiples (caso The DAO). Usa el patrón checks-effects-interactions o el modificador nonReentrant.
  • Stack too deep: el compilador de Solidity limita el número de variables locales porque el stack solo tiene 1024 slots; divide la función en piezas más pequeñas cuando encuentres este error.
  • No deberías ejecutar lógica intensiva en cálculos (hashes complejos, bucles grandes) directamente on-chain — cada bucle multiplica el costo de gas, así que mejor calcula off-chain y luego envía el resultado con prueba si es posible.

10. Conclusión

La EVM es una máquina virtual basada en stack, simple en su conjunto de instrucciones pero rigurosa en gas y state — cada opcode tiene un precio, cada cambio en storage es permanente y costoso. Entender bien stack/memory/storage, cómo se calcula el gas, y el ciclo de vida de una transacción te ayuda a escribir contratos inteligentes más eficientes y a depurar cuando las transacciones revierten. Si trabajas con contratos con frecuencia, practica leyendo trazas reales de opcodes usando evm disasm o el debugger en Hardhat/Foundry — es mucho más intuitivo que solo leer Solidity.

evmethereumblockchainsmart-contractsolidity