Создаем оракула на Solidity
Оригинал — https://noahliechti.hashnode.dev/an-effective-way-to-build-your-own-oracle-with-solidity
Канал — https://t.me/jetix37eth
Если вы ранее создавали смарт-контракт, возможно, вы уже знаете, как запрашивать информацию, такую как цены или случайные числа, в вашем контракте. Но как насчет внешних данных, которые не предоставляются обычными оракулами? Как мы можем получить доступ к папиным шуткам или вытянуть случайную карту?
Как работают Оракулы?
Для выполнения своей основной цели смарт-контрактам может требоваться связь с внешним миром. Оракулы дают эту функциональность. Оракул состоит из компонента, связанного с блокчейном (смарт-контракт), и компонента вне блокчейна, который может запрашивать API. Затем серверная часть периодически отправляет транзакции для обновления состояния смарт-контракта.
Важно понимать, что ваш контракт просит оракула выполнить вызов API. Он не выполняет вызов самостоятельно.
Есть две причины использовать оракулов:
- Вам нужна информация, которая не может быть предоставлена из блокчейна.
- Вам нужна информация, которая будет предоставлена в определенное время в будущем.
Идея
Создадим минимально жизнеспособного оракула, который позволит контракту извлекать случайные карты из стандартной колоды из 52 карт.
Минимальные требования
- Использовать API deckofcardsapi.com
- Запрос из контракта CardsClient стоит 0.001 $ETH
- Контракт CardsClient имеет callback-функцию fulfill
- CardsClient разрешает только один ожидающий запрос
- Выпавшие кадры идут как bytes2 (Туз Пик -> AS -> 0x4153)
- Когда CardsOracle получает запрос через receiveRequest, он вызывает событие OracleRequest
- Бекенд должен слушать OracleRequest, запрашивать API и высылать результат в CardsOracle через функцию fulfillRequest
- Функция fulfillRequest может вызваться только на беке
Реализация
Здесь полный код реализации: https://github.com/noahliechti/minimal-viable-oracle
Архитектура
Смарт-контракты
Моя реализация состоит из двух контрактов и одного интерфейса. Контракт, который взаимодействует с оракулом, называется CardsClient, а ончейн компонент, предоставляющий эту услугу, называется CardsOracle. Контракты были скомпилированы с версией 0.8.7 и также должны работать до версии 0.8.15.
CardsClient
Чтобы получить общее представление, я покажу вам переменные состояния и конструктор контракта CardsClient:
Owner — это адрес, который создал контракт. Таких контрактов может быть несколько, поскольку всем разрешено использовать нашего оракула.
Адрес оракула должен быть payable, потому что мы платим оракулу 0.001 ETH за каждый сделанный нами запрос. Причина взимания платы заключается в том, что оракул должен совершить вторичную транзакцию к контракту CardsClient, которая стоит некоторое количество ETH.
Результаты нашего запроса будут сохранены в массиве переменных bytes2. Каждая карта может быть эффективно представлена в двух байтах памяти (Туз пик -> AS -> 0x4153). Наконец, мы будем использовать uint32 для хранения идентификатора последнего ожидающего запроса. Эта реализация предотвращает несколько одновременных запросов. Вы также можете разрешить несколько одновременных запросов.
Далее нам надо определить функции, с которыми могут взаимодействовать кошельки или контракты:
Есть две public функции, называемые drawNCardsWithShuffle и drawNCardsWithoutShuffle. Первая функция вытягивает карту из перетасованной колоды и перетасовывает ее перед любым последующим розыгрышем карт. Вероятность вытянуть определенную карту останется 1/52. Последняя функция позволяет извлекать карты из одной и той же колоды без последующей повторной вставки карт. Обе функции начинаются с новой и перетасованной колоды при каждом запросе.
Функции drawNCardsWithShuffle и drawNCardsWithoutShuffle создаю уровень абстракции. Пользователь никогда не вызовет функцию drawNCards напрямую. Вот почему они internal.
Все функции используют uint8 в качестве типа данных для параметра _nrOfCards, поскольку это наименьший тип данных, способный хранить число 52.
Чтобы использовать функциональность между CardsClient и CardsOracle, мы создадим интерфейс, в котором есть структура Request:
Эта структура действует как новый тип данных. Он группирует несколько атрибутов в один "объект". С полями nrOfCards и shuffle мы уже знакомы.
В cbClient мы сохраним адрес контакта, который вызывает оракула. Вместе с cbSelector оракул может использовать функцию вызова низкого уровня для передачи результатов API обратно в наш контракт CardsClient.
Чтобы предотвратить злонамеренную перезапись уже выполненных запросов, мы можем отслеживать состояние с помощью поля fulfilled.
drawNCards отвечает за передачу эфира и Request оракулу.
После того, как мы убедимся, что ожидающего запроса нет, мы можем создать запрос и инициализировать его. address(this) — это cbClient, а this.fulfill.selector — cbSelector. Если вы присмотритесь повнимательнее, я добавил сигнатуру функции fulfill. Это функция, которую вызовет оракул и передаст результаты запроса оффчейн API. this.fulfill.selector возвращает первые левые четыре байта хэша Keccak-256 (SHA-3) сигнатуре функции fulfill.
Теперь, когда запрос сформирован, пришло время передать его CardsOracle. Чтобы успешно выполнить транзакцию, функция call пересылает все, что контракт получил либо через drawNCardsWithShuffle, либо через drawNCardsWithoutShuffle.
Получателем средств и структуры от оракула является функция receiveRequest . В первом аргументе функции abi.encodeWithSignature обязательно использовать две открывающие и закрывающие круглые скобки, поскольку мы используем структуру. В противном случае аргумент не будет закодирован правильно.
Транзакция receiveRequest контракта CardsOracle вызовет событие, которое сообщит бекенду, что она должна запросить API. Следовательно, переменная data не будет включать результат вызова API. Только fallback-функция (в нашем случае fulfill) получает доступ к результатам. Она будет вызвана из оракула транзакцией, которая инициируется бекендом.
Если call не успешен, транзакция отменяется. Если успешен, идентификатор запроса извлекается из data, сохраняется в pendingRequest и затем возвращается.
fulfill — это callback-функция, которая получает результат из оффчейн-сервиса:
Когда вызывается функция fulfill, она сначала проверяет, соответствует ли вызывающий адрес адресу, который мы сохранили в переменной состояния oracle. Это очень важно. В противном случае любой кошелек или контракт могли бы вызвать эту функцию и злонамеренно предоставить неверную информацию.
Наша функция также проверяет, что переданный _requestID совпадает с pendingRequestId. Я использую оператор assert, поскольку это условие, которое всегда должно быть истинным.
Мы также запускаем событие под названием ClientFulfillment. На самом деле это не обязательно, но полезно для отслеживания и тестирования нашего контракта.
В нашем случае функция просто сохраняет результат в переменной cards. Но это также может вызвать внутреннюю логику в зависимости от requestId. Все это зависит от варианта использования.
CardsOracle
Как и в случае с CardsClient, я показываю вам переменные состояния и конструктор.
Owner'ом является разработчик контракта. fee — это сумма, которую запрашивающий должен заплатить оракулу за использование его сервиса. Это должно, по крайней мере, покрывать плату за газ для вторичной транзакции.
Каждый запрос имеет свой собственный идентификатор. requestId увеличивается на единицу при каждом запросе. В экстренном случае, когда API будет взломан, нам нужно запретить пользователям отправлять запросы. С помощью stopped мы можем временно приостановить действие контракта.
Меппинг idToRequest кэширует запросы. На это есть две причины. Во-первых, нам нужно сохранить адрес и сигнатуру callback-функции, во-вторых, должен быть механизм, позволяющий не выполнять запрос несколько раз. Для этого мы используем поле fulfilled.
Сначала мы развертываем контракт CardsOracle, чтобы мы могли напрямую передать его адрес конструктору CardsClient.
Функция receiveRequest получает запросы вместе с fee и запускает событие.
receiveRequest получает Request в качестве параметра. Функция проверяет, не остановлен ли контракт и правильно ли заполнены все поля. Если нет, то все возвращается. Также количество эфира, отправляемое пользователем, должно быть больше или равно указанной нами комиссии. Возврат средств не производится, если пользователь платит слишком много. Все запросы хранятся в нашем меппинге idToRequest. К запросам можно получить доступ по requestId.
Далее мы генерируем событие OracleRequest. Наш бекенд слышит это событие и запрашивает данные из API. Важно, чтобы событие было отправлено из контракта CardsOracle. Если бы мы разрешили какому-либо контракту отправлять событие, оракул был бы уязвим для спама, поскольку мы не можем гарантировать, что пользователь заплатил за услугу.
Функция заканчивается оператором return, который возвращает текущий requestId и увеличивает ID. Это полезно, поскольку CardsClient может захотеть связать входящие вызовы по их идентификатору.
Следующая функция может быть вызвана только беком. Его задача заключается в передаче извлеченных данных callback-функции контракта CardsClient.
С помощью _requestId, который мы получаем в качестве параметра, мы можем получить доступ к кэшированным запросам. Параметр _cards сохраняет разыгранные карты. Для того, чтобы успешно передать _cards клиенту, необходимо выполнить несколько условий:
- Контракт не остановлен
- Запрос должен быть в idToRequest
- Запрос должен быть выполнен
- Количество запрошенных карт должно быть равно количеству кард, которые функция получила
После этого мы можем снова использовать функцию call. В этом случае мы передаем закодированные данные и _requestId.
Тестирование
Я написал несколько скриптов на Hardhat чтобы запустить проект:
- 1_deploy-contracts.js
- 2_listen-to-client-fulfillments.js
- 2_listen-to-oracle-requests.js
- 3_draw-cards.js
- helper-functions.js
Хотя все части важны для работы проекта, только 2_listen-to-oracle-requests.js необходим для прослушивания события OracleRequest и извлечения данных.
cardsOracle ссылается на контракт CardsOracle, который развернут в блокчейне. Метод on контракта позволяет нам определить событие, которое мы хотим прослушать, и действие, которое должно быть выполнено при запуске события.
Мы используем событие OracleRequest, поскольку это событие мы определили в функции receiveRequest контракта CardsOracle. Когда происходит событие, скрипт получает доступ ко всем параметрам, которые были определены в контракте.
Чтобы извлечь соответствующие данные, нам нужно передать nrOfCards и shuffle в функцию getCards. Эта функция выполняет вызов API deckofcardsapi.com и в результате возвращает массив карт в шестнадцатеричном формате. Этот массив (allCardsCoresHex) затем передается вместе с requestId обратно в контракт. Затем данные будут перенаправлены в fullback-функцию клиентского контракта.