Storage layout в Solidity — это то, как компилятор раскладывает переменные состояния контракта по 256-битным слотам хранилища EVM. От этого зависят:
- предсказуемость работы контракта;
- совместимость при апгрейдах через прокси;
- корректность прямого чтения/записи storage через низкоуровневые вызовы.
Правильное понимание storage layout особенно важно, если вы используете прокси-паттерны, пишете код на Yul или анализируете контракты на низком уровне.
Storage в EVM: базовая модель
В EVM хранилище контракта — это ассоциативный массив:
- ключ — 256-битный индекс слота;
- значение — 256-битное слово (32 байта).
Solidity проецирует переменные состояния на эти слоты по довольно жёстким правилам. Важно:
- слоты нумеруются с 0;
- порядок переменных в коде Solidity влияет на их размещение;
- множество типов можно «упаковать» в один слот, если они занимают меньше 32 байт.
Базовые правила раскладки переменных
Упрощённо:
- каждая переменная состояния получает основной слот (base slot);
- для простых фиксированного размера типов (например, uint256) значение хранится прямо в этом слоте;
- для динамических типов (bytes, string, динамические массивы, mapping) в основном слоте хранится «указатель» (смещение), а фактические данные лежат в других слотах, вычисляемых через keccak256.
Пример:
contract Example {
uint256 public a; // slot 0
address public b; // slot 1 (но может быть упакован с другими мелкими типами)
bool public c; // может упаковаться в slot 1
}
Упаковка (packing) простых типов в один слот
Solidity пытается компактно упаковать переменные, которые занимают меньше 32 байт:
- в один слот можно поместить несколько переменных «меньше 256 бит»;
- упаковка идёт в порядке объявления;
- если следующая переменная не помещается в текущий слот, она переезжает в следующий.
Пример:
contract Packed {
uint128 public a; // 16 байт
uint128 public b; // 16 байт, итого a+b = 32 байта → оба в slot 0
uint64 public c; // 8 байт → новый слот, slot 1
bool public d; // 1 байт → тоже slot 1
}
Размещение:
- a и b — в slot 0 (каждый занимает половину слота);
- c и d — в slot 1 (сначала c, затем d и возможные «заполнители»).
Если изменить порядок переменных, раскладка изменится — это критично при апгрейдах.
Динамические массивы и строки
Для динамических массивов и типов bytes/string используется другая схема:
contract Dyn {
uint256[] public arr; // base slot = p (допустим, p = 0)
string public name; // base slot = p+1
}
Правила:
- в слоте p хранится длина массива (количество элементов);
- фактические элементы лежат начиная со слота keccak256(p):
- элемент arr[i] хранится в слоте keccak256(p) + i.
Аналогично для bytes и string, но детали оптимизации зависят от размера строки:
- короткие значения (bytes/string до 31 байта) могут храниться прямо в слоте вместе с длиной;
- более длинные — по схеме, похожей на динамический массив, с указателем и данными в слотах keccak256(p) и далее.
Mapping и формула keccak256(key, slot)
Mapping не хранит данные «по порядку» — только через хеш:
contract Maps {
mapping(address => uint256) public balance; // base slot = q
}
Схема:
- в слоте q сам mapping пустой — там ничего полезного;
- значение для balance[key] лежит в слоте:
`keccak256(encode(key) . encode(q))`
где `.` — конкатенация, `encode` — 32-байтовое представление.
Последствия:
- порядок вставки элементов не влияет на раскладку;
- зная key и q, можно вычислить точный слот и читать значение напрямую из storage (полезно для дебага/аналитики).
Struct и вложенные структуры
Struct’ы используют те же правила, что и «простые» переменные, но внутри своей области:
struct Position {
uint128 margin; // упакуется с
uint128 size; // → один слот
bool isActive; // новый слот
}
contract Positions {
Position public position; // base slot = r (например, r = 0)
}
Размещение:
- position.margin и position.size — в slot r;
- position.isActive — в slot r+1.
Если в контракте несколько переменных до position, их слоты учитываются; struct «занимает» последовательный диапазон. Вложенные динамические типы (например, uint256[] внутри struct) получат свои base slots и далее будут жить по правилам динамических массивов и mapping’ов.
Почему storage layout критичен для прокси и апгрейдов
В апгрейдируемых контрактах через прокси (DELEGATECALL и прокси):
- прокси хранит всё состояние (storage);
- реализация (implementation) содержит только логику;
- при апгрейде меняется код реализаций, но storage-прокси должен остаться совместимым.
Ошибки storage layout:
- изменение порядка переменных;
- вставка новых переменных «в середину» списка;
- изменение типов (например, uint256 → uint128)
приводят к тому, что новые версии контракта читают «не те» слоты и ломают состояние (балансы, роли, конфигурации).
Практика:
- новые переменные добавлять только в конец списка;
- резервировать «gap»-поля (массивы фиксированной длины) под будущие переменные;
- строго документировать layout и не менять его без крайней необходимости.
Практические рекомендации
- Планируйте переменные заранее: групируйте мелкие типы так, чтобы они хорошо упаковывались в слоты.
- Не меняйте порядок и типы существующих переменных в апгрейдируемых контрактах.
- Используйте gap’ы (uint256[50] private __gap;) в базовых контрактах для будущих расширений.
- При низкоуровневой работе с storage (Yul, off-chain-анализ) всегда сверяйтесь с фактическим storage layout сгенерированным компилятором.
