В этой статье мы разберемся в основных концепциях работы сети 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) ]
Мы получили сообщение от узла начальной загрузки! Отлично.
Перевод статьи «Ethereum from scratch»