Ethereum: de historia a labs

Artículo 15 de 48

31%

completado al llegar aquí

Sistemas distribuidosNota de investigación13 min de lectura

Storage layout: donde Solidity cobra la factura

Cómo asigna Solidity slots de 32 bytes: empaquetado, mappings y arrays por keccak256, strings cortos, costo de acceso y por qué el layout rompe upgrades.

Ruta Ethereum: 0/48

Si alguien te da la dirección de un contrato y el número 0, ¿qué estás leyendo? En Ethereum, el storage de cada contrato es un diccionario enorme de claves de 32 bytes a valores de 32 bytes. Cada entrada se llama slot. Solidity decide en qué slot cae cada variable siguiendo reglas fijas y públicas, y esas reglas explican tres cosas que un equipo nota tarde: por qué una variable mal ordenada cuesta más gas, cómo leer un saldo sin ABI y por qué un upgrade inocente puede corromper un contrato.

La documentación de Solidity considera el layout de storage parte de la interfaz externa del lenguaje: cambiar sus reglas sería un cambio incompatible . Por eso estas reglas son estables y vale la pena aprenderlas una vez.

Invariante de upgrade

Qué cambios de variables tolera un contrato actualizable

Recomendado

Añadir variables al final

Los slots existentes no se mueven; la nueva variable ocupa un slot libre.

Ideal para
Extender una implementación sin migrar datos.
Ojo con
En herencia, el final de un contrato base no es el final del layout completo.
Riesgo

Insertar, borrar o reordenar

Todas las variables posteriores cambian de slot y leen datos ajenos.

Ideal para
Nunca en un contrato ya desplegado detrás de un proxy.
Ojo con
El compilador no avisa: el código es válido, el estado queda corrupto.
Precaución

Cambiar el tipo de una variable

Puede cambiar el tamaño, el empaquetado y la forma de interpretar los bytes.

Ideal para
Solo con una migración explícita y probada.
Ojo con
Un uint128 que pasa a uint256 desplaza a sus vecinos empaquetados.

Storage con espacio de nombres

Agrupa variables en un struct anclado en un slot derivado por hash (ERC-7201).

Ideal para
Librerías y contratos modulares que comparten storage.
Ojo con
La anotación es convención: el compilador no verifica que se use bien.
Con un proxy, la nueva implementación lee el storage que escribió la anterior. El layout es el contrato entre ambas.

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

El compilador

Asigna slots y offsets según orden de declaración, tamaño y herencia.

Evidencia

storageLayout y cast storage

El mapa declarado por el compilador contra el valor crudo leído del nodo.

Estado

Slots de 32 bytes

Persisten entre implementaciones; el código cambia, los datos se quedan.

Preguntas guía

  • ¿Por qué uint128, uint128, uint256 ocupa dos slots y uint128, uint256, uint128 ocupa tres?
  • ¿En qué slot está el saldo de una dirección concreta dentro de un mapping?
  • ¿Cómo distingue Solidity un string corto de uno largo leyendo un solo slot?
  • ¿Qué se rompe si la versión 2 de un contrato añade una variable al principio?

Un slot de 32 bytes y un contador que empieza en cero

Las variables de estado se colocan una tras otra a partir del slot 0, en el orden en que se declaran. Cada tipo tiene un tamaño en bytes: uint256 usa 32, address 20, bool 1, uint64 8. Si varias variables seguidas caben en 32 bytes, comparten slot . A esto se le llama empaquetado (packing).

Las reglas concretas son cinco. El primer elemento de un slot se alinea en los bytes de menor orden, es decir, a la derecha del número. Los tipos de valor usan solo los bytes que necesitan. Si un valor no cabe en lo que queda del slot, pasa al siguiente. Los structs y arrays siempre empiezan un slot nuevo. Y lo que viene después de un struct o array también empieza un slot nuevo.

Un ejemplo mínimo, compilado con Solidity 0.8.28, muestra el efecto del orden:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
contract SinEmpaquetar {
    uint128 public a;   // slot 0, offset 0
    uint256 public b;   // no cabe junto a `a`: slot 1
    uint128 public c;   // slot 2
}
 
contract Empaquetado {
    uint128 public a;   // slot 0, offset 0
    uint128 public c;   // slot 0, offset 16
    uint256 public b;   // slot 1
}

Es el mismo ejemplo que da la documentación: uint128, uint128, uint256 ocupa dos slots y uint128, uint256, uint128 ocupa tres. En herencia, el orden lo fija la linealización C3 empezando por el contrato más base, y variables de contratos distintos pueden compartir slot si las reglas lo permiten. Las variables constant e immutable no ocupan storage.

Traza de ejecución: dónde cae cada variable de Ledger

Tomemos un contrato con los casos que aparecen en la práctica: un valor empaquetado, un entero completo, un mapping, un array dinámico y un string.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
contract Ledger {
    address public owner;                          // slot 0, offset 0 (20 bytes)
    bool public paused;                            // slot 0, offset 20
    uint64 public lastUpdate;                      // slot 0, offset 21
    uint256 public totalSupply;                    // slot 1
    mapping(address => uint256) public balances;   // slot 2 (vacío; ancla del hash)
    uint256[] public history;                      // slot 3 (guarda la longitud)
    string public name;                            // slot 4
}

No hace falta adivinar: el compilador publica el mapa. forge inspect Ledger storageLayout lo imprime, y la salida JSON estándar de solc incluye para cada variable su slot, su offset en bytes y su tipo . Los comentarios del código coinciden con esa salida: owner, paused y lastUpdate comparten el slot 0 y ocupan 29 de sus 32 bytes.

Figura 1

Mapa de storage del contrato Ledger

Fuentes:

[1]
Mapa de storage del contrato Ledger
Cómo leer esta figura

Cinco filas representan los slots 0 a 4 del contrato Ledger, cada una de 32 bytes con el byte 31 a la izquierda y el byte 0 a la derecha. En el slot 0, de derecha a izquierda, están owner con 20 bytes, paused con 1 byte en el offset 20, lastUpdate con 8 bytes y 3 bytes libres. El slot 1 contiene totalSupply completo. El slot 2 está vacío y sirve de ancla para el mapping balances; una flecha lo une a una caja que dice que balances[k] vive en keccak256(h(k) concatenado con 2). El slot 3 guarda la longitud del array history; una flecha lo une a una caja que dice que history[i] vive en keccak256(3) más i. El slot 4 guarda el string name con el texto Ledger y en el byte final la longitud por dos. Una nota indica que las posiciones derivadas están lejos del rango 0 a 4 y no colisionan.

Visual original de TrautsLab. Los valores pequeños se empaquetan desde el byte de menor orden; mappings y arrays dinámicos reservan un slot ancla y guardan sus datos en posiciones derivadas con keccak256.

Un mapping no puede guardarse "en medio" porque su tamaño es impredecible. Ocupa un slot p que queda vacío y sirve solo para que dos mappings vecinos no colisionen. El valor de la clave k vive en keccak256(h(k) . p), donde h rellena los tipos de valor a 32 bytes y . es concatenación . Para un array dinámico en el slot p, ese slot guarda la longitud y los elementos empiezan en keccak256(p).

Con números concretos: el saldo de 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (la segunda cuenta de Anvil) en balances está en el slot 0x6ffab96d4009ce38df68f4dc04583568617773212ffc44bef9feaece2962b766, que es keccak256(abi.encode(clave, uint256(2))). Cast lo calcula y lo lee sin ABI:

Bash
SLOT=$(cast index address 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 2)
cast storage $LEDGER $SLOT          # saldo crudo en hexadecimal
cast storage $LEDGER 0              # owner, paused y lastUpdate en un solo valor

Los strings y bytes tienen una codificación especial. Si el contenido mide 31 bytes o menos, se guarda en el mismo slot, alineado a la izquierda, y el último byte vale longitud * 2. Si mide 32 o más, el slot guarda longitud * 2 + 1 y los datos van a keccak256(p). El bit más bajo distingue ambos casos. Si name vale "Ledger", el slot 4 contiene 0x4c6564676572, ceros, y 0c (12) al final.

Cuello de botella: el costo de leer y escribir slots

El empaquetado no es siempre más barato. La EVM opera en palabras de 32 bytes, así que un tipo más pequeño obliga a operaciones extra para aislarlo. La ventaja aparece cuando el compilador puede combinar en una sola operación varias lecturas o escrituras del mismo slot. Si solo escribes una de las variables, el slot debe leerse primero para no destruir a sus vecinas . En argumentos y memoria no hay beneficio, porque allí el compilador no empaqueta.

El precio de tocar un slot depende de si ya se tocó en la misma transacción. Desde EIP-2929, la primera lectura de un slot (acceso "frío") cuesta 2100 de gas y las siguientes ("calientes") cuestan 100 . Por eso agrupar en un slot las variables que se leen juntas, como owner y paused en una comprobación de acceso, ahorra un acceso frío por llamada. Agrupar variables que nunca se usan juntas no aporta nada.

La regla práctica para un equipo: ordena las variables por cómo se usan, no por estética, y mide. forge test --gas-report y las trazas con -vvvv muestran el gas por llamada, así que la decisión se valida con números de tu contrato y no con intuiciones.

Invariante de upgrade: el mapa de slots es una interfaz

En un contrato actualizable, un proxy guarda el estado y delega la ejecución en una implementación que se puede reemplazar. La implementación nueva lee los mismos slots que escribió la anterior. Si el mapa cambia, el código nuevo interpreta bytes viejos con un significado distinto. Mira lo que ocurre al añadir una variable al principio:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
contract VaultV1 {
    address public owner;        // slot 0
    uint256 public totalShares;  // slot 1
}
 
contract VaultV2Roto {
    uint256 public feeBps;       // slot 0: lee los bytes de `owner`
    address public owner;        // slot 1: lee `totalShares`
    uint256 public totalShares;  // slot 2: vacío
}
 
contract VaultV2Correcto {
    address public owner;        // slot 0
    uint256 public totalShares;  // slot 1
    uint256 public feeBps;       // slot 2, nuevo y al final
}

VaultV2Roto compila sin errores. Tras el upgrade, feeBps devuelve la dirección del dueño interpretada como número, owner devuelve los últimos 20 bytes del total de shares y totalShares devuelve cero. Ningún dato se borró; simplemente se lee en el lugar equivocado. Comparar la salida de storageLayout de ambas versiones antes de cada upgrade detecta este error de forma mecánica.

Los propios proxies necesitan guardar la dirección de la implementación sin chocar con las variables de esta. EIP-1967 fija ese slot en bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1), que es 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc . Al ser un hash, es prácticamente imposible que el layout normal llegue a esa posición.

ERC-7201 generaliza la idea para el estado de la aplicación. Las variables se agrupan en un struct anotado con @custom:storage-location erc7201:<id>, cuya raíz es keccak256(keccak256(id) - 1) & ~0xff . Para el id example.main la raíz es 0x183a6125c38840424c4a85fa12bab2ab606c4b6d0e7cc73c0c06ba5300eab500. La anotación es documentación: el compilador no la hace cumplir, así que la disciplina sigue siendo responsabilidad del equipo.

Conexiones dentro de la serie

Referencias

Bibliografía académica

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

    Solidity Team, "Layout of State Variables in Storage and Transient Storage," Solidity Documentation.

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

    Reglas de slots y empaquetado, herencia C3, mappings y arrays dinámicos con keccak256, codificación de bytes/string y salida JSON storageLayout.

  2. [2]

    Foundry contributors, "forge inspect," Foundry Documentation.

    tool docsID: FOUNDRY-FORGE-INSPECTAbrir fuente (abre en otra pestaña)

    forge inspect <contrato> storageLayout imprime el layout calculado por el compilador.

  3. [3]

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

    COLD_SLOAD_COST = 2100, WARM_STORAGE_READ_COST = 100 y conjunto accessed_storage_keys por transacción.

  4. [4]

    S. Palladino, F. Giordano and H. Croubois, "ERC-1967: Proxy Storage Slots," Ethereum Improvement Proposals, no. 1967, 2019.

    Slot de implementación derivado de keccak256('eip1967.proxy.implementation') - 1 para evitar colisiones.

  5. [5]

    F. Giordano, H. Croubois, E. García and E. Lau, "ERC-7201: Namespaced Storage Layout," Ethereum Improvement Proposals, no. 7201, 2023.

    Anotación @custom:storage-location y fórmula erc7201(id) = keccak256(keccak256(id) - 1) & ~0xff.

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.