К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Раскладка хранилища и упаковка переменных в Solidity

Как контракт хранит данные в слотах по 32 байта, как упаковываются переменные, где лежат mapping и массивы и как устроена структура сделки Coin-Trust.

4 мин чтенияОбновлено:
В этой статье

Постоянная память контракта - storage - самая дорогая часть EVM. Запись в неё стоит тысячи единиц газа, и потому разработчики тщательно продумывают, как расположить данные. Эта статья объясняет правила раскладки, а затем разбирает реальный пример - структуру сделки в контракте эскроу. Основы EVM - в статье EVM изнутри.

Слоты по 32 байта

Хранилище контракта - огромный массив ячеек, каждая по 32 байта (256 бит), нумеруемых от 0. Это и есть слоты. По умолчанию слот пуст (нули), а читать его можно в любой момент. Контракт обращается к слоту по номеру (SLOAD/SSTORE).

Компилятор раскладывает переменные состояния по слотам в порядке объявления.

solidity
contract Example {
    uint256 a;   // слот 0
    uint256 b;   // слот 1
    address c;   // слот 2 (занимает 20 байт из 32)
}

Упаковка

Если подряд идущие переменные короче 32 байт и влезают вместе, компилятор упакует их в один слот, справа налево.

solidity
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, лежит в слоте:

text
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 слотов:

СлотПоляБайты
0buyer (address), amount (uint96)20 + 12 = 32
1seller (address), две ставки (uint16 × 2), deadline (uint64)20 + 2 + 2 + 8 = 32
2arbiter (address), disputeOpenedAt (uint64), status, outcome, durationPreset20 + 8 + 1 + 1 + 1 = 31
3token (address), offerExpiresAt (uint64), три флага bool20 + 8 + 3 = 31
4termsHash (bytes32)32

Всего в структуре 17 полей. Без упаковки это были бы 17 слотов; упаковка сокращает их до 5. Разница в первой записи - 12 слотов × ~20 000 = около 240 000 газа за каждую сделку. Это существенно уменьшает комиссию создания оферты (комиссии).

Поэтому в amount стоит uint96, а не uint256: максимум около 7,9 × 10^28 единиц, чего хватает даже для токенов с 18 знаками. Но тогда нужна явная проверка при записи, иначе значение тихо обрежется (почему это важно).

Почему уместить в слот - не всегда выгодно

Упаковка экономит место, но чтение и запись части слота требуют дополнительных операций: маскирования и сдвига. Если в одной транзакции вы меняете и читаете несколько полей одного слота, выгода огромна. Если переменные изменяются в разных транзакциях и независимо, а лишь «чужие» биты нужно сохранить, выигрыш менее очевиден. Практика: упаковывайте поля, которые меняются вместе, и измеряйте газ (Foundry).

Видимость хранилища

«Приватные» переменные (private) скрыты лишь от других контрактов. Данные хранилища публичны: любой может прочитать любой слот через узел (eth_getStorageAt). Нельзя хранить секреты в private-переменных (каталог уязвимостей).

Что почитать дальше

Проследим, как защищаются функции от несанкционированного вызова: шаблоны контроля доступа. Или как экономить газ: оптимизация газа.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.