Un contrato de implementación que solo cambió su propio campo owner puede escribir basura en la dirección de administrador del contrato proxy. La raíz de este tipo de accidentes está normalmente en cómo Solidity guarda las variables de estado. Dentro de un contrato no existe un índice en tiempo de ejecución que vaya «del nombre de la variable a la posición de almacenamiento»; el compilador coloca las variables una tras otra en slots de 32 bytes (32 bytes) siguiendo el orden de declaración. delegatecall hace que el código del contrato de implementación se ejecute sobre el almacenamiento del proxy, y en cuanto las dos partes no entienden igual el reparto de slots, la escritura aterriza en los datos de la otra.
El slot es a la vez la unidad de direccionamiento del almacenamiento y la granularidad mínima con la que la capa de ejecución registra un acceso a estado. En qué slot cae cada variable, si comparten slot tras el empaquetado y cómo derivan su posición los elementos de arrays y mappings a partir de la clave determinan dos cuestiones muy prácticas: qué campo del proxy puede estropear una llamada y cuán grande es en realidad el conjunto de lectura/escritura de estado de una transacción.
Las variables ocupan linealmente slots de 32 bytes en el orden de declaración
Según la documentación oficial de Solidity, «Layout of State Variables in Storage and Transient Storage», salvo los arrays dinámicos y los mappings, las variables de estado se guardan de forma contigua desde la primera declaración, y la primera variable cae en el slot 0. Cada variable ocupa un número de bytes determinado por su tipo, y las variables consecutivas que en total no llegan a 32 bytes se meten en el mismo slot. Las reglas son cinco: el primer elemento del slot se guarda en la parte baja (lower-order aligned); un tipo valor solo ocupa los bytes que realmente necesita; si no cabe en el espacio restante del slot actual, pasa al siguiente; los datos de un struct o de un array siempre empiezan en un slot nuevo; y las variables posteriores a un struct o a un array también empiezan en un slot nuevo.
Dos excepciones fáciles de pasar por alto afectan al cálculo del número de slot. Una variable constant no ocupa un slot de almacenamiento: su valor se inserta en línea en el punto de uso; una variable immutable se codifica en el bytecode de despliegue y en tiempo de ejecución no lee del almacenamiento. En el contrato de ejemplo C de la documentación oficial, ni la constante c ni la inmutable d participan en el diseño; si se contaran en el ordinal de declaración, el número de slot de todas las variables siguientes se desplazaría. El almacenamiento transitorio (transient storage) es un diseño aparte e independiente: las reglas son las mismas, pero el espacio está completamente separado, de modo que en un mismo contrato las variables de estado normales y las transitorias pueden intercalarse sin afectarse entre sí.
El empaquetado ahorra slots, no necesariamente gas
Los tipos pequeños comparten un mismo slot; a eso se le llama empaquetado (packing). Las dos declaraciones siguientes solo se diferencian en el orden de las variables, y el número de slots que ocupan difiere en uno:
// ocupa 3 slots
uint128 a; // slot 0, offset 0
uint256 b; // los 32 bytes no caben en el espacio restante del slot 0, cae en el slot 1
uint128 c; // el slot 1 está lleno, cae en el slot 2
// ocupa 2 slots
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1
La documentación oficial señala de forma explícita que usar elementos de menos de 32 bytes puede elevar el consumo de gas: la EVM opera en unidades de 32 bytes y manejar tipos pequeños exige operaciones adicionales de truncado o desplazamiento. El beneficio real del empaquetado es que «acceder una vez a un slot permite obtener varios valores», y las lecturas y escrituras se tarifan por slot. Lo contrario también se cumple: si un fragmento de lógica solo escribe una de esas variables, la EVM tiene que leer primero el slot completo, modificar los bytes correspondientes y volver a escribirlo entero, porque de lo contrario sobrescribiría las demás variables del mismo slot. Meter en un mismo slot variables a las que no se accede a la vez convierte una escritura en una lectura más una escritura.
Aquí ya queda sembrado un punto de observación relacionado con la concurrencia: visto a escala de slot, el empaquetado ata varias variables lógicamente independientes en un mismo objeto escribible.
Los arrays de tamaño fijo van en línea; los arrays dinámicos y los mappings derivan su posición de un hash
Los elementos de un array de tamaño fijo (como uint256[3]) se guardan en línea y en orden dentro de los slots, igual que variables declaradas una por una; cuando el total de bytes supera 32, ocupa varios slots de forma natural. Los structs funcionan igual, solo que tanto el struct como las variables posteriores a él deben empezar en un slot nuevo.
Los arrays dinámicos y los mappings no pueden disponerse en línea, porque su tamaño es impredecible. Lo que hacen es ocupar un solo slot p en el diseño; ese slot no guarda datos, y la posición de los datos se calcula con keccak256.
En un array dinámico, el slot p guarda la longitud del array y los elementos empiezan en keccak256(p), dispuestos de forma contigua según las reglas de los arrays de tamaño fijo; si los elementos no superan los 16 bytes, aún pueden compartir slot. Los arrays dinámicos anidados aplican la misma regla de forma recursiva: para una x de tipo uint24[][] declarada en el slot p, el slot del elemento x[i][j] es keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)).
El slot p de un mapping permanece vacío, pero debe reservarse; es justamente ese slot reservado el que garantiza que los datos de dos mappings contiguos no se solapen. El valor correspondiente a la clave k está en keccak256(h(k) . p), donde . indica concatenación y h aplica a la clave un tratamiento dependiente del tipo: los tipos valor se rellenan hasta 32 bytes de la misma manera que en memoria; las claves de tipo string y bytes no se rellenan. El ejemplo anidado que da la documentación permite comprobarlo directamente: para uint x; mapping(uint => mapping(uint => S)) data;, el slot de data[4][9].c es keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1; el +1 final proviene del desplazamiento del miembro c dentro de S, mientras que los dos uint16 a y b ya se han empaquetado en un mismo slot.
La codificación de bytes y string merece una explicación aparte, porque no son un simple envoltorio de bytes1[]. Cuando los datos no superan los 31 bytes, los datos y la longitud se guardan en el mismo slot: los datos se alinean a la izquierda en los bytes altos y el byte más bajo guarda length * 2. Cuando los datos alcanzan o superan los 32 bytes, el slot p guarda length * 2 + 1 y los datos reales van en la zona que empieza en keccak256(p). Los dos casos se distinguen por el bit más bajo: en los datos cortos ese bit es 0 y en los largos es 1.
La ubicación de los tipos compuestos sigue la misma recursión. Deduciéndolo con las reglas anteriores, cuando uint8[4] es el elemento de un array dinámico, los cuatro valores de 1 byte caben exactamente en un slot; en uint[3][], cada elemento es un array de tamaño fijo que ocupa 3 slots, de modo que los elementos se disponen con un intervalo de 3 slots y el inicio de x[i] es keccak256(p) + 3 * i. Estos dos ejemplos son deducciones de casos particulares de la regla; la documentación oficial solo da el ejemplo anidado de uint24[][] y no incluye estos dos literalmente.
Todo esto tiene una consecuencia directa: la posición de slot de los elementos de un array y de los valores de un mapping depende de la clave o del índice en tiempo de ejecución, no puede enumerarse en tiempo de compilación, y un análisis estático que solo lea el código fuente tampoco puede deducir el conjunto completo.
El diseño es una salida del compilador y puede exportarse y compararse
Los números de slot no tienen por qué deducirse a ojo. La interfaz JSON estándar de Solidity permite exportar el diseño de almacenamiento del contrato; la salida contiene dos claves, storage y types: cada elemento del array storage da astId, contract, label, offset, slot y type, mientras que types describe la forma de codificación de cada tipo, donde el valor inplace indica una disposición en línea, mapping y dynamic_array indican una derivación basada en keccak256, y bytes indica que según la longitud se elige entre un solo slot y la zona del hash. El valor de slot puede ser muy grande y en el JSON se representa como cadena. La documentación advierte además de que este formato de salida todavía se considera experimental y puede cambiar en versiones no disruptivas de Solidity, por lo que sirve como herramienta de comprobación puntual y no conviene usarlo como interfaz de dependencia a largo plazo.
El orden de herencia y las fronteras de slot determinan la viabilidad de una actualización
En un contrato con herencia, el orden de las variables de estado lo determina el orden de linealización C3 (C3-linearized) del contrato, empezando por el contrato más basal de la cadena de herencia. Cuando el empaquetado lo permite, variables de contratos distintos siguen compartiendo un mismo slot, y las variables de la clase base y de la derivada pueden convivir en un slot.
Esta regla explica dos clases de accidentes de actualización. Una es añadir variables en la clase base: basta con que la subclase haya declarado ya sus propias variables para que las nuevas variables de la base desplacen el slot de las variables originales de la subclase y los datos viejos se lean como si fueran variables nuevas. La otra es insertar variables antes de las existentes o cambiar el tipo de una variable, con el mismo efecto. La documentación de actualizaciones de OpenZeppelin formula esta restricción sin rodeos: las variables nuevas solo pueden añadirse al final; si se eliminan variables del final, el almacenamiento no se vacía y las variables que se añadan después en esa misma posición leerán valores residuales históricos.
Para los escenarios en los que se quiere controlar el diseño de forma activa, Solidity permite declarar un punto de partida de diseño personalizado en el contrato. El ejemplo de la documentación oficial se escribe pragma solidity ^0.8.29; y contract C is A, B layout at 42, y el número de slot de todas las variables estáticas del árbol de herencia se desplaza en bloque; la documentación no indica desde qué versión del compilador existe esta capacidad, y aquí tampoco se afirma ninguna versión. Esta declaración solo afecta a ese árbol de herencia: cuando A y B se despliegan por separado, su diseño sigue empezando en el slot 0. Conviene tener en cuenta que esto solo desplaza el punto de partida, y que la posición de los datos de arrays dinámicos y mappings cambia también al cambiar el slot base.
Actualizaciones con proxy: por qué la colisión de almacenamiento es inevitable y cómo la evitan los slots reservados
El mecanismo de un proxy actualizable es el siguiente: el proxy guarda el almacenamiento y el saldo, y ejecuta el código del contrato de implementación mediante delegatecall. Como delegatecall conserva el contexto de almacenamiento del llamador, el slot 0 y el slot 1 que ve el contrato de implementación son el slot 0 y el slot 1 del proxy. Si el propio proxy declara también un address public admin;, ocupará el slot 0, y la primera variable de estado del contrato de implementación ocupa igualmente el slot 0, de modo que cualquier escritura de una parte sobrescribe a la otra. Eso es una colisión de almacenamiento (storage collision). El problema no está en que una de las partes escriba mal el código: almacenamiento compartido más dos compilaciones independientes, esa combinación por sí sola ya produce el conflicto.
La solución de EIP-1967 es no usar los slots que asigna el compilador, sino fijar un conjunto de slots convenidos:
| Uso | Posición del slot | Forma de derivación |
|---|---|---|
| Dirección del contrato de implementación | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) |
| Dirección del contrato beacon | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) |
| Dirección de administrador | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) |
La elección de los parámetros de estos números tiene una razón clara. La posición del slot procede del keccak256 de una cadena que no empieza por un índice de almacenamiento, así que no puede coincidir con los slots que el compilador asigna de forma incremental desde 0; además, el valor en sí es muy grande, lo que lo aleja todavía más del rango habitual de las variables. Restar 1 al final hace que la preimagen del hash sea desconocida y evita que alguien construya una clave de mapping que, mediante keccak256(h(k) . p), escriba exactamente en ese slot. EIP-1967 recomienda además que cualquier función que reescriba estos slots emita el evento correspondiente, porque para la monitorización en cadena es muy difícil seguir directamente los cambios de un slot arbitrario.
Aparte de EIP-1967 hay otras dos prácticas habituales. La más antigua es el hueco de almacenamiento (storage gap): declarar al final de la clase base un array de tamaño fijo, por ejemplo uint256[49] __gap;, para reservar slots a las variables futuras, y reducir __gap en la misma medida cuando se añaden variables a la base. La documentación de OpenZeppelin señala que esto no aumenta el consumo de gas, pero su modo de fallo también es muy concreto: olvidar reducir el hueco, o añadir variables a la clase base cuando la subclase ya tiene las suyas, reintroduce el conflicto. La práctica más reciente es el diseño de almacenamiento por espacios de nombres de ERC-7201, que mete un grupo de variables en un struct y lo marca con @custom:storage-location erc7201:<NAMESPACE_ID>; la posición se calcula con keccak256(keccak256(id) - 1) & ~0xff, y el & ~0xff final alinea el espacio de nombres a 256 slots. La razón que da la documentación es que esto puede servir como optimización futura: tras la migración al árbol de estado Verkle podrían aparecer reglas de gas en las que 256 slots se calientan juntos. Las versiones actualizables de OpenZeppelin Contracts desde la 5.0 adoptaron esta convención.
Falta añadir una premisa: la documentación de Solidity considera el diseño de almacenamiento parte de la interfaz externa del lenguaje, porque los punteros de almacenamiento pueden pasarse a funciones de biblioteca y cualquier cambio en las reglas de diseño cuenta como cambio disruptivo. La viabilidad de las soluciones de proxy que fijan direcciones por slot se apoya en la promesa de que las reglas de diseño se mantendrán estables a largo plazo.
La unidad mínima del conjunto de lectura/escritura es el slot
Si se juntan las reglas anteriores, a efectos de la tarificación de accesos la unidad mínima de acceso a estado que la capa de ejecución puede observar es la dirección más el slot. EIP-2929 es la prueba más directa: mantiene para cada transacción dos conjuntos, accessed_addresses y accessed_storage_keys, el segundo con elementos de tipo Set[Tuple[Address, Bytes32]], y tarifa con esa granularidad. El primer acceso a un slot de almacenamiento cobra COLD_SLOAD_COST, es decir, 2100 gas; un slot ya accedido cobra WARM_STORAGE_READ_COST, es decir, 100 gas; y el primer acceso a una dirección de cuenta cobra COLD_ACCOUNT_ACCESS_COST, es decir, 2600 gas. Que la tarificación en frío y en caliente tenga como unidad el slot demuestra que la propia capa de ejecución toma el slot como objeto de acceso.
De aquí se pueden deducir tres conclusiones de ingeniería relacionadas con la concurrencia.
EIP-2929 define la unidad mínima de la tarificación de accesos; la especificación no define con qué granularidad debe decidirse un conflicto durante la ejecución paralela. Lo siguiente es una deducción a partir de la granularidad de la tarificación: el empaquetado ata variables pequeñas a un mismo slot, lo que significa que, al registrar el conjunto de lectura/escritura con el slot como granularidad, dos transacciones que escriben variables distintas dentro de un mismo slot siguen en conflicto; para eliminar ese tipo de falso conflicto, la detección tendría que afinar hasta el desplazamiento dentro del slot y las diferencias de bytes, a costa de un mayor coste en cada comparación.
La posición de slot de los arrays dinámicos y los mappings depende de la clave, así que el conjunto de lectura/escritura no puede conocerse en tiempo de compilación: solo puede registrarse durante la ejecución y verificarse después. Aquí está también la razón de que el control de concurrencia optimista (optimistic concurrency control, OCC) necesite conjuntos de lectura y de escritura y no pueda copiar el enfoque de las bases de datos, en el que el desarrollador los declara de antemano (véanse en este sitio «Introducción al control de concurrencia optimista (OCC): de las bases de datos a la ejecución en cadena» y «Hotspots de conflicto y cargas de trabajo: cuándo ayuda de verdad una EVM paralela»).
El conjunto de lectura/escritura a nivel de slot es una cota superior, no un conjunto exacto. Una transacción puede modificar solo un byte de un slot y quedar registrada como si hubiera escrito el slot entero; también puede acceder a un slot y no llegar a tener efecto porque una ruta posterior se revierte. Tomar el conjunto de lectura/escritura directamente como criterio de conflicto da una tasa de conflicto demasiado alta, que hay que absorber con reversiones y reejecuciones.
También conviene dejar claros los límites. Si el contrato de implementación adopta por completo el diseño por espacios de nombres de ERC-7201, en principio deja de chocar con los slots del proxy, a cambio de que el número de cualquier slot ya no pueda leerse a partir del orden del código fuente y de que las herramientas que solo entienden ese orden (algunos analizadores estáticos, exploradores de bloques) dejen de funcionar. Además, sstore en ensamblador en línea puede escribir en cualquier slot fuera del diseño del compilador, y ninguna herramienta de diseño que solo analice el AST puede cubrir ese tipo de escritura; es también una fuente de incertidumbre recurrente al analizar el comportamiento de almacenamiento de un contrato.
Fuentes
- Documentación de Solidity, Layout of State Variables in Storage and Transient Storage: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- EIP-1967, Proxy Storage Slots: https://eips.ethereum.org/EIPS/eip-1967
- ERC-7201, Namespaced Storage Layout: https://eips.ethereum.org/EIPS/eip-7201
- EIP-2929, Gas cost increases for state access opcodes: https://eips.ethereum.org/EIPS/eip-2929
- Documentación de OpenZeppelin, Writing Upgradeable Contracts: https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable