В 2022 году мы перешли на Web3 с целью выяснить конкретные преимущества запуска игр полностью на блокчейне, а также собрать средства (к сожалению, нам не удалось с обоими).
В то же время я убедился, что использование Common Lisp каким-то образом будет полезно как для Tinka, так и для меня, и мы решили пойти на это и разработать на нем наше программное обеспечение.
Краткое (почти) резюме
Далее следует много контента (особенно кода), и я подумал, что было бы неплохо включить краткое описание:
- В этом посте описаны подробности библиотеки Common Lisp для взаимодействия с блокчейном Ethereum,
eth-cl
eth-cl
была написана в контексте игрового проекта (точнее, интерактивной истории)- Целью было поговорить с Ethereum на самом низком уровне, через
json-rpc
. - Библиотека включает в себя следующие возможности: создание учетной записи, развертывание контракта, взаимодействие с контрактом (т. е. вызовы функций для развернутых контрактов) и работа с базовыми транзакциями.
Это был мой первый профессиональный проект на Common Lisp, и он ни в коем случае не предназначен для использования в качестве справочного материала для чего-либо еще, кроме тех знаний, которые я получил от него.
Сделай сам
При решении новой проблемы (в играх или где-то еще) часто возникает соблазн использовать стороннее программное обеспечение. Во многих случаях это имеет смысл: зачем изобретать велосипед?
Хотя иногда полезно сделать это самостоятельно.
Примерно через неделю изучения Web3 (и Ethereum в частности) мы решили, что лучше всего попытаться создать небольшую игру, работающую на блокчейне (на самом деле это могло быть частью руководства по ethereum. орг). Нужно было многое раскрыть с технической точки зрения, и часто нет ничего лучше, чем создавать работающее программное обеспечение для обучения.
Я написал очень простую PvP-игру «Крестики-нолики» и запустил ее в тестовой сети. Было здорово увидеть, как что-то работает, и почувствовать процесс развертывания чего-то в цепочке.
Однако была одна проблема: поскольку очень многое было абстрагировано библиотеками, которые я использовал (web3.py, а также довольно большим количеством различных фреймворков), я на самом деле очень мало понимал, как то, что я написал, работало под капотом. . Например, мне было очень трудно по-настоящему понять взаимосвязь между потреблением газа и транзакциями.
Поскольку я был убежден, что нам нужно действительно четко понять, как работает Ethereum, я решил написать библиотеку для взаимодействия с цепочкой. Конечно, это будет сделано с использованием Common Lisp.
Требования
Хотя то, что мы создавали, поначалу было не очень четко (мы предварительно начали с идеи создания шахматной игры), в конечном итоге мы остановились на создании платформы для публикации книг Выбери свое собственное приключение, таких как эта.
Идея заключалась в том, чтобы предоставить авторам инструменты и конвейеры для создания этих интерактивных историй и их самостоятельной публикации в блокчейне, а также предоставить конечным пользователям клиентов для просмотра, покупки и «прослушивания» этих книг. Дополнительный контент (например, изображения) должен был быть загружен на IPFS для создания действительно децентрализованной платформы.
Требования к библиотеке были относительно простыми (и очевидными, поскольку это своего рода «оно» с точки зрения того, что означает взаимодействие с Ethereum, поскольку реальные вещи происходят в смарт-контрактах):
- Создание учетных записей (мы хотели делать бесплатные игры, поэтому нужны были «временные» адреса Ethereum)
- Создание контрактов из скомпилированного кода Solidity
- Вызов функций этих контрактов и декодирование ответов.
- Развертывание контрактов в Ethereum
Хотя мы пытались сделать как можно больше с нуля, некоторые вещи оказались слишком сложными:
- Параметры кодирования и декодирования. Это было просто то, на что я не нашел времени, так как нужно было многое сделать.
- Подписание сделок. Криптографическая библиотека Ironclad, которую мы использовали, не раскрывала все, что нам нужно, и я изо всех сил пытался внести в нее изменения, чтобы это сделать.
Первоначально мы добились этого, используя Geth в локальной частной цепочке, но мне не удалось заставить его подписывать транзакции для других цепочек. В итоге я использовал урезанную версию web3.py (eth_keys.backends.native.ecdsa
для подписи необработанных транзакций и eth_abi
для параметров кодирования и декодирования) и разговаривал с ней через микросервис (конечно, правильным способом было бы каким-то образом поговорить с Common Lisp на Python, но это было намного проще, поскольку мы использовали библиотеку только в контексте контейнеров Docker, развернутых на AWS).
Счет
Аккаунт — это приватный ключ secp256k1 (соответствующая литература есть здесь), из которого мы можем получить публичный ключ и адрес.
При использовании Ironclad создание нового закрытого ключа сводилось к простому вызову (ironclad:make-private-key :secp256k1 :x (ironclad:random-data 32))
.
Самое интересное заключалось в том, чтобы сгенерировать из него адрес Ethereum. Да, это просто хэш подпоследовательности закрытого ключа, но здесь есть дополнительная забава: каждый символ в адресе пишется с заглавной буквы, как указано здесь.
Вот полный код:
Затем мы можем использовать следующие три простые функции для доступа к данным:
ПКП
Поскольку мы хотели напрямую общаться с цепочкой, мы собирались сделать это через интерфейс json-rpc. Благодаря макросам Common Lisp я мог легко генерировать функции, вызывающие различные RPC (используя Dexador для отправки HTTP-запросов и jzon для кодирования и декодирования в JSON).
Затем я смог определить функции RPC следующим образом:
Например, eth_getBalance
расширяется до:
Это был нижний уровень библиотеки (на самом деле существует существующая библиотека web3.lisp, выполняющая то же самое).
Транзакции
Углубление транзакций было полезно по трем причинам:
- Это позволило мне взглянуть на правильное определение транзакции и ее формат (а также понять различные версии).
- Я смог гораздо лучше понять газ, вместо того, чтобы пытаться отправлять различные значения, пока он не заработает.
- Это позволило мне увидеть, как они выглядят в необработанном виде, что полезно, поскольку при игре с Ethereum часто приходится иметь дело с длинными шестнадцатеричными строками. Умение сказать, что они представляют, очень полезно.
Первым шагом было определение структуры данных, в которой будут храниться объекты транзакции. Как обычно, я начал со списка свойств и в конечном итоге перешел к классу. Формат основан на определении, указанном здесь, т.е. транзакции EIP-1559.
Как видите, это просто раскрывает поля из этого определения. При создании нового экземпляра транзакции мы инициализируем max-fee-per-gas
из данных цепочки:
Затем у нас есть функция, преобразующая эти данные во что-то, что смогут использовать функции RPC.
Отправка транзакции в цепочку производилась в два этапа:
- Во-первых, установите nonce и оцените стоимость газа (
eth/get-transaction-count
иeth/estimate-gas
в полезных данных транзакции). - Во-вторых, закодируйте и отправьте транзакцию
Кодирование транзакции означало создание необработанной транзакции, как описано здесь, и ее подписание. Как я упоминал выше, мы делегировали подписание web3.py, но нам удалось выполнить остальную часть процесса.
Первое, что нужно было сделать, — это сгладить транзакцию, по сути, объединив все ее данные в одну шестнадцатеричную строку. Это то, что делает transaction->flat
.
Затем эта сведенная транзакция должна быть закодирована RLP. Код кажется немного загадочным (без каламбура), но на самом деле он довольно прост. Это скорее вопрос следования документации, чем чего-либо еще, и кодирования параметров в правильном формате.
Вышеупомянутая функция по существу рекурсивна, пока ей передается список. При работе с атомами он добавляет нужные данные в зависимости от длины этих атомов в байтах.
Получить хеш транзакции тогда просто:
Наконец, функция encode-transaction
:
После того как мы закодировали нашу транзакцию, мы можем отправить ее с помощью eth/send-raw-transaction
. Это абстрагировано в следующих общих функциях и методах:
Одной из причин использования методов была возможность специализировать поведение для определенных типов транзакций: например, нам нужно было поддерживать то, что мы называли «параллельными» транзакциями, и гарантировать, что nonce всегда будет уникальным, что выглядело так:
Мы также хотели иметь возможность блокировать майнинговые транзакции.
Я, конечно, также написал эквивалентные функции декодирования:
Контракты
Наконец, на самом высоком уровне у нас есть контракты. Нам нужно было добиться следующего:
- Загрузка данных смарт-контракта из скомпилированных файлов Solidity
- Развертывание контрактов в цепочке
- Вызов функций по контрактам
Сначала мы помещаем данные контракта в определение класса:
Развертывание контракта, конечно, осуществляется посредством транзакции. Чтобы упростить работу, мы определяем класс deploy-transaction
, который заполняет данные из скомпилированного контракта при создании экземпляра.
Чтобы создать deploy-transaction
, нам нужно объединить байт-код контракта с закодированными параметрами его конструктора, если он есть (кодирование выполняется с помощью крошечного подмножества web3.py, о котором я упоминал выше). Самый интересный момент — это функция arguments
, которая работает со структурами и выравнивает их.
Вызов функции по контракту абстрагирует две отдельные концепции:
- Вызов функции
pure
илиview
приводит к вызовуeth/call
. - Вызов функции другого типа приводит к отправке новой транзакции в цепочку для майнинга.
call-transaction
— это оболочка транзакции, которая вызывает функцию. Это сбивает с толку, поскольку он используется как для вызовов только для чтения (т. е. функций pure
или view
), так и для других. Создание транзакции вызова формирует нужную полезную нагрузку в зависимости от параметров, которые необходимо передать в функцию контракта.
«Клиентская» функция call
сначала создает call-transaction
и связанные с ним данные (большая часть работы связана с кодированием параметров). Затем вызывается eth/call
, чтобы получить результат этой транзакции. Если вызов функции доступен только для чтения, это все, что нам нужно. В противном случае клиентский код должен отправить возвращенную транзакцию в цепочку для майнинга.
Как вы можете видеть, мы сигнализируем об ошибке, если транзакция вызывает функцию pure
или view
, поскольку в ее майнинге нет необходимости.
Наконец, последние несколько битов, которые мы хотели обработать, — это журналы.
Заключение
Это основная часть того, что мы использовали для ныне несуществующего прототипа платформы Tinka. Это был отличный опыт обучения, поскольку мне пришлось по-настоящему углубиться в различные спецификации транзакций, кодирования и т. д.