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
Prerequisitos recomendados
Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.
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 [1]. Por eso estas reglas son estables y vale la pena aprenderlas una vez.
Qué cambios de variables tolera un contrato actualizable
Brújula de lectura
Actor, evidencia y estado
Actor
El compiladorAsigna slots y offsets según orden de declaración, tamaño y herencia.
Evidencia
storageLayout y cast storageEl mapa declarado por el compilador contra el valor crudo leído del nodo.
Estado
Slots de 32 bytesPersisten entre implementaciones; el código cambia, los datos se quedan.
Preguntas guía
- ¿Por qué
uint128, uint128, uint256ocupa dos slots yuint128, uint256, uint128ocupa 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 [1]. 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:
// 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.
// 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 [2]. Los comentarios del código coinciden con esa salida: owner, paused y lastUpdate comparten el slot 0 y ocupan 29 de sus 32 bytes.
Mapa de storage del contrato Ledger
Fuentes:
[1]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.
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 [1]. 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:
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 valorLos 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 [1]. 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 [3]. 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:
// 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 [4]. 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 [5]. 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
- Antes: Solidity sin magia: contratos como máquinas de estado define qué es una variable de estado y por qué vive en storage.
- Base técnica: Estado, storage y Merkle Patricia Trie explica cómo esos slots se comprometen en la raíz de estado.
- Después: ERC-20: simple por fuera, tramposo por dentro usa dos mappings cuyos slots ya sabes calcular.
- Más adelante: Proxies y upgrades: cambiar el motor con el auto andando convierte el invariante de layout en procedimiento.
Bibliografía académica
- [1]
Solidity Team, "Layout of State Variables in Storage and Transient Storage," Solidity Documentation.
Reglas de slots y empaquetado, herencia C3, mappings y arrays dinámicos con keccak256, codificación de bytes/string y salida JSON storageLayout.
- [2]
Foundry contributors, "forge inspect," Foundry Documentation.
forge inspect <contrato> storageLayout imprime el layout calculado por el compilador.
- [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]
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]
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.
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.
Series relacionadas
HotStuff: de cero a estado del arte
Ruta progresiva para entender consenso BFT moderno: quórums, sincronía parcial, locks, taxonomía one/two/three-chain, QC/highQC, multipipeline, view-change, responsiveness, AggQC, Fast-HotStuff, sync y frontera DAG-BFT.
Substrate: del runtime al consenso propio
Serie de 20 capítulos, de neófito a experto, sobre Substrate (Polkadot SDK): runtime en Wasm, FRAME, extrinsics, estado, pesos, actualizaciones sin fork, Aura, BABE, GRANDPA, sincronización, parachains, XCM, Sassafras, BEEFY y cómo conectar un consenso BFT propio a un nodo.
Ruta práctica: activos digitales en la operación
Nueve módulos, de menor a mayor dependencia, para decidir cómo usar custodia, wallets, llaves, firmas multifirma y MPC, AML/KYT y stablecoins con Fireblocks, BitGo, Kaleido, Circle y Tether.
Labs para convertir lectura en práctica
Prueba de trabajo y reajuste de dificultad
Laboratorio determinista para medir el costo de la prueba de trabajo y ver cómo el reajuste por altura de bloque estabiliza el tiempo entre bloques.
Consensus benchmark
Banco reproducible para comparar consenso BFT y documentar los trade-offs de HotStuff-2.
Ethereum local: Anvil, wallet y primera transacción
Laboratorio base para levantar una red local, crear cuentas, firmar transacciones y observar receipts sin gastar ETH real.
RPC y Cast: leer Ethereum con lupa
Ejercicios con JSON-RPC y Cast para consultar bloques, nonces, gas, receipts, logs y errores reproducibles.
Sigue leyendo
Ideas conectadas con este artículo
Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.
Berlin, London y EIP-1559: gas deja de ser solo subasta
Explicación de EIP-1559, base fee, priority fee y el cambio de UX/economía que preparó a Ethereum moderno.
Una transacción Ethereum por dentro
Campos de transacción, firma, gas, receipt, logs y diferencia entre enviar una intención y lograr un cambio de estado.
Estado, storage y Merkle Patricia Trie
Cómo pensar el estado global de Ethereum, storage de contratos, state root y pruebas de pertenencia.
EVM: stack, memory, storage y calldata
Modelo operativo de la Ethereum Virtual Machine: pila, memoria temporal, storage persistente, calldata y logs.