Сетевой протокол Ethereum с нуля. Часть первая. Соединение

В этой статье мы разберемся в основных концепциях работы сети 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:

Мы получили сообщение от узла начальной загрузки! Отлично.

 

Перевод статьи «Ethereum from scratch»