Ethereum: de historia a labs

Artículo 9 de 48

19%

completado al llegar aquí

Sistemas distribuidosNota de investigación16 min de lectura

Estado, storage y Merkle Patricia Trie

Cómo Ethereum compromete cuentas y storage en una stateRoot de 32 bytes, cómo un slot de Solidity se vuelve clave del trie y cómo verificar con eth_getProof.

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.

Un auditor pregunta: "¿qué saldo de token tenía la dirección A en el bloque 20 000 000?". Tu proveedor de RPC responde un número. ¿Cómo sabes que no se equivocó o mintió? La respuesta de Ethereum es que cada bloque compromete todo el estado en un solo hash de 32 bytes, y que cualquier dato puede acompañarse de una prueba que se verifica contra ese hash.

Este capítulo explica esa estructura. El estado es el conjunto de todas las cuentas con sus campos y el almacenamiento de cada contrato. Ethereum lo guarda en un Merkle Patricia Trie (MPT): un árbol de prefijos en el que cada nodo se identifica por el hash de su contenido.

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

Cliente de ejecución

Aplica cada bloque y recalcula las raíces; si no coinciden, rechaza el bloque.

Evidencia

stateRoot y pruebas Merkle

Una ruta de nodos que une un valor con la raíz publicada en la cabecera.

Estado

Trie de cuentas y tries de storage

Uno global para cuentas y uno por contrato para sus slots.

Preguntas guía

  • ¿Por qué basta un hash de 32 bytes en la cabecera para comprometer millones de cuentas?
  • ¿Cómo se convierte una variable de Solidity en una clave del trie de storage?
  • ¿Qué devuelve eth_getProof y qué hay que tener para verificarlo?
  • ¿Qué datos de un contrato son realmente privados?

Conexiones dentro de la serie

Tres raíces en cada cabecera

Cada cabecera de bloque contiene tres raíces de tries distintos: stateRoot, transactionsRoot y receiptsRoot. El yellow paper define stateRoot como el hash Keccak-256 del nodo raíz del trie de estado después de ejecutar todas las transacciones del bloque.

Los tres tries no se comportan igual:

  • Trie de estado. Hay uno solo y cambia con cada bloque. La clave de cada entrada es keccak256(dirección) y el valor es rlp([nonce, balance, storageRoot, codeHash]).
  • Tries de storage. Hay uno por cuenta. Su raíz es el campo storageRoot de la cuenta; para una cuenta sin código está vacío.
  • Tries de transacciones y de receipts. Hay uno de cada tipo por bloque, con clave rlp(índice). El de receipts nunca se actualiza después de crearse el bloque.

El código de un contrato no está dentro del trie. La cuenta guarda solo codeHash y el código se busca en la base de datos por ese hash.

Figura 1

De la cabecera del bloque a un slot de storage

Fuentes:

[1][2]
De la cabecera del bloque a un slot de storage
Cómo leer esta figura

A la izquierda, la cabecera del bloque con stateRoot, transactionsRoot y receiptsRoot, y debajo los tries por bloque de transacciones y receipts, indexados por rlp del índice, que no cambian después. Una flecha azul rotulada stateRoot va a la raíz del trie de estado, que baja por un nodo branch, siguiendo los nibbles de la clave, hasta una hoja verde que representa una cuenta: clave keccak de la dirección y valor rlp de nonce, balance, storageRoot y codeHash. Desde la hoja, una flecha verde rotulada storageRoot sube a la raíz del trie de storage de ese contrato, que baja por un branch hasta una hoja de slot con clave keccak del slot y valor rlp de una palabra de 32 bytes. Otra flecha desde la cuenta, rotulada codeHash, apunta al código guardado aparte. Una nota dice que cambiar un solo slot cambia su hoja, cada nodo del camino, la storageRoot y la stateRoot.

Visual original de TrautsLab. La stateRoot de la cabecera es la raíz del trie global de cuentas; la hoja de cada cuenta contiene la storageRoot de su propio trie de storage y el hash de su código.

Invariante del compromiso: por qué una raíz basta

Un árbol de Merkle enlaza nodos por el hash de su contenido. En el MPT, cuando un nodo referencia a otro, guarda keccak256(rlp(nodo)); si la codificación RLP del nodo hijo mide menos de 32 bytes, lo guarda directamente en lugar del hash.

De ahí sale el invariante que sostiene todo el capítulo: la raíz es una función determinista de todo el contenido. Si cambia un solo slot, cambia su hoja; eso cambia el hash que guarda su padre, y así hasta la raíz del trie de storage. Esa raíz es un campo de la cuenta, así que cambia la hoja de la cuenta y, siguiendo el camino, la stateRoot.

La consecuencia práctica es fuerte. Dos clientes escritos en lenguajes distintos, con bases de datos internas distintas, están de acuerdo sobre el estado si y solo si calculan la misma stateRoot. Un cliente que recibe un bloque ejecuta las transacciones, recalcula la raíz y rechaza el bloque si no coincide con la de la cabecera. Fabricar un estado distinto con la misma raíz exigiría encontrar una colisión de Keccak-256.

Traza de una búsqueda: branch, extension y leaf

La clave de una cuenta son 32 bytes, es decir 64 nibbles (grupos de 4 bits, un carácter hexadecimal cada uno). Un trie ingenuo tendría 64 niveles. El MPT comprime los tramos sin bifurcación con tres tipos de nodo:

  • Branch: una lista de 17 elementos. Las primeras 16 posiciones corresponden a los 16 valores posibles del siguiente nibble y la última guarda un valor si alguna clave termina ahí.
  • Extension: un par [camino parcial, referencia] que salta varios nibbles compartidos y apunta al siguiente nodo.
  • Leaf: un par [resto del camino, valor] que termina la búsqueda.

Como extension y leaf son ambos pares, el primer nibble del camino parcial lleva una marca. Los valores 0 y 1 indican extension con longitud par o impar; 2 y 3, leaf con longitud par o impar. Buscar una cuenta es, entonces, calcular keccak256(dirección), partirlo en nibbles y bajar desde la raíz: en cada branch se elige la posición del siguiente nibble, en cada extension se comprueba el prefijo y se salta, hasta llegar a una leaf.

Flujo visual

Buscar una cuenta en el trie de estado

Cada paso usa solo datos públicos: la dirección, la stateRoot de la cabecera y los nodos del camino.
  1. Hashear la direcciónkeccak256(dirección): 32 bytes, 64 nibbles
  2. Cargar la raízel nodo cuyo hash es la stateRoot
  3. Branchelegir la posición del siguiente nibble
  4. Extensioncomprobar el prefijo compartido y saltarlo
  5. Leafresto del camino y rlp([nonce, balance, storageRoot, codeHash])

Hashear la dirección antes de usarla como clave tiene un efecto útil: las claves quedan repartidas de forma uniforme, de modo que nadie puede fabricar direcciones que alarguen artificialmente un camino del trie.

Del slot de Solidity a la clave del trie

El storage de un contrato es, para la EVM, un mapa de claves de 32 bytes a valores de 32 bytes. Cada clave se llama slot. Solidity decide qué slot ocupa cada variable: las variables de estado se colocan una tras otra empezando por el slot 0, y varias variables pequeñas pueden compartir un slot.

Los mappings no ocupan slots consecutivos. Si un mapping está declarado en el slot pp, el valor para la clave kk vive en keccak256(h(k) ∥ p)\mathrm{keccak256}(h(k) \,\|\, p), donde hh rellena los tipos de valor a 32 bytes. Para un array dinámico, el slot pp guarda la longitud y los elementos empiezan en keccak256(p)\mathrm{keccak256}(p).

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
// Ilustrativo: un token mínimo sin lógica de transferencia.
contract Ledger {
    mapping(address => uint256) public balanceOf; // slot 0 (la raíz del mapping)
    uint256 public totalSupply;                    // slot 1
}

El saldo de 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 en ese contrato está en el slot keccak256(dirección rellenada a 32 bytes ++ 0), que vale 0x14e04a66bf74771820a7400ff6cf065175b3d7eb25805a5bd1633b161af5d101. Ese es el número que se pasa a eth_getStorageAt. Dentro del trie de storage hay un segundo hash: la ruta real es keccak256(slot).

Separar esos dos hashes evita una confusión habitual. El primero lo calcula Solidity y define qué slot usa una variable. El segundo lo calcula el cliente y define dónde vive ese slot dentro del trie. Una herramienta de lectura solo necesita el primero.

Pruebas Merkle con eth_getProof: verificar sin confiar

EIP-1186 añadió el método eth_getProof. Recibe una dirección, una lista de slots y un bloque, y devuelve los campos de la cuenta, un accountProof con los nodos RLP desde la stateRoot hasta la hoja de la cuenta, y un storageProof por cada slot con los nodos desde la storageHash de la cuenta. La especificación JSON-RPC actual mantiene ese método.

Verificar una prueba es repetir la búsqueda con los nodos recibidos: hashear cada nodo, comprobar que coincide con la referencia del padre y llegar a la raíz. El punto delicado es de dónde sale la raíz. Si también la entrega el mismo proveedor, la prueba solo demuestra coherencia interna. La garantía aparece cuando la stateRoot viene de una cabecera que obtuviste por otra vía de confianza, por ejemplo de tu propio nodo o de un cliente ligero que sigue la cadena.

Hay un límite operativo. Un nodo completo conserva el estado reciente, típicamente los últimos 128 bloques, y puede regenerar estados antiguos bajo demanda; un nodo de archivo guarda todo desde el génesis. La pregunta del auditor sobre un bloque antiguo suele requerir un nodo de archivo o un proveedor que lo tenga.

Riesgo operativo: datos que parecen privados o borrados

Dos malentendidos aparecen en casi todas las revisiones de seguridad.

private no significa secreto. En Solidity, private e internal solo impiden que otros contratos lean o modifiquen la variable. El valor sigue siendo visible para todo el mundo fuera de la cadena. Cualquiera puede leer cualquier slot con eth_getStorageAt. Una contraseña, una semilla o un dato personal guardado en storage es público.

SELFDESTRUCT ya no borra. Desde Dencun, EIP-6780 cambió el opcode: si se ejecuta en una transacción distinta a la que creó el contrato, solo transfiere el balance; el código, el storage y la cuenta permanecen. Un diseño que cuente con "destruir y volver a desplegar en la misma dirección" ya no funciona.

Leer storage también tiene un costo que depende de la historia de la transacción. EIP-2929 cobra 2100 gas por el primer acceso a un slot en una transacción (acceso frío) y 100 por los siguientes (acceso caliente). El capítulo de la EVM vuelve sobre esa diferencia.

Ejercicio con Anvil: leer un slot y su prueba

Este ejercicio despliega un contrato de 8 bytes escritos a mano. Su código de inicialización 0x602a5f5560015ff3 guarda 42 (0x2a) en el slot 0 y deja como código final un solo byte 0x00 (STOP). El capítulo siguiente desarma esos bytes opcode por opcode.

Bash
# Requiere Anvil corriendo (anvil) y la cuenta de prueba 0 de su mnemónica pública
export ETH_RPC_URL=http://127.0.0.1:8545
PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
cast send --private-key $PK --create 0x602a5f5560015ff3
 
# Si fue la primera transacción de esa cuenta en un Anvil recién arrancado:
C=0x5FbDB2315678afecb367f032d93F642f64180aa3
cast storage $C 0            # palabra de 32 bytes que termina en ...2a
cast code $C                 # 0x00
cast proof $C 0              # accountProof y storageProof en JSON
 
# Slot de un mapping declarado en el slot 0 para una clave de tipo address
cast index address 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 0

Qué observar: cast storage devuelve el valor 42 relleno a 32 bytes; cast proof devuelve un storageHash igual a la raíz del trie de storage y una lista de nodos; cast index imprime el slot 0x14e04a66…d101 calculado arriba. Si ya enviaste otras transacciones, usa la dirección que aparece como contractAddress en el receipt. Detén Anvil para limpiar.

Referencias

Bibliografía académica

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

    ethereum.org, "Merkle Patricia Trie," Ethereum developer documentation. [Online]. Available: https://ethereum.org/developers/docs/data-structures-and-encoding/patricia-merkle-trie/

    official docsID: ETH-MPT-DOCSAbrir fuente (abre en otra pestaña)

    Nodos branch, extension y leaf; tries de estado, storage, transacciones y receipts.

  2. [2]

    G. Wood, "Ethereum: A secure decentralised generalised transaction ledger," Yellow Paper, Shanghai version efc5f9a, 2025.

    formal specID: ETH-YELLOWPAPERAbrir fuente (abre en otra pestaña)

    Especificación formal de estado, transacciones, firma (apéndice F), gas y EVM.

  3. [3]

    ethereum.org, "Ethereum accounts," Ethereum developer documentation. [Online]. Available: https://ethereum.org/developers/docs/accounts/

    official docsID: ETH-ACCOUNTS-DOCSAbrir fuente (abre en otra pestaña)

    Tipos de cuenta, los cuatro campos de una cuenta y derivación de la dirección.

  4. [4]

    Solidity Team, "Layout of state variables in storage and transient storage," Solidity documentation.

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

    Slots, empaquetado, arrays y mappings en storage.

  5. [5]

    S. Jentzsch and C. Jentzsch, "EIP-1186: RPC-method to get Merkle proofs - eth_getProof," Ethereum Improvement Proposals, 2018.

    Pruebas Merkle de cuenta y storage por RPC.

  6. [6]

    Ethereum, "Execution APIs: JSON-RPC specification," ethereum/execution-apis, GitHub repository.

    formal specID: ETH-EXECUTION-APISAbrir fuente (abre en otra pestaña)

    Especificación OpenRPC de métodos eth_*, block tags, receipts y logs.

  7. [7]

    ethereum.org, "Nodes and clients," Ethereum developer documentation. [Online]. Available: https://ethereum.org/developers/docs/nodes-and-clients/

    official docsID: ETH-NODES-DOCSAbrir fuente (abre en otra pestaña)

    Clientes de ejecución y consenso; nodos full, archive y light.

  8. [8]

    Solidity Team, "Contracts: visibility and getters," Solidity documentation.

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

    private e internal no ocultan datos fuera de la cadena.

  9. [9]

    G. Ballet, V. Buterin and D. Feist, "EIP-6780: SELFDESTRUCT only in same transaction," Ethereum Improvement Proposals, 2023.

    SELFDESTRUCT deja de borrar código y storage salvo en la transacción de creación.

  10. [10]

    T. Beiko, "EIP-7569: Hardfork meta - Dencun," Ethereum Improvement Proposals, 2023.

    EIPs de Dencun y activación en mainnet.

  11. [11]

    V. Buterin and M. Swende, "EIP-2929: Gas cost increases for state access opcodes," Ethereum Improvement Proposals, 2020.

    Accesos fríos y calientes a cuentas y slots.

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.