Невзаимозаменяемые токены (NFT) стали популярным использованием технологии блокчейна, представляя уникальные цифровые активы. В основе реализации NFT лежит задача эффективного хранения и извлечения данных NFT в рамках ограничений модели хранения Ethereum. В этой статье рассмотрим технические тонкости хранения метаданных NFT (в частности, tokenURI и tokenId) в смарт-контрактах Ethereum с акцентом на хранилище смарт-контракта и важную роль хэш-функции keccak256.
В Ethereum модель хранения для смарт-контрактов является ключевым аспектом того, как данные храняться в блокчейне. Хранилище в Ethereum является очень дорогим ресурсом с точки зрения газа, и важно понимать, как распределение места работает для оптимизации смарт-контрактов и обеспечения эффективного использования ресурсов.
Отличие хранения NFT от обычных токенов
Основное различие в хранении между NFT и взаимозаменяемыми токенами вытекает из их фундаментальной природы и способа отслеживания права собственности.
Для взаимозаменяемых токенов, таких как те, которые соответствуют стандарту ERC-20, модель хранения относительно проста. Первичная структура данных обычно представляет собой mapping, который связывает адреса с балансами. Это выглядит примерно так:
mapping(address => uint256) private _balances;
Эта простая структура достаточна, поскольку токены являются взаимозаменяемыми. Нет необходимости отслеживать отдельные токены; системе нужно только знать, сколько токенов принадлежит каждому адресу. Переводы подразумевают простую корректировку этих балансов.
Напротив, хранение NFT, как видно из таких стандартов, как ERC-721, является более сложным из-за уникальной природы каждого токена. Типичный контракт NFT может включать несколько mapping:
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
mapping(uint256 => string) private _tokenURIs;
Здесь каждый токен имеет уникальный идентификатор tokenId, и контракт отслеживает владельца каждого конкретного токена. Отображение _balances ведет подсчет того, сколько токенов принадлежит каждому адресу, аналогично взаимозаменяемым токенам, но это дополнительная информация, а не основная запись о владельце. Помимо mapping, который по адресу вычисляет баланс токенов, для NFT есть еще mapping, который по номеру токена вычисляет адрес владельца. Этот mapping не хранят идентификаторы токенов напрямую, а используют их в качестве ключей для доступа к адресам владельцев.
Mapping _tokenURIs является еще одним ключевым отличием. Он связывает каждый токен с метаданными, обычно с URI, указывающим на данные вне блокчейна, описывающие характеристики токена (металанные). tokenURI обычно хранятся в виде строки (string). Этот mapping подчеркивает индивидуальность каждого NFT.
Переводы в контрактах NFT более сложны. Вместо простой корректировки балансов они требуют обновления права собственности для определенных токенов. С точки зрения эффективности использования газа операции с взаимозаменяемыми токенами, как правило, менее затратны. Обычно они требуют меньше обновлений хранилища на транзакцию. Операции NFT, особенно минтинг и переводы, часто требуют больше газа из-за необходимости обновления нескольких слотов хранения для каждого токена. Это различие в моделях хранения отражает различные варианты использования и свойства этих типов токенов.
Масштабируемость — еще одна точка расхождения. Контракты взаимозаменяемых токенов обычно хорошо масштабируются с большим количеством токенов и пользователей, поскольку они хранят только ненулевые балансы. Однако контракты NFT масштабируются линейно с количеством токенов, поскольку для каждого токена требуются собственные слоты в хранилище для записи права собственности и метаданных.
Модель хранения Ethereum
Смарт-контракты Ethereum имеют доступ к нескольким типам хранения данных:
Хранилище (Storage): хранилище относится к долгосрочной записи данных в рамках контракта и хранится в блокчейне. Эти данные могут быть доступны и изменены функциями контракта. Каждый смарт-контракт, развернутый в Ethereum, имеет свое собственное хранилище, и изменение его является достаточно дорогостоящим.
Память (Memory): разработчики используют память для переменных и параметров, которые используются внутри функции. Эти типы переменных существуют только в течение жизненного цикла выполняемой функции. Когда функция завершает выполнение, переменные и параметры, хранящиеся в области памяти, стираются.
Стек (Stack): это временное хранилище, используемое виртуальной машиной Ethereum (EVM) для хранения данных во время выполнения функций контракта. Стек используется для хранения значений и промежуточных результатов во время вычислений.
Подробно о хранилище
Каждый смарт-контракт получает свою собственную область хранения, которая является постоянной областью памяти для чтения и записи. Контракты могут читать и писать только из своего собственного хранилища. Хранилище контракта разделено на 2²⁵⁶ слотов по 32 байта каждый. Слоты являются смежными и индексируются, начиная с 0 и заканчивая 2²⁵⁶. Все слоты инициализируются значением 0.
Память хранилища EVM доступна только через эти 32-байтовые слоты.
2²⁵⁶ слотов!
Из-за огромного размера хранилища контракта его можно считать виртуальным. Это означает, что если вы прочитаете случайный слот, он, скорее всего, будет пустым/неинициализированным. Чтение такого слота вернет значение 0. EVM на самом деле не хранит все эти нули, но отслеживает, какие слоты используются, а какие нет. Когда вы обращаетесь к неиспользуемому слоту, EVM знает об этом и вернет 0.
Распределение хранилища
Переменные фиксированного размера: переменные, такие как uint256, address и другие фиксированного размера, напрямую хранятся в слотах. Каждой переменной обычно выделяется один слот, если только это не оптимизировано Solidity (например, упаковка нескольких меньших переменных в один слот).
Переменные динамического размера: переменные, такие как массивы и mapping, обрабатываются по-разному. Для массивов длина хранится в назначенном слоте, а элементы хранятся, начиная с хеша keccak256 номера слота, назначенного для массива. Для mapping значения хранятся в местах, определяемых хешированием ключа с номером слота.
Каким образом mapping хранятся в смарт-контрактах?
Магия эффективного хранения в mapping заключается в использовании хэш-функции keccak256. Когда вы объявляете mapping, Solidity не выделяет хранилище для всех возможных ключей. Вместо этого он использует умную систему с использованием keccak256 для вычисления места хранения для каждой пары ключ-значение.
Вот как это работает:
1. Каждой переменной (включая mapping) в контракте назначается слот хранения на основе порядка ее объявления.
2. Для mapping, когда вы обращаетесь к _tokenURIs[tokenId], Solidity вычисляет место хранения следующим образом:
location = keccak256(abi.encode(tokenId, uint256(storageSlot)))
Где storageSlot — это номер слота, назначенный mapping _tokenURIs при объявлении. Они конкатенируются в функции abi.encode. Использование функции abi.encode может быть полезно для предотвращения коллизий в хеш-функциях.
3. Полученный 256-битный хэш становится фактическим адресом хранения для URI этого tokenId. Хеш можно перевести в десятичный вид, который и будет номером слота.
Использование keccak256 в распределении адреса хранения достаточно разумно. Хэшируя комбинацию ключа и номера слота хранилища, Ethereum создает детерминированное, но, случайное распределение данных по доступному пространству хранилища. Такой подход предотвращает легкое предсказание мест хранения и позволяет эффективно использовать огромное адресное пространство 2²⁵⁶, доступное для хранилища каждого контракта.
В заключении
Эффективное хранение данных NFT в смарт-контрактах Ethereum в значительной степени зависит от понимания модели хранения Ethereum и разумного использования mapping и хэш-функции keccak256. Используя эти инструменты, мы можем создавать контракты NFT, которые являются как экономичными, так и масштабируемыми, способными обрабатывать большие коллекции с минимальными издержками на хранение в блокчейне.
Этот текст будет частью небольшой серии постов по мотивам моей статьи “Что такое NFT на самом деле?” и будет детальнее раскрывать основные аспекты работы с NFT.