Раскладка хранилища и упаковка переменных в Solidity
Как контракт хранит данные в слотах по 32 байта, как упаковываются переменные, где лежат mapping и массивы и как устроена структура сделки Coin-Trust.
В этой статье
Постоянная память контракта - storage - самая дорогая часть EVM. Запись в неё стоит тысячи единиц газа, и потому разработчики тщательно продумывают, как расположить данные. Эта статья объясняет правила раскладки, а затем разбирает реальный пример - структуру сделки в контракте эскроу. Основы EVM - в статье EVM изнутри.
Слоты по 32 байта
Хранилище контракта - огромный массив ячеек, каждая по 32 байта (256 бит), нумеруемых от 0. Это и есть слоты. По умолчанию слот пуст (нули), а читать его можно в любой момент. Контракт обращается к слоту по номеру (SLOAD/SSTORE).
Компилятор раскладывает переменные состояния по слотам в порядке объявления.
contract Example {
uint256 a; // слот 0
uint256 b; // слот 1
address c; // слот 2 (занимает 20 байт из 32)
}Упаковка
Если подряд идущие переменные короче 32 байт и влезают вместе, компилятор упакует их в один слот, справа налево.
contract Packed {
uint128 a; // слот 0, байты 0-15
uint128 b; // слот 0, байты 16-31
uint256 c; // слот 1
}
contract NotPacked {
uint128 a; // слот 0
uint256 c; // слот 1
uint128 b; // слот 2 - из-за порядка нет упаковки
}Порядок объявления имеет значение. Packed использует 2 слота, NotPacked - 3.
Размеры типов: bool и uint8 - 1 байт, uint16 - 2, uint64 - 8, uint96 - 12, address - 20, uint256 и bytes32 - 32.
Структуры и массивы
- Структура всегда начинается с нового слота, внутри неё действуют те же правила упаковки; следующая переменная после структуры тоже начинается с нового слота.
- Статический массив фиксированной длины занимает последовательные слоты (для маленьких типов - упакованные).
Mapping и динамические массивы
Эти типы не занимают «место подряд», а используют хеш для вычисления адреса.
Mapping. Значение для ключа k в переменной, занимающей слот p, лежит в слоте:
keccak256(k ‖ p)Благодаря хеш-функции слоты разных ключей разбросаны, но вычисляются без перебора. У mapping нет «длины» и нельзя получить список ключей: чтобы перечислить записи, используют события или отдельный счётчик.
Динамический массив. Длина лежит в слоте p, а элементы - начиная со слота keccak256(p), подряд.
Сколько это стоит
| Операция | Газ (порядок) |
|---|---|
| Чтение слота (холодное) | ~2 100 |
| Чтение слота (тёплое) | ~100 |
| Запись в пустой слот (0 → ненулевое) | ~20 000 (+ 2 100 за «холодный» доступ) |
| Изменение непустого слота | ~2 900 (+ 2 100 за «холодный» доступ) |
| Обнуление слота | возврат части газа |
Один новый слот при первой записи - порядка 22 000 газа. Если упаковка позволяет не занимать лишние слоты, экономия существенна (техники оптимизации).
Пример: структура сделки в Coin-Trust
В контракте эскроу каждая сделка - структура Deal, расположенная в mapping(uint256 => Deal). Поля подобраны так, чтобы уложиться в 5 слотов:
| Слот | Поля | Байты |
|---|---|---|
| 0 | buyer (address), amount (uint96) | 20 + 12 = 32 |
| 1 | seller (address), две ставки (uint16 × 2), deadline (uint64) | 20 + 2 + 2 + 8 = 32 |
| 2 | arbiter (address), disputeOpenedAt (uint64), status, outcome, durationPreset | 20 + 8 + 1 + 1 + 1 = 31 |
| 3 | token (address), offerExpiresAt (uint64), три флага bool | 20 + 8 + 3 = 31 |
| 4 | termsHash (bytes32) | 32 |
Всего в структуре 17 полей. Без упаковки это были бы 17 слотов; упаковка сокращает их до 5. Разница в первой записи - 12 слотов × ~20 000 = около 240 000 газа за каждую сделку. Это существенно уменьшает комиссию создания оферты (комиссии).
Поэтому в amount стоит uint96, а не uint256: максимум около 7,9 × 10^28 единиц, чего хватает даже для токенов с 18 знаками. Но тогда нужна явная проверка при записи, иначе значение тихо обрежется (почему это важно).
Почему уместить в слот - не всегда выгодно
Упаковка экономит место, но чтение и запись части слота требуют дополнительных операций: маскирования и сдвига. Если в одной транзакции вы меняете и читаете несколько полей одного слота, выгода огромна. Если переменные изменяются в разных транзакциях и независимо, а лишь «чужие» биты нужно сохранить, выигрыш менее очевиден. Практика: упаковывайте поля, которые меняются вместе, и измеряйте газ (Foundry).
Видимость хранилища
«Приватные» переменные (private) скрыты лишь от других контрактов. Данные хранилища публичны: любой может прочитать любой слот через узел (eth_getStorageAt). Нельзя хранить секреты в private-переменных (каталог уязвимостей).
Что почитать дальше
Проследим, как защищаются функции от несанкционированного вызова: шаблоны контроля доступа. Или как экономить газ: оптимизация газа.
Читайте также
- 3 · Под капотом
Оптимизация газа в Solidity: 12 приёмов и когда они уместны
Практические приёмы экономии газа в смарт-контрактах: упаковка хранилища, calldata, кэширование, immutable, unchecked и правило - не жертвовать безопасностью.
3 мин чтения - 3 · Под капотом
Аккаунты и состояние Ethereum: EOA, контракты и мировое состояние
Как устроены аккаунты Ethereum: EOA и контрактные, четыре поля аккаунта, мировое состояние, адреса контрактов и единицы wei, gwei, ether.
3 мин чтения
Упоминается в статьях
- Словарь терминов: блокчейн, кошельки, смарт-контракты и эскроу от A до Я
- Аккаунты и состояние Ethereum: EOA, контракты и мировое состояние
- Обзор языка Solidity: типы, функции, ошибки и события на примерах
- ERC-20 с нуля: пишем токен на Solidity и разбираем каждую строку
- Пишем эскроу на Solidity: конечный автомат и разбор реального контракта
- Целочисленная математика и округление в смарт-контрактах
- Каталог уязвимостей смарт-контрактов: 12 классических ошибок и защита от них
- Прокси и обновляемые контракты: delegatecall, UUPS и цена гибкости
- Деревья Меркла и доказательства: как проверять миллионы данных одним хешем
- Оптимизация газа в Solidity: 12 приёмов и когда они уместны
Готовы попробовать?
Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.

