16 февраля 2023 года я провел семинар в Университете Ватерлоо о разработке умных контрактов на платформе Near с использованием Rust. Мне понравилось собирать информацию и мне кажется, что было бы забавно представить содержание здесь в виде серии блог-постов. В этом первом посте я дам аналогию, связывающую разработку блокчейнов с шаблоном для обычных веб-приложений, представлю пример умного контракта, который мы будем использовать в течение этой серии, и обсудим некоторые общие принципы разработки умных контрактов, уникальные для блокчейнов по сравнению с другими областями программирования.
Ментальная модель для создания распределенного приложения (dapp)
Цель этого раздела заключается в том, чтобы провести аналогию между разработкой на основе блокчейна (приложения, разработанные на базе технологии блокчейн, часто называются «dapps» в этой сфере) и более распространенной технологией для веб-приложений, с которой вы могли сталкиваться раньше. Эта аналогия может помочь вам понять, как пользователи взаимодействуют со смарт-контрактами.
Идея заключается в том, что dapps очень похожи на веб-приложения, основанные на “без серверной архитектуре”. Термин “без серверный” немного вводит в заблуждение, потому что, конечно, серверы все еще участвуют в процессе, но причина этого названия заключается в том, что аппаратное обеспечение (т. е. сервер), выполняющее код, скрыто от разработчика. Это имеет преимущества по сравнению с другой инфраструктурой облачных вычислений с точки зрения стоимости и масштабируемости, потому что вы платите только за те ресурсы, которые фактически используете, в отличие от того, чтобы платить за запуск виртуальной машины, которая может простаивать в бездействии, если трафик низкий, или может стать нерабочей, если трафик слишком большой. Каждый раз, когда пользователь взаимодействует с веб-приложением, на бэкэнде вызывается новый экземпляр “без серверной функции”, чтобы обслужить запрос пользователя, без того, чтобы разработчику приходилось думать о том, на каком аппаратном обеспечении эта функция работает.
Dapps также скрывают аппаратное обеспечение. Смарт-контракт развертывается на блокчейне и выполняется на узлах (серверах), которые составляют пара-пару сеть этого блокчейна. Когда пользователь взаимодействует с dapp, он делает вызов к блокчейну (транзакцию), чтобы выполнить смарт-контракт. Каждая транзакция создает новый экземпляр смарт-контракта (в том смысле, что между транзакциями нет состояния в памяти, сохраняемого между транзакциями), как и с без серверными функциями.
Ниже приведена изображение, взятое непосредственно с веб-сайта Amazon Web Services (AWS) для Lambda (их версии без серверного вычислительного предложения).
Это изображение легко изменить, чтобы увидеть, как похож рабочий процесс в децентрализованном приложении.
Еще одним сходством между вычислением без сервера и смарт-контрактами является то, что каждая транзакция имеет свою стоимость. В случае AWS за использованные ресурсы оплачивает аккаунт разработчика, а в случае блокчейна за выполнение транзакции уплачивает тот, кто ее подписал.
Используя эту аналогию в качестве опорной точки, давайте обсудим пример разработки децентрализованных приложений (dapp), который мы будем использовать в этой серии.
Наш пример: чат-приложение на основе блокчейна
Пример, который мы будем использовать в этой серии – это чат-приложение на основе блокчейна. Это не реальный пример в том смысле, что нет хорошего делового кейса для использования публичного блокчейна для чата (по моему мнению). То, что все сообщения будут полностью общедоступны и необратимо включены в постоянную запись, является недостатком, а не функцией. Однако причиной выбора этого примера является то, что он иллюстрирует различные важные концепции в разработке децентрализованных приложений, при этом логически легкий для того, кто использовал что-то вроде Facebook Messenger, Telegram или Signal.
Код для этого примера доступен на моем GitHub. README в этом репозитории дает инструкции по настройке среды разработки для взаимодействия с кодом и базовые представления о том, как использовать контракт. Эта серия постов будет намного глубже углубленной в код и как он работает.
Чтобы связать обсуждение принципов разработки смарт-контрактов, рассмотрим общую картину работы чат-контракта.
- Каждый желающий принять участие в сети чата развертывает свою собственную версию смарт-контракта.
- Каждый экземпляр контракта поддерживает список известных ему учетных записей (контакты, ожидающие запросы на добавление в контакты и т. д.). Кроме того, он сохраняет сообщения, которые получил (а также некоторые метаданные об этих сообщениях).
- Чтобы отправить сообщение кому-то другому, сначала нужно иметь его в качестве “контакта”. Это работает так, как вы и ожидаете: Алиса отправляет запрос на добавление в контакты Бобу, если Боб принимает запрос, то Алиса и Боб становятся контактами друг друга, в противном случае они не являются контактами.
- Каждый экземпляр контракта имеет “владельца”, который может отправлять сообщения и отправлять/принимать запросы на добавление в контакты.
Принципы разработки смарт-контрактов
Я хочу подчеркнуть три взаимосвязанные концепции, которые важны для разработки смарт-контрактов, но могут не встречаться в типичной разработке программного обеспечения.
1. враждебное мышление
2. экономика
3. обеспечить инварианты, прежде чем делать вызовы между контрактами.
Враждебное мышление
Первое важное, что нужно помнить при развертывании в общедоступный блокчейн, это то, что любой житель мира может взаимодействовать с вашим кодом. Если в вашем смарт-контракте есть какое-то чувствительное действие (например, при отправке сообщений в чат-контракте вы бы не хотели, чтобы кто-то мог выдать себя за вас), то вы должны явно проверять авторизацию, чтобы только уполномоченные аккаунты могли успешно выполнять действия (поэтому наш чат-контракт имеет свойство “владельца”). Если у вас есть метод, который принимает входные данные, то вы должны проверять их достоверность перед тем как передавать их на выполнение бизнес-логики, потому что любой случайный пользователь может отправить любые данные, которые ему нравятся. Действительно, идея враждебного мировоззрения идет еще дальше; не только пользователь может отправить неправильный ввод, но он может внимательно составить входные данные, чтобы вызвать уязвимость в вашем коде. Единственный способ предотвратить это – не иметь таких уязвимостей в первую очередь.
Аналогично, логика смарт-контракта часто зависит от некоторого протокола, чтобы координировать разные компоненты вместе (например, протокол для добавления контактов в наш чат-контракт). Имеет ли пользователь агентство в этом протоколе? Что происходит, если он не следует ему правильно? Это вопросы, на которые вы должны ответить при разработке смарт-контракта, потому что хакеры будут пытаться эксплуатировать ваш контракт.
Короче говоря, вы всегда должны предполагать, что любой внешний ввод является византийским, и явно проверять его, прежде чем продолжить. Вам следует научиться замечать, какие предположения вы делаете, и всегда думать: «Как я могу нарушить это предположение?» всякий раз, когда вы понимаете, что вы делаете его.
Экономика
Экономика типичного веб-приложения довольно проста. Необходимо генерировать достаточный доход, чтобы покрыть затраты на хостинг сервера, содержащего код и данные, используемые вашим приложением. Доход можно получить из нескольких источников, но наиболее распространенными являются доходы от рекламы и платные подписки пользователей.
Для блокчейна ситуация немного сложнее, потому что каждая транзакция должна быть оплачена независимо. Новые продукты на блокчейне стремятся упростить эту ситуацию, например, Aurora+ предоставляет что-то вроде «блокчейн-подписки», которая позволяет проводить определенное количество бесплатных транзакций. Но пока это не станет стандартом в блокчейн-пространстве, все еще важно ответить на вопрос “кто за это платит?”.
Часто пользователь оплачивает каждую транзакцию, потому что оплата связана с подписывающим аккаунтом (т.е. оплата связана с идентификацией / авторизацией). Альтернативная модель – использовать “мета-транзакции” (транзакции внутри транзакций), чтобы оплата происходила “внешним подписывающим”, а авторизация основывалась на “внутреннем подписывающем”. Так, например, работает Aurora+. К сожалению, поскольку это не является стандартным способом функционирования блокчейн-транзакций, разработчикам требуется дополнительная работа, чтобы сделать это возможным.
Для примера чат-приложения мы будем следовать пути наименьшего сопротивления, и каждый пользователь должен будет платить за затраты, которые он создает при использовании приложения. Приняв это решение, необходимо рассмотреть возможные затраты и убедиться, что они соответствующим образом покрываются. Например, на Near оплата за хранение обрабатывается через “стейкинг хранилища”. По сути, каждый аккаунт блокирует часть своего баланса в зависимости от объема занимаемого хранилища. Это важно в нашем чат-контракте, потому что он хранит сообщения, полученные от других пользователей, поэтому мы должны убедиться, что другие пользователи покрывают затраты на застейкинг хранилища, прикрепляя достаточный депозит к своему сообщению. Аналогично, запросы на контакт создают запись в хранилище, поэтому и тут необходим депозит. Если бы мы не требовали таких депозитных требований, то пользователи могли бы эффективно красть деньги друг у друга, отправляя множество сообщений и блокируя всю сумму на счете жертвы (обратите внимание, как это связано с адверсивным мышлением выше).
В заключение, при проектировании dapp всегда важно обдумать, какие затраты будут связаны с приложением и как они будут оплачены, будь то добавление проверок на депозиты или использование мета-транзакций.
Обеспечить инварианты, прежде чем делать вызовы между контрактами
Этот последний момент непрост. В типичном приложении весь код связывается в один двоичный файл. При вызове функции в библиотеке это обычно не вызывает никакой связи, а просто добавляет новый фрейм в стек и выполняет какой-то код из другой части двоичного файла. В блокчейн-среде дела обстоят немного иначе.
Вызов другого контракта больше напоминает вызов целого другого процесса, чем вызов библиотеки. Опять же, мы должны применять враждебный подход и понимать, что мы не знаем, что может делать этот другой процесс; в действительности, он может пытаться сделать что-то сознательно вредоносное. Общий вектор атаки заключается в том, чтобы другой процесс вызывал обратно наш контракт и эксплуатировать его, потому что наш контракт не ожидал новый вызов во время ожидания ответа на вызов, который он инициировал. Это называется “атакой на повторный вход” и она была источником одного из самых известных взломов на Ethereum, который привел к созданию “Ethereum Classic” (Ethereum Classic отверг “хардфорк”, который был ответом Ethereum Foundation на взлом).
На Near эту проблему даже более острой делает дополнительный вопрос атомарности. В Ethereum Virtual Machine (EVM) каждая транзакция является “атомарной” в том смысле, что все действия, результатом транзакции, либо записываются в состояние блокчейна, либо нет (весь транзакция “откатывается”). Это означает, что атаку на повторный вход можно предотвратить, используя откат; все, что произошло, будет отменено, сохраняя контракт в безопасности. Этот шаблон включен даже в пример Mutex в официальной документации по Solidity. Однако в работе времени выполнения Near выполнение контрактов независимо друг от друга; они не являются атомарными. Так что если транзакция приводит к тому, что контракт A вызывает контракт B, и B сталкивается с ошибкой, то изменения состояния, которые произошли в A, останутся.
Это было много истории и теории, но что из этого можно извлечь практически? Суть в том, что вы должны убедиться, что ваш контракт находится в “хорошем состоянии”, когда он делает вызов другому контракту. Это означает, что если существуют инварианты, на которых основана логика вашего контракта, то они должны быть правильны в момент вызова. Как простой пример, предположим, что у нас есть контракт с функцией передачи. Инвариантом, который необходимо поддерживать, является то, что токены не создаются или не уничтожаются при передаче. Если по какой-то причине во время передачи было нужно позвонить в другой контракт, неверно было бы задействовать один счет и затем делать вызов, не начисляя средства на другой счет. Это потому, что несоблюдение инварианта относительно токенов может быть использовано для эксплуатации. Пример в этом духе также приводится в документации по Near.
Итог
Подводя итог, в этом сообщении блога мы представляем новую серию сообщений, дающих введение в разработку смарт-контрактов на Near с использованием Rust. Здесь мы обсудили пример контракта чата, который мы будем использовать на протяжении всей серии, а также некоторые общие принципы, которые следует учитывать при разработке приложений на основе блокчейна. В следующем посте мы углубимся в код, чтобы обсудить технические детали реализации контракта. Это послужит примером для Rust SDK компании Near, иллюстрирующим концепции, применимые ко всем видам реальных контрактов, которые вы, возможно, захотите написать.
Если вам нравится эта серия постов в блоге, свяжитесь с нами по Type-Driven Consulting. Мы рады предоставить услуги по разработке программного обеспечения для dapps, а также учебные материалы для ваших собственных инженеров.