Это вторая часть серии гайдов о создании чат-приложения с помощью Rust на блокчейне Near. Первый гайд из серии вы можете найти здесь.
В этом гайде мы сосредоточимся на самом смарт-контракте. Мы увидим библиотеку Near-SDK, которая заставляет наш код работать на Near. Также увидим шаблоны доступа и принципы разработки смарт-контрактов, просмотрев код этого смарт-контракта. Вы можете найти полный репозиторий со всем кодом, который мы сегодня обсудим, на моем GitHub.
Смарт-контракт Near Rust SDK
По своей сути среда выполнения смарт-контрактов Near использует WebAssembly (Wasm). Wasm — это хорошо зарекомендовавший себя формат байт-кода, который также используется вне блокчейна, например, в веб-приложениях. Это хорошо для Near, поскольку его среда выполнения может извлечь выгоду из работы, проделанной в более широком сообществе Wasm.
Компилятор Rust хорошо справляется с созданием вывода Wasm, но для того, чтобы байт-код Wasm правильно работал с его «хостом» (средой выполнения Near в нашем случае или движком JavaScript веб-браузера в веб-приложения). Этот скаффолдинг можно сгенерировать автоматически с помощью удобных библиотек Rust: wasm-bindgen в случае интеграции с браузером и Near-sdk в случае с Near. Смарт-контракт, с которым мы сегодня работаем, написан с использованием Near-SDK.
Обе библиотеки используют процедурные макросы Rust (макросы proc). Это своего рода метапрограммирование, когда библиотека определяет небольшие аннотации, которые мы можем использовать для запуска автоматической генерации кода Rust. Макросы proc в Rust используются для уменьшения объема шаблонного кода, который разработчик должен написать, чтобы его бизнес-логика заработала. Например, макрос proc является ядром языка Rust. Он может автоматически определять общие функции для новых типов данных, которые вы создаете. Вы можете увидеть, как это используется в следующем простом фрагменте кода из смарт-контракта чата:
Вы можете увидеть множество признаков, перечисленных в аннотации производных. Вызов некоторых конкретных: Debug означает, что тип MessageStatus может быть преобразован в строку, чтобы помочь в отладке кода; Clone означает, что можно создать идентичный экземпляр MessageStatus из текущего экземпляра, а Copy означает, что операция Clone дешева; PartialEq и Eq означают, что вы можете сравнить два экземпляра MessageStatus, чтобы убедиться, что они одинаковы. Трейты Serialize и Deserialize берутся из библиотеки serde, повсеместно распространенной в экосистеме Rust для кодирования/декодирования данных из таких форматов, как JSON или CBOR. К чертам Borsh мы вернемся позже.
До сих пор это был стандартный Rust, который вы найдете в любом проекте. Макрос Near-specific proc называется Near_bindgen, который используется в следующем фрагменте кода:
Макрос proc near_bindgen автоматически генерирует дополнительный код, который нам нужен, чтобы при компиляции в Wasm мы получали вывод, который среда выполнения Near понимает как использовать. Он используется во многих местах, где необходим такой связующий код. Здесь он помечает структуру MessengerContract как имеющую состояние, необходимое для выполнения методов контракта. Экземпляр структуры MessengerContract будет создаваться каждый раз, когда мы вызываем метод нашего смарт-контракта. Позже мы обсудим, для чего используются некоторые из этих полей.
Макрос Near_bindgen также используется в блоке impl для структуры MessengerContract:
Здесь это означает, что функции, определенные в этом блоке, представляют собой методы, которые мы хотим предоставить в нашем смарт-контракте. Это позволяет пользователям блокчейна Near отправлять транзакции, вызывая эти функции по имени. Например, в этом блоке определяется способ отправки сообщения. Ниже мы рассмотрим некоторые другие методы из этого блока более подробно.
Таким образом, библиотека near-sdk предоставляет макрос proc с именем near_bindgen для автоматической генерации связующего кода, благодаря которому выходные данные Wasm работают со средой выполнения Near. Этот макрос можно использовать в структуре для определения состояния вашего контракта и в блоке реализации этой структуры для определения общедоступных методов в вашем контракте. Near-sdk также предоставляет другие полезные функции и структуры, которые мы увидим в следующих разделах.
Состояние смарт-контракта
По сути, все нетривиальные смарт-контракты требуют некоторого состояния для правильной работы. Например, контракт токенов должен поддерживать балансы различных держателей токенов. Наш чат-контракт ничем не отличается. В предыдущем разделе мы видели, что структура MessengerContract содержит множество полей. В этом разделе мы обсудим некоторые общие особенности состояния в среде выполнения Near, а также некоторые особенности его использования в примере смарт-контракта.
Самое важное, что нужно знать о состоянии смарт-контракта в Near, это то, что это простое хранилище ключей и значений. Вы можете увидеть это в низкоуровневых функциях storage_read и storage_write, предоставляемых near-sdk. Однако вы можете создать несколько более сложных структур данных поверх этой простой основы, и Near-sdk предоставляет некоторые из них в своем модуле collections. По этой причине наш пример контракта не использует хранилище ключей-значений напрямую; вместо этого он использует коллекции более высокого уровня, предлагаемые near-sdk.
Например, смарт-контракт отслеживает состояние учетных записей, о которых он знает (какие из них являются контрактами, на которые мы отправили запрос на контакт и т. д.). Поле учетных записей в MessengerContract представляет собой структуру LookupMap из Near-sdk. Это довольно близко к прямому использованию хранилища ключ-значение, поскольку также является просто способом поиска значения по ключу, но LookupMap делает две важные вещи помимо необработанного интерфейса хранилища ключ-значение. Во-первых, у него есть префикс, который он включает во все ключи хранилища, связанные с этой картой. Использование префикса предотвращает смешение ключей из этой карты с ключами из другой (например, карты last_received_message, которая также имеет ключ на AccountId). Во-вторых, LookupMap позволяет нам работать с типами Rust более высокого уровня, тогда как необработанный интерфейс хранилища работает только с байтами. Это достигается за счет использования сериализации Borsh для преобразования типов в/из двоичных строк. Borsh — это формат сериализации, разработанный Near для использования в блокчейн-приложениях. Это использование Borsh является причиной того, что BorshDeserialize и BorshSerialize являются производными от многих типов по всему коду.
Более интересным примером используемой здесь коллекции является UnorderedSet в поле unread_messages. Это используется для контракта, чтобы отслеживать, какие сообщения все еще не прочитаны. UnorderedSet по-прежнему основан на базовом хранилище ключей и значений, но эффективно использует только ключи, поскольку нам важно только, находится ли элемент в наборе или нет. Структура также хранит метаданные о том, какие ключи она использует, чтобы мы могли перебирать все ключи в наборе.
Проверка среды и вызов других контрактов.
В этом разделе мы обсудим общие особенности среды выполнения Near и вызовы между контрактами. Это делается в контексте того, как пользователи добавляют друг друга в качестве контактов в нашем чат-приложении. Давайте взглянем на определение функции add_contact (это определение находится в импл-блоке MessengerContact с упомянутой выше аннотацией near_bindgen, потому что это основная точка входа для нашего контракта).
В этих нескольких строках кода есть что рассмотреть. В качестве дополнительной основы для нашего обсуждения вспомните три принципа разработки смарт-контрактов, изложенные в предыдущем посте:
- враждебное мышление
- экономика
- обеспечить инварианты, прежде чем делать кросс-контрактные вызовы.
Вернитесь и просмотрите первый гайд, если вам нужно освежить в памяти эти принципы. Каждый из этих принципов появляется в этой функции.
Враждебное мышление
Все методы смарт-контракта являются общедоступными, и мы должны обеспечить контроль доступа, когда метод выполняет конфиденциальное действие, иначе кто-то будет злоупотреблять функциональностью. В этом случае мы не хотим, чтобы кто-либо мог добавлять контакты от имени владельца; только владелец должен иметь возможность решить, с кем связаться (если кто-то еще хочет установить контакты в сети чата, он может развернуть этот контракт на свою учетную запись!). Поэтому у нас есть вызов require_owner_only() прямо в верхней части тела функции. Реализация этой функции проста:
Он использует функцию Pregence_account_id из модуля env для Near-SDK. Модули env содержат множество полезных функций для запроса аспектов среды выполнения Near, в которой выполняется наш контракт. Например, здесь мы проверяем, какая учетная запись выполнила вызов нашего контракта. Модуль env содержит другие полезные функции, например, для проверки идентификатора учетной записи самого нашего контракта и того, сколько токенов Near было привязано к этому вызову. Я рекомендую прочитать документацию модуля, чтобы увидеть все доступные функции.
Из соображений эффективности функция require_owner_only также возвращает учетную запись-предшественника (чтобы избежать множественных вызовов env::predecessor_account_id() в случае, если функции только для владельца также требуется учетная запись-предшественник по другой причине).
Экономика
Самая первая строка фрагмента кода add_contact выше включает атрибут payable. Использование этой аннотации разрешено функцией, определяемой как часть блока реализации near_bindgen. Это означает, что этот метод будет принимать токены Near от пользователей, которые его вызывают. Эти токены необходимы, потому что мы приняли решение, что пользователи платят за такие действия, как создание состояния в цепочке. Поскольку добавление другой учетной записи в качестве контакта создает состояние в их контракте, а также в нашем (нам нужно сообщить им, что мы хотим подключиться), мы должны убедиться, что пользователь, инициирующий это подключение, платит за это хранилище. Депозит, связанный с этой оплачиваемой функцией, используется для покрытия расходов на хранение.
Вы можете увидеть несколько строк ниже, где мы проверяем наличие депозита. Это использует функцию attach_deposit из модуля env. Тот факт, что мы делаем эту проверку на ранней стадии, прекрасно переходит в третий принцип.
Обеспечьте инварианты, прежде чем делать кросс-контрактные вызовы
Важно обратить внимание на сигнатуру типа функции add_contact. Во-первых, аргументы функции (&mut self, account: AccountId) означают, что это изменяемый вызов (он изменит состояние контракта), и он принимает один аргумент с именем «account», который должен быть идентификатором Near Account ID. Когда close_bindgen совершит свое волшебство, это будет означать, что пользователи блокчейна Near могут вызывать эту функцию, совершая транзакцию, которая принимает аргумент в формате JSON, например { “account”: “my.account.near” }. Во-вторых, тип возвращаемого значения — Promise, что означает, что мы делаем кросс-контрактный вызов в конце этой функции. Кросс-контрактные вызовы на Near являются асинхронными и неатомарными, поэтому мы должны убедиться, что все в хорошем состоянии, прежде чем совершать вызов. Вот почему мы включаем в тело функции чек только для владельца и депозитный чек. Асинхронный характер вызовов между контрактами также означает, что эта функция не возвращает немедленного значения. Асинхронный вызов будет выполнен, а результат придет позже, после того, как этот вызов произойдет.
Подробную информацию о кросс-контрактном вызове можно увидеть в нижней части функции. Он использует высокоуровневый API от near-sdk (хотя низкоуровневый API также доступен в модуле env), где функция ext автоматически создается Near_bindgen и возвращает структуру данных для построения кросс-контрактного вызова. Вы можете видеть, что сначала мы используем ext(account) для вызова учетной записи, которую хотим добавить в качестве контакта. Вызов включает наш депозит через with_attached_deposit и вызывает функцию ext_add_contact (которая в данном случае определена в том же блоке impl, но в целом может быть определена где угодно). Наконец, мы вызываем then, что означает включение обратного вызова. Обратный вызов сам по себе является еще одним обещанием, поэтому мы снова используем ту же функцию ext, но на этот раз вызывая идентификатор нашей учетной записи. Это сделано для того, чтобы наш контракт мог знать, какой был ответ от контракта, который мы пытаемся добавить в качестве контакта. Я не буду вдаваться в детали реализации ext_add_contact или add_contact_callback здесь (они просто манипулируют хранилищем в зависимости от текущего состояния учетной записи), но я рекомендую вам прочитать их в исходном коде на GitHub, если вам интересно.
Итог
В этом гайде мы с головой погрузились в код! Мы увидели, как Near_bindgen используется для автоматической генерации кода, необходимого для запуска нашего контракта в среде выполнения Near, а также другие функции Near-SDK для взаимодействия с хранилищем, средой выполнения и другими контрактами. В следующем посте мы продолжим глубокое погружение в код, но переключимся на офчейн-компонент этого приложения. Смарт-контракт сам по себе не представляет собой децентрализованное приложение, следите за обновлениями, чтобы узнать, почему!
Если вы хотите получить практический опыт работы с этим кодом, попробуйте некоторые из упражнений! В нескольких местах кода смарт-контракта я включил комментарий с пометкой EXERCISE. Например, в определении типов я указываю на тот факт, что доступен статус пользователя «Заблокировано», но нет способа заблокировать кого-то, кто в настоящее время реализован. Добавление этой функции для блокировки другого пользователя — одно из рекомендуемых упражнений, с которого стоит начать. Все упражнения представляют собой предложения по расширению функциональности контракта, что дает вам возможность попробовать написать код смарт-контракта самостоятельно. Возможно, в следующих постах этой серии я расскажу о некоторых решениях упражнений.
Если вам нравится эта серия гайдов в блоге, свяжитесь с нами по Type-Driven Consulting. Мы рады предоставить услуги по разработке программного обеспечения для dapps, а также учебные материалы для ваших собственных инженеров.