Аватарка пользователя Gregory Bass
Gregory Bass

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

В этой статье мы разберемся в основных концепциях работы сети Ethereum и напишем Python-скрипт для ее пингования.Ethereum — это криптовалюта, где код может исполняться посредством блокчейна. Предполагается, что у читателя есть базовое понимание Python, Git и сетевых концепций, таких как TCP и UDP.

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

В этой статье мы разберемся в основных концепциях работы сети Ethereum и напишем Python-скрипт для ее пингования.

Ethereum — это криптовалюта, где код может исполняться посредством блокчейна. Эта возможность позволяет создавать «умные контракты», которые будут выполняться автоматически. Давайте разберемся, как работают «умные контракты» и протокол Ethereum в целом.

Предполагается, что у читателя есть базовое понимание Python, Git и сетевых концепций, таких как TCP и UDP.

Концепция криптовалюты

Под криптовалютой подразумевается механизм, который использует децентрализованное хранение и передачу информации. В централизованных системах всегда есть доверенная третья сторона, которая отслеживает все учетные данные и занимается обработкой транзакций. Без посредника сторонам может быть трудно доказать, что то, о чем они говорят, действительно принадлежит им. Такую систему использует большинство банков мира.

Криптовалюты решают проблему децентрализованного регулирования, так как каждый элемент сети ведет учет всех проходящих в ней транзакций. Для поддержания консенсуса после проведения транзакции данные о ней передаются в сеть вместе с математической задачей, которую решают узлы сети и распространяют далее. Это обновление, протекающее по сети, и является доказательством того, что транзакция была проведена успешно и имеет место быть.

Настройка среды разработки

Все нижеприведенные действия производились на Amazon Linux и должны быть осуществимы на OS X и большинстве дистрибудивов Linux.

Давайте создадим виртуальную среду для этого проекта:

			$ virtualenv pyeth
		

Виртуальная среда не позволит произойти конфликту модулей Python.

Для активации виртуального окружения запустите команду:

			$ source pyeth/bin/activate
		

Это приведет к изменению некоторых переменных среды. Теперь Python будет использовать пакеты только из виртуальной среды, pip будет ставить пакеты только туда.

Вы можете убедиться, что все сработало, проверив путь к среде:

			(pyeth)$ which python
~/pyeth/bin/python
		

Для того, чтобы не запускать виртуальную среду каждый раз при входе в систему, можете добавить следующие строки в ~/.bashrc:

			source ~/pyeth/bin/activate
		

Это довольно удобно использовать при работе над проектом.

Примечание Используемая версия Python 2.7.12 не гарантирует, что все будет работать с другим версиями.

Версию можно проверить командой:

			(pyeth)$ python --version
Python 2.7.12
		

Последнее, что нужно сделать — создать пакетный скелет с библиотекой pipiecutter:

			(pyeth)$ pip install cookiecutter
		

Будем использовать minimal skeleton, который позволяет производить публикацию в pip и выполнять тестирование:

			(pyeth)$ cookiecutter gh:wdm0006/cookiecutter-pipproject
		

Вам потребуется ответить на некоторые вопросы для настройки проекта. Назовем проект pyethtutorial. После этого вы можете настроить Git для его отслеживания.

Также давайте установим пакет nose. Он понадобится для тестирования:

			(pyeth)$ pip install nose
		

Теперь давайте проверим, что все работает. В pyethtutorial/tests есть один тест, который поможет нам в этом убедиться:

			def test_pass():
        assert True, "dummy sample test"
		

Для запуска всех тестов используйте команду nosetests в каталоге проекта:

			(pyeth)$ nosetests
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
		

Все работает. Идем дальше.

Реализация

Нам нужно выяснить, как общаться с другими узлами сети.
Вот отрывок из документации по протоколу Ethereum:

Что приводит нас к протоколу devp2p:

Таким образом по умолчанию мы отправляем пакеты через порт 30303, используя протокол RLPx. Протокол devp2p имеет два разных режима: основной, который использует TCP, и режим обнаружения, который использует UDP. UDP работает таким образом: вы подключаетесь к определенным серверам, называемыми «узлами начальной загрузки» (для BitTorrent это router.bittorrent.com и router.utorrent.com), которые предоставляют вам небольшой список одноранговых узлов для подключения. После получения списка узлов вы можете подключиться к ним. Сервера в свою очередь будут делиться своими списками узлов с вами. Это будет продолжаться до тех пор, пока у вас не будет полного списка узлов в сети.

Звучит достаточно просто, но давайте сделаем это еще проще. В спецификации RLPx есть раздел «Обнаружение узла». В нем описано, как сообщения отправляются через UPD порт 30303, задавая следующую структуру пакетов:

			hash || signature || packet-type || packet-data
    hash: sha3(signature || packet-type || packet-data) 
    signature: sign(privkey, sha3(packet-type || packet-data))
    signature: sign(privkey, sha3(pubkey || packet-type || packet-data))
    packet-type: single byte < 2**7 // valid values are [1,4]
    packet-data: RLP encoded list. Packet properties are serialized in the order in which they're defined. See packet-data below.
		

И различные типы пакетов:

			Все структуры данных кодируются RLP.
Общая полезная нагрузка пакета (за исключением заголовков IP) не должна превышать 1280 байт.
NodeId: открытый ключ узла.
Inline: свойства привязаны к текущему списку.
Timestamp: указывает на то, когда был создан пакет (количество секунд с 1-го января 1970).

PingNode packet-type: 0x01
struct PingNode
{
    h256 version = 0x3;
    Endpoint from;
    Endpoint to;
    uint32_t timestamp;
};

Pong packet-type: 0x02
struct Pong
{
    Endpoint to;
    h256 echo;
    uint32_t timestamp;
};

FindNeighbours packet-type: 0x03
struct FindNeighbours
{
    NodeId target; // Id узла. Отвечающий узел будет отправлять ответ по адресу указанному в target.
    uint32_t timestamp;
};

Neighbors packet-type: 0x04
struct Neighbours
{
    list nodes: struct Neighbour
    {
        inline Endpoint endpoint;
        NodeId node;
    };

    uint32_t timestamp;
};

struct Endpoint
{
    bytes address; // BE-кодированный 4-байтовый или 16-байтовый адрес
    uint16_t udpPort; // BE-кодированный 16-битный 
    uint16_t tcpPort; // BE-кодированный 16-битный
}
		

Типы сообщений представлены C-подобными структурами данных. Самое простое, что мы можем сделать сейчас — это реализовать пакет PingNode, который состоит из объекта version, двух объектов EndPoint и timestamp. Объекты EndPoint состоят из IP-адреса и двух целых чисел, представляющих порты UDP и TCP соответственно.

Чтобы отправить пакеты на аппаратный интерфейс, они кодируются по стандарту RLP. В документации говорится:

Прежде чем что-либо можно будет закодировать в RLP, нужно преобразовать структуру в «элемент»: либо строку, либо список элементов (определение является рекурсивным). Как сказано в документации, RLP просто кодирует «структуру» и оставляет интерпретацию байтов содержимого протоколу более высокого порядка.

Начнем реализацию самого протокола. Будем использовать библиотеку rlp для RLP-кодирования и декодирования. Для установки используем pip install rlp.

У нас есть все необходимое для отправки пакета PingNode. Далее мы создадим PingNode, упакуем его и отправим. Чтобы упаковать данные, мы начнем с RLP-кодирования структуры, потом добавим байт, чтобы обозначить тип структуры, добавим криптографическую подпись и, наконец, добавим хеш для проверки целостности пакета. Приступим.

pyethtutorial/discovery.py:

			import socket
import threading
import time
import struct
import rlp
from crypto import keccak256
from secp256k1 import PrivateKey
from ipaddress import ip_address

class EndPoint(object):
    def __init__(self, address, udpPort, tcpPort):
        self.address = ip_address(address)
        self.udpPort = udpPort
        self.tcpPort = tcpPort

    def pack(self):
        return [self.address.packed,
                struct.pack(">H", self.udpPort),
                struct.pack(">H", self.tcpPort)]
		

Первый класс — это класс 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».

			class PingNode(object):
    packet_type = '\x01';
    version = '\x03';
    def __init__(self, endpoint_from, endpoint_to):
        self.endpoint_from = endpoint_from
        self.endpoint_to = endpoint_to

    def pack(self):
        return [self.version,
                self.endpoint_from.pack(),
                self.endpoint_to.pack(),
                struct.pack(">I", time.time() + 60)]
		

Следующий класс — это PingNode. Вместо того, чтобы задавать значения позже, введем исходные байтовые значения для packet_type и version. Для метода pack мы можем использовать исходное значение версии, так как оно уже находится в байтах. Для адресов будем использовать struct.pack со строкой формата >I. Также добавим 60 к отметке времени, чтобы дать дополнительные 60 секунд и пакет успел прибыть в пункт назначения. (В документации сказано, что пакеты с устаревшей временной меткой не обрабатываются.)

			class PingServer(object):
    def __init__(self, my_endpoint):
        self.endpoint = my_endpoint

        ## get private key
        priv_key_file = open('priv_key', 'r')
        priv_key_serialized = priv_key_file.read()
        priv_key_file.close()
        self.priv_key = PrivateKey()
        self.priv_key.deserialize(priv_key_serialized)


    def wrap_packet(self, packet):
        payload = packet.packet_type + rlp.encode(packet.pack())
        sig = self.priv_key.ecdsa_sign_recoverable(keccak256(payload), raw = True)
        sig_serialized = self.priv_key.ecdsa_recoverable_serialize(sig)
        payload = sig_serialized[0] + chr(sig_serialized[1]) + payload

        payload_hash = keccak256(payload)
        return payload_hash + payload

    def udp_listen(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.bind(('0.0.0.0', self.endpoint.udpPort))

        def receive_ping():
            print "listening..."
            data, addr = sock.recvfrom(1024)
            print "received message[", addr, "]"

        return threading.Thread(target = receive_ping)

    def ping(self, endpoint):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        ping = PingNode(self.endpoint, endpoint)
        message = self.wrap_packet(ping)
        print "sending ping."
        sock.sendto(message, (endpoint.address.exploded, endpoint.udpPort))
		

Последний класс — 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:

			import hashlib
import sha3

## Ethereum uses the keccak-256 hash algorithm
def keccak256(s):
    k = sha3.keccak_256()
    k.update(s)
    return k.digest()
		

Вернемся к PingServer. Следующая функция udp_listen обрабатывает входящие передачи. Она создает объект сокета, который привязывается к UDP-порту сервера. Затем определяется функция receive_ping, которая принимает входящие данные, выводит их и возвращает объект Thread, который будет запускать get_ping, поэтому мы можем отправлять пинги одновременно с приемом входящих данных.

Последний метод ping создает объект PingNode, формирует сообщение с помощью wrap_packet и отправляет его с использованием UDP.

Теперь мы можем настроить скрипт, который будет отправлять некоторые пакеты.

			from discovery import EndPoint, PingNode, PingServer

my_endpoint = EndPoint(u'52.4.20.183', 30303, 30303)
their_endpoint = EndPoint(u'127.0.0.1', 30303, 30303)

server = PingServer(my_endpoint)

listen_thread = server.udp_listen()
listen_thread.start()

server.ping(their_endpoint)
		

Запустим код и получим следующий вывод:

			(pyeth)$ python send_ping.py
sending ping
listening...
received message[ ('127.0.0.1', 41948) ]
		

Мы успешно запинговали самих себя.

Пытаемся пинговать соседний узел

Хорошими кандидатами для приема наших сообщений будут те самые узлы начальной загрузки. В документации сказано:

Geth — это Ethereum-клиент, реализованный на Go. В этом репозитории файл /bootnodes.go содержит списки узлов начальной загрузки в специальном формате:

public_key_hex@ip_address:port

Ниже перечислены основные узлы сети:

			var MainnetBootnodes = []string{

    // Ethereum Foundation Go Bootnodes
    "enode://[..pub_key..]@52.16.188.185:30303", // IE
    "enode://[..pub_key..]@13.93.211.84:30303",  // US-WEST
    "enode://[..pub_key..]@191.235.84.50:30303", // BR
    "enode://[..pub_key..]@13.75.154.138:30303", // AU
    "enode://[..pub_key..]@52.74.57.123:30303",  // SG

    // Ethereum Foundation Cpp Bootnodes
    "enode://[..pub_key..]@5.1.83.226:30303", // DE

}
		

Для примера используется узел US-WEST, но вы можете использовать любой из этого списка. Например, ближайший к вам.

			their_endpoint = EndPoint(u'13.93.211.84', 30303, 30303)
		

Сейчас send_ping.py выглядит так:

			from discovery import EndPoint, PingNode, PingServer

my_endpoint = EndPoint(u'52.4.20.183', 30303, 30303)
their_endpoint = EndPoint(u'13.93.211.84', 30303, 30303)

server = PingServer(my_endpoint)

listen_thread = server.udp_listen()
listen_thread.start()

server.ping(their_endpoint)
		

Давайте проверим, что выйдет:

			$ python send_ping.py
sending ping.
listening...
		

Ждет ответа, но его нет. Разберемся, что пошло не так.

Решение

Оказывается, что Ethereum использует адрес возврата из заголовка UPD, а не тот, который мы ему передаем в PingNode.

			received message[ ('127.0.0.1', 53042) ]
		

53042 – это порт из заголовка UDP. Сокет отправляет пакет с этим заголовком, потому что он не привязан к какому-либо порту заранее. Ниже в комментариях отмечены проблемы с PingServer:

			def udp_listen(self):
        ## сокет создается и привязывается к порту 30303
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.bind(('0.0.0.0', self.endpoint.udpPort))

        def receive_ping():
            print "listening..."
            data, addr = sock.recvfrom(1024)
            print "received message[", addr, "]"

        return threading.Thread(target = receive_ping)

    def ping(self, endpoint):
        ## новый сокет не походит!
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        ping = PingNode(self.endpoint, endpoint)
        message = self.wrap_packet(ping)
        print "sending ping."
        sock.sendto(message, (endpoint.address.exploded, endpoint.udpPort))
		

Проблема в том, что udp_listen и ping используют разные сокеты (созданные на строках 3 и 15), а тот, который используется ping, не привязан к порту 30303, поэтому он использует произвольный порт.

Чтобы исправить это, нужно переопределить порт в методе __init__ на сервере. Окончательный результат выглядит примерно так:

			class PingServer(object):
    def __init__(self, my_endpoint):
        self.endpoint = my_endpoint

        ## получаем ключ
        priv_key_file = open('priv_key', 'r')
        priv_key_serialized = priv_key_file.read()
        priv_key_file.close()
        self.priv_key = PrivateKey()
        self.priv_key.deserialize(priv_key_serialized)

        ## инициализируем сокет
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.bind(('0.0.0.0', self.endpoint.udpPort))

    def wrap_packet(self, packet):
        payload = packet.packet_type + rlp.encode(packet.pack())
        sig = self.priv_key.ecdsa_sign_recoverable(keccak256(payload), 
                                                   raw = True)
        sig_serialized = self.priv_key.ecdsa_recoverable_serialize(sig)
        payload = sig_serialized[0] + chr(sig_serialized[1]) + payload

        payload_hash = keccak256(payload)
        return payload_hash + payload

    def udp_listen(self):
        def receive_ping():
            print "listening..."
            data, addr = self.sock.recvfrom(1024)
            print "received message[", addr, "]"

        return threading.Thread(target = receive_ping)

    def ping(self, endpoint):
        ping = PingNode(self.endpoint, endpoint)
        message = self.wrap_packet(ping)
        print "sending ping."
        self.sock.sendto(message, (endpoint.address.exploded, endpoint.udpPort))
		

Сокет инициализирован в __init__ и указан в udp_listen и ping.

Теперь попробуем запустить send_ping.py:

			$ python send_ping.py
sending ping.
listening...
received message[ ('13.93.211.84', 30303) ]
		

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

 

15355