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

作者: JonHuang | 来源:发表于2017-08-18 00:28 被阅读2494次

    以太坊是一种可以在区块链上执行代码的加密货币。这个功能允许人们编写可以自动运行的“智能合约”。大概一年前,一个叫做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/

    相关文章

      网友评论

      本文标题:Python从头实现以太坊(一):Ping

      本文链接:https://www.haomeiwen.com/subject/rrubrxtx.html