Сетевой протокол Ethereum с нуля. Часть первая. Соединение
В этой статье мы разберемся в основных концепциях работы сети Ethereum и напишем Python-скрипт для ее пингования.Ethereum — это криптовалюта, где код может исполняться посредством блокчейна. Предполагается, что у читателя есть базовое понимание Python, Git и сетевых концепций, таких как TCP и UDP.
16К открытий16К показов
В этой статье мы разберемся в основных концепциях работы сети Ethereum и напишем Python-скрипт для ее пингования.
Ethereum — это криптовалюта, где код может исполняться посредством блокчейна. Эта возможность позволяет создавать «умные контракты», которые будут выполняться автоматически. Давайте разберемся, как работают «умные контракты» и протокол Ethereum в целом.
Предполагается, что у читателя есть базовое понимание Python, Git и сетевых концепций, таких как TCP и UDP.
Концепция криптовалюты
Под криптовалютой подразумевается механизм, который использует децентрализованное хранение и передачу информации. В централизованных системах всегда есть доверенная третья сторона, которая отслеживает все учетные данные и занимается обработкой транзакций. Без посредника сторонам может быть трудно доказать, что то, о чем они говорят, действительно принадлежит им. Такую систему использует большинство банков мира.
Криптовалюты решают проблему децентрализованного регулирования, так как каждый элемент сети ведет учет всех проходящих в ней транзакций. Для поддержания консенсуса после проведения транзакции данные о ней передаются в сеть вместе с математической задачей, которую решают узлы сети и распространяют далее. Это обновление, протекающее по сети, и является доказательством того, что транзакция была проведена успешно и имеет место быть.
Настройка среды разработки
Все нижеприведенные действия производились на Amazon Linux и должны быть осуществимы на OS X и большинстве дистрибудивов Linux.
Давайте создадим виртуальную среду для этого проекта:
Виртуальная среда не позволит произойти конфликту модулей Python.
Для активации виртуального окружения запустите команду:
Это приведет к изменению некоторых переменных среды. Теперь Python будет использовать пакеты только из виртуальной среды, pip будет ставить пакеты только туда.
Вы можете убедиться, что все сработало, проверив путь к среде:
Для того, чтобы не запускать виртуальную среду каждый раз при входе в систему, можете добавить следующие строки в ~/.bashrc
:
Это довольно удобно использовать при работе над проектом.
Примечание Используемая версия Python 2.7.12 не гарантирует, что все будет работать с другим версиями.
Версию можно проверить командой:
Последнее, что нужно сделать — создать пакетный скелет с библиотекой pipiecutter:
Будем использовать minimal skeleton
, который позволяет производить публикацию в pip и выполнять тестирование:
Вам потребуется ответить на некоторые вопросы для настройки проекта. Назовем проект pyethtutorial
. После этого вы можете настроить Git для его отслеживания.
Также давайте установим пакет nose
. Он понадобится для тестирования:
Теперь давайте проверим, что все работает. В pyethtutorial/tests
есть один тест, который поможет нам в этом убедиться:
Для запуска всех тестов используйте команду nosetests
в каталоге проекта:
Все работает. Идем дальше.
Реализация
Нам нужно выяснить, как общаться с другими узлами сети.
Вот отрывок из документации по протоколу Ethereum:
Одноранговая связь между узлами, на которых запущенны клиенты Ethereum, выполняется с использованием протокола devp2p.
Что приводит нас к протоколу devp2p:
Узлы devp2p обмениваются сообщениями с использованием RLPx — транспортного протокола, использующего шифрование. Одноранговые узлы могут предлагать и принимать соединения на любых TCP-портах, однако по умолчанию порт, на котором может быть установлено соединение, будет 30303.Узлы devp2p находят соседние узлы с помощью протокола обнаружения DHT.
Таким образом по умолчанию мы отправляем пакеты через порт 30303, используя протокол RLPx. Протокол devp2p имеет два разных режима: основной, который использует TCP, и режим обнаружения, который использует UDP. UDP работает таким образом: вы подключаетесь к определенным серверам, называемыми «узлами начальной загрузки» (для BitTorrent это router.bittorrent.com и router.utorrent.com), которые предоставляют вам небольшой список одноранговых узлов для подключения. После получения списка узлов вы можете подключиться к ним. Сервера в свою очередь будут делиться своими списками узлов с вами. Это будет продолжаться до тех пор, пока у вас не будет полного списка узлов в сети.
Звучит достаточно просто, но давайте сделаем это еще проще. В спецификации RLPx есть раздел «Обнаружение узла». В нем описано, как сообщения отправляются через UPD порт 30303, задавая следующую структуру пакетов:
И различные типы пакетов:
Типы сообщений представлены C-подобными структурами данных. Самое простое, что мы можем сделать сейчас — это реализовать пакет PingNode
, который состоит из объекта version
, двух объектов EndPoint
и timestamp
. Объекты EndPoint
состоят из IP-адреса и двух целых чисел, представляющих порты UDP и TCP соответственно.
Чтобы отправить пакеты на аппаратный интерфейс, они кодируются по стандарту RLP. В документации говорится:
Функция кодирования RLP принимает элемент. Элемент определяется следующим образом:
Строка (то есть массив байтов) является элементом.
Список элементов – это элемент.Кодирование RLP определяется следующим образом:
Для одного байта, значение которого находится в диапазоне [0x00, 0x7f], этот байт является его собственной RLP-кодировкой.
В противном случае, если длина строки составляет 0-55 байт, кодировка RLP состоит из одного байта со значением 0x80 плюс длина строки, за которой следует строка. Таким образом, диапазон первого байта [0x80, 0xb7].
Если длина строки больше 55 байтов, то RLP-кодировка состоит из одного байта со значением 0xb7 плюс длина в строки в двоичной форме, за которой следует длина строки, за которой следует строка. Например, строка длиной 1024 будет кодироваться как \ xb9 \ x04 \ x00, за которой следует строка. Таким образом, диапазон первого байта равен [0xb8, 0xbf].
Если общая полезная нагрузка списка (т. е. длина всех элементов) составляет 0-55 байт, то RLP-кодирование состоит из одного байта со значением 0xc0 плюс длина списка, за которым следует последовательность RLP-кодировок каждого элемента. Таким образом, диапазон первого байта [0xc0, 0xf7].
Если общая полезная нагрузка списка составляет более 55 байтов, то RLP-кодирование состоит из одного байта со значением 0xf7 плюс длина полезной нагрузки в двоичной форме, за которой следует длина полезной нагрузки, за которой следует последовательность RLP-кодировок объектов. Таким образом, диапазон первого байта [0xf8, 0xff].
Прежде чем что-либо можно будет закодировать в RLP, нужно преобразовать структуру в «элемент»: либо строку, либо список элементов (определение является рекурсивным). Как сказано в документации, RLP просто кодирует «структуру» и оставляет интерпретацию байтов содержимого протоколу более высокого порядка.
Начнем реализацию самого протокола. Будем использовать библиотеку rlp для RLP-кодирования и декодирования. Для установки используем pip install rlp
.
У нас есть все необходимое для отправки пакета PingNode
. Далее мы создадим PingNode
, упакуем его и отправим. Чтобы упаковать данные, мы начнем с RLP-кодирования структуры, потом добавим байт, чтобы обозначить тип структуры, добавим криптографическую подпись и, наконец, добавим хеш для проверки целостности пакета. Приступим.
pyethtutorial/discovery.py:
Первый класс — это класс EndPoint
. Ожидается, что порты будут целыми числами и адрес будет находиться в формате 127.0.0.1. Адрес передается в библиотеку ipaddress
, поэтому мы можем использовать его служебные функции, например, преобразование представления с точками в двоичный формат, что и происходит в методе pack
. Для установки этого пакета используйте pip install ipaddress
. Метод pack
подготавливает объект, который будет использоваться rlp.encode
, преобразуя его в список строк. Для портов на странице спецификации RLP сказано: «Целые числа Ethereum должны быть представлены в бинарной форме», а спецификация Endpoint
перечисляет их типы данных как uint16_t
или беззнаковые 16-битные целые числа. Таким образом, используется метод struck.pack
с строкой формата >H
, что означает «big-endian unsigned 16-bit integer».
Следующий класс — это PingNode
. Вместо того, чтобы задавать значения позже, введем исходные байтовые значения для packet_type
и version
. Для метода pack
мы можем использовать исходное значение версии, так как оно уже находится в байтах. Для адресов будем использовать struct.pack
со строкой формата >I
. Также добавим 60 к отметке времени, чтобы дать дополнительные 60 секунд и пакет успел прибыть в пункт назначения. (В документации сказано, что пакеты с устаревшей временной меткой не обрабатываются.)
Последний класс — PingServer
. Этот класс открывает сокеты, подписывает, хеширует сообщения и отправляет их на другие серверы. Конструктор принимает объект EndPoint
. Далее при создании сервера загружается секретный ключ, который мы должны инициализировать.
Ethreum использует систему асимметричного шифрования, основанную на эллиптических кривых secp256k1
. Для реализации понадобится библиотека secp256k1-py
. Установим pip install secp256k1
.
Чтобы сгенерировать ключ, вызовем конструктор Private Key
с None
в качестве аргумента, а затем запишем вывод функции serialized()
в файл:
Помещаем файл в каталог с проектом. Не забудьте добавить его в .gitignore
, если вы используете Git, чтобы случайно его не опубликовать.
Метод wrap_packet
кодирует пакет: hash || signature || packet-type || packet-data
Первое, что нужно сделать, — добавить тип пакета в RLP-код пакетных данных. Затем хэшированная полезная нагрузка подписывается с помощью функции ecdsa_sign_recoverable
и ключа. Параметр raw
установлен в значение True
, потому что мы сами сделали хеширование (иначе функция использовала бы собственную хеш-функцию). Затем мы обрабатываем подпись и добавляем ее перед полезной нагрузкой. Наконец, вся полезная нагрузка хэшируется, и этот хэш добавляется в пакет. Теперь пакет готов к отправке.
Возможно, вы заметили, что мы еще не определили функцию keccak256
. Ethereum использует нестандартный алгоритм sha3
, называемый keccak-256
. Библиотека pysha3
реализует ее. Используйте pip install pysha3
для установки.
В pyethtutorial/crypto.py
мы определяем keccak256
:
Вернемся к PingServer
. Следующая функция udp_listen
обрабатывает входящие передачи. Она создает объект сокета, который привязывается к UDP-порту сервера. Затем определяется функция receive_ping
, которая принимает входящие данные, выводит их и возвращает объект Thread
, который будет запускать get_ping
, поэтому мы можем отправлять пинги одновременно с приемом входящих данных.
Последний метод ping
создает объект PingNode
, формирует сообщение с помощью wrap_packet
и отправляет его с использованием UDP.
Теперь мы можем настроить скрипт, который будет отправлять некоторые пакеты.
send_ping.py:
Запустим код и получим следующий вывод:
Мы успешно запинговали самих себя.
Пытаемся пинговать соседний узел
Хорошими кандидатами для приема наших сообщений будут те самые узлы начальной загрузки. В документации сказано:
Чтобы начать работу, geth использует узлы начальной загрузки, адреса которых записаны в исходном коде.
Geth — это Ethereum-клиент, реализованный на Go. В этом репозитории файл /bootnodes.go содержит списки узлов начальной загрузки в специальном формате:
public_key_hex@ip_address:port
Ниже перечислены основные узлы сети:
Для примера используется узел US-WEST, но вы можете использовать любой из этого списка. Например, ближайший к вам.
Сейчас send_ping.py
выглядит так:
Давайте проверим, что выйдет:
Ждет ответа, но его нет. Разберемся, что пошло не так.
Решение
Оказывается, что Ethereum использует адрес возврата из заголовка UPD, а не тот, который мы ему передаем в PingNode
.
53042 – это порт из заголовка UDP. Сокет отправляет пакет с этим заголовком, потому что он не привязан к какому-либо порту заранее. Ниже в комментариях отмечены проблемы с PingServer
:
Проблема в том, что udp_listen
и ping
используют разные сокеты (созданные на строках 3 и 15), а тот, который используется ping
, не привязан к порту 30303, поэтому он использует произвольный порт.
Чтобы исправить это, нужно переопределить порт в методе __init__
на сервере. Окончательный результат выглядит примерно так:
Сокет инициализирован в __init__
и указан в udp_listen
и ping
.
Теперь попробуем запустить send_ping.py
:
Мы получили сообщение от узла начальной загрузки! Отлично.
16К открытий16К показов