Python从头实现以太坊(一):Ping
以太坊是一种可以在区块链上执行代码的加密货币。这个功能允许人们编写可以自动运行的“智能合约”。大概一年前,一个叫做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.com
和router.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
库,用它的encode
和decode
函数来做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_type
和version
当做常量字段,填入原始字节值,后面就不需要再转化了。在构造函数中你必须传入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) ]
我已经成功的和自己打招呼。我还没有连接任何的引导节点,那是下一篇帖子计划做的。请继续关注本系列的第二部分。