程序猿阵线联盟-汇总各类技术干货程序员

Python从头实现以太坊(一):Ping

2017-08-18  本文已影响2494人  JonHuang

以太坊是一种可以在区块链上执行代码的加密货币。这个功能允许人们编写可以自动运行的“智能合约”。大概一年前,一个叫做DAO的智能合约炸锅了,有人找到方法操纵它去获取当时价值4100万美元的ETH。从而导致了网络的分裂,人们决定分叉区块链,生成一条从未发生DAO攻击的链。我一听说这件事,就寻思“这听上去真是太有趣了”,但却没时间深入了解其运行机制,直到现在。本文是以初学者角度完整实现以太坊协议系列的第一部分。后面,我计划把这个系列写成易消化的小短篇,陆续发布,这样你就不用每天花太多时间去阅读,但是随着时间积累,你会对以太坊有更深入地理解。

我假设读者对Python、git以及诸如TCP和UDP这样的网络概念知识(不必很专业)有基本的了解,并且不怕使用原始字节。除此之外,我会尽量做详细解释。今天,我从介绍加密货币的概念开始,然后搭建Python开发环境,最后在以太坊网络上实现ping。让我们开始吧。

加密货币的概念

加密货币是一种无需中央结算机构参与的,以电子方式存储和转移价值的方式。中央结算机构扮演所有交易可信的第三方,它跟踪所有账户,并为每笔交易做更新。在美国,联邦储备系统就是中央结算机构。所有银行账户都在美联储,银行利用其权威来结算账户之间的交易。如果没有中心化的结算,一方难以向另一方证明他们拥有自己宣称的东西——他们有可能撒谎。

加密货币让每个人都保存一份账本记录,以此解决没有中央权威机构参与的结算问题。为了让这些账本在发生交易之后保持一致,更新信息和一个可解的数学问题会广播给整个网络,求得解的人始终把信息更新到最长的账本上。只要网络超过50%的人按照这个规则来,这个策略就有效,因为人越多数学问题解得越快,最终会生成一条最长的链。当信息更新到区块链被所有人共识后,交易就被证明有效且真正发生。

因此,为了实现加密货币,我们需要搞清楚几件事,节点是如何对话的,交易是如何存储的,以及,如何与其他人一起解数学问题。

建立开发环境

(略去了virtualenv的介绍,不知道的话请自行 Google。译者用的操作系统是OSX+virtualenvwrapper)。

让我们为这个项目搭建一个Python的虚拟环境:

$ mkvirtualenv pyeth

注意Python的版本,我用的是2.7.13,不能保证本项目代码在其他版本下也可以同样运行。

(pyeth)$ python --version
Python 2.7.13

最后我要做的是用一个叫做cookiecutter的pip库搭建一个软件包骨架。

(pyeth)$ pip install cookiecutter

我将使用最小骨架以便能够进行pip发布和测试。

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

执行时会提示你回答几个问题。比如项目名称、作者、版本等。我给这个项目取名为pyeth。之后,我设置了git来跟踪我的项目代码。

让我们安装nose软件包用于单元测试。

(pyeth)$ pip install nose

我们可以在软件包根目录使用nosetests命令运行tests目录下的所有测试案例。

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

好了,我想我们准备好开车了。

开始实现

我们先要搞清楚如何与节点对话,谷歌一下,我找到了以太坊线路协议,文档写到:

运行以太坊客户端的节点之间的点对点通讯底层采用ÐΞVp2p线路协议

基本链同步

  • 两个对等端连接打招呼并发送状态消息。状态包含总难度(TD)和最佳区块的哈希。

于是,我去看了devp2p线路协议文档

ÐΞVp2p节点通过发送使用了RLPx(一种加密和认证的传输协议)的消息进行通讯。对等端可以在他们想要的任意TCP端口上自由发布通告和接受连接,但是,恐怕得在一个默认的30303端口上创建和监听连接。虽然TCP提供了面向连接的介质,但是ÐΞVp2p节点以包(packets)为单位通讯。RLPx提供发送和接收数据包的设施。了解RLPx的更多信息,请参考协议规范

ÐΞVp2p节点通过RLPx发现协议DHT找到其他的对等端。对等连接也可以通过将对等端点提供给客户端特定的RPC API来创建。

所以,我们使用RLPx协议默认通过30303端口发送数据包。devp2p协议有两种不同的模式:使用TCP的主协议和使用UDP的发现协议。今天我只想要搞明白怎样用发现协议DHT找到对等端。DHT是“分布式哈希表(Distributed Hash Table)”的缩写。你连接到被称为引导节点(在BitTorrent中,这些服务器是router.bittorrent.comrouter.utorrent.com)的特定服务器,它们会给你一个对等端的小清单。一旦有了这些对等端,你就可以连接它们,它们又会和你共享它们的对等端,你再连接这些对等端,如此延展,直到你拥有网络中所有对等端的完整清单。

听上去已经足够简单,但是我们还要让它再简单一点。在RLPx规范最后一个块引用中有一节称为节点发现(Node Discovery)的提示。它介绍了如何通过UDP端口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 // 可用值 [1,4]
    packet-data: RLP编码的列表。包属性按它们被定义的顺序序列化。见后面的packet-data。

和不同类型的数据包:

所有的数据结构都是RLP编码。
包(除了IP头)的数据体大小不能超过1280字节。
NodeId: 节点的公钥。
inline: 属性被追加到当前列表而不是编码成列表。
包的最大字节大小仅标记为参考。
timestamp: 包何时创建(UNIX时间戳)。

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。响应节点将会发回离目标最近的那些节点。
    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; // 大端编码的4字节或16字节地址 (大小取决于ipv4 vs ipv6)
    uint16_t udpPort; // 大端编码的16位无符号整型
    uint16_t tcpPort; // 大端编码的16位无符号整型
}

消息类型用近似C语言的数据结构表示。今天,我们可以做的最简单的事情就是实现PingNode,它由一个version,两个EndPoint对象和一个timestamp组成。EndPoint对象由一个IP地址,分别用两个整数表示的UDP和TCP端口组成。

为了把这些结构体发送到线路上,我们把它们放进RLP,即递归长度前缀编码(recursive length prefix)。详情请查看RLP编码原理RLP

在任何东西被转为RLP编码之前,我们首先需要把结构体转化为“item”:字符串或多个item的列表(item的定义是递归的)。编码后输出形式是<LENGTH><BYTES>,因此叫做“递归长度前缀”。就如文档所说,RLP只编码结构体,把BYTES的解释留给更高阶的协议。

因为我更愿意实现协议本身,所以我将使用rlp库,用它的encodedecode函数来做RLP编码。使用pip install rlp将它包含到本地的软件包中。

我们已经有了发送PingNode数据包所需的一切东西。在下面的Python程序中,我们将创建一个PingNode类,将它打包,并发给自己。为了打包数据,我们将从结构体的RLP编码值开始,添加一个字节表示结构体的类型,加上加密签名,最后添加一个用来验证数据包完整性的哈希值。

pyeth/discovery.py

# -*- coding: utf8 -*-
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使用。在EndPoint的规范中,地址要求是大端编码的4字节数据,由self.address.packed输出。对于端口,EndPoint规范把他们的数据类型列为uint16_t。所以我使用struct.pack方法,并用了格式字符串>H,意思是大端无符号16位整型,就如Python文档里所说。

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_typeversion当做常量字段,填入原始字节值,后面就不需要再转化了。在构造函数中你必须传入from和to端点对象,正如规范中罗列的。在pack方法中,我在时间戳上加了60,给这个包额外60秒时间去到达目的地(规范说收到过去时间的包会被丢弃,以防止重放攻击)。

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)


    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对象,在网络空间中指代它自己。发送数据包的时候,服务器用这个对象作为from地址。服务器对象创建的时候,它的私钥也会被加载——我们需要事先生成。

以太坊使用secp256k1,一个椭圆曲线用于非对称加密。已实现的Python库是secp256k1-py。你可以用pip install secp256k1安装。

为了生成一把私钥,需要以None为参数调用PrivateKey的构造函数,然后将其serialize()输出的内容写到文件中。

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

我把它跟源文件放一起。如果你使用git的话,记得将它添加到你的.gitignore文件中,以免一不小心发布出去。

wrap_packet方法将包编码为:

hash || signature || packet-type || packet-data

首先要做的事情是把包类型添加到RLP编码的包数据前。然后用私钥的ecdsa_sign_recoverable函数签名已经散列的数据体。raw参数被设置为True,因为我们已经自己做了散列。然后我们序列化签名并把它添加到之前的数据体前。签名序列化后是一个元组对象,其第二个元需要用chr转化为字符串。最后,散列化整个数据体,把获得的哈希值添加到前面,数据包就可以准备发送了。

你可能已经注意到,我们还没有定义keccak256函数。以太坊使用叫做keccak-256的非标准sha3算法。已经实现的Python库是pysha3。使用pip install pysha3安装。

pyeth/crypto.py, 我们定义keccak256

# -*- coding: utf8 -*-
import hashlib
import sha3

## 以太坊使用keccak-256哈希算法
def keccak256(s):
    k = sha3.keccak_256()
    k.update(s)
    return k.digest()

这个函数很简单。

回到PingServer。第二个函数udp_listen,监听流入的传输。它创建socket对象,并将它绑定到服务器端点的UDP端口上。然后我在函数里面定义了receive_ping函数,它的功能是在这个套接字上监听流入的数据,打印传输的凭证地址并返回。函数最后返回一个Thread线程对象,receive_ping将在这个线程中运行,这样我们就可以监听接收的同时发送pings了。

最后的ping方法接收一个目的地端点,为它创建一个PingNode对象,用wrap_packert将这个对象转化成消息,最后用UDP协议将消息发送出去。

send_ping.py,现在我们可以启动一个脚本来发送一些包。

# -*- coding: utf8 -*-
from pyeth.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', 58974) ]

我已经成功的和自己打招呼。我还没有连接任何的引导节点,那是下一篇帖子计划做的。请继续关注本系列的第二部分

参考:https://ocalog.com/post/10/

上一篇下一篇

猜你喜欢

热点阅读