Написать пост

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

Аватарка пользователя Ihor.m !

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

Обложка поста Сетевой протокол 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:

Одноранговая связь между узлами, на которых запущенны клиенты 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, задавая следующую структуру пакетов:

			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 определяется следующим образом:
Для одного байта, значение которого находится в диапазоне [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:

			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() в файл:

			>>> from secp256k1 import PrivateKey 
>>> k = PrivateKey(None) 
>>> f = open("priv_key", 'w') 
>>> f.write(k.serialize()) >>> f.close()
		

Помещаем файл в каталог с проектом. Не забудьте добавить его в .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.

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

send_ping.py:

			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 использует узлы начальной загрузки, адреса которых записаны в исходном коде.

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) ]
		

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

 

Следите за новыми постами
Следите за новыми постами по любимым темам
16К открытий16К показов