Python從頭實現(xiàn)以太坊(一):Ping

Python從頭實現(xiàn)以太坊系列索引:
一、Ping
二、Pinging引導節(jié)點
三、解碼引導節(jié)點的響應
四、查找鄰居節(jié)點
五、類-Kademlia協(xié)議
六、Routing

以太坊是一種可以在區(qū)塊鏈上執(zhí)行代碼的加密貨幣。這個功能允許人們編寫可以自動運行的“智能合約”。大概一年前,一個叫做DAO的智能合約炸鍋了,有人找到方法操縱它去獲取當時價值4100萬美元的ETH。從而導致了網絡的分裂,人們決定分叉區(qū)塊鏈,生成一條從未發(fā)生DAO攻擊的鏈。我一聽說這件事,就尋思“這聽上去真是太有趣了”,但卻沒時間深入了解其運行機制,直到現(xiàn)在。本文是以初學者角度完整實現(xiàn)以太坊協(xié)議系列的第一部分。后面,我計劃把這個系列寫成易消化的小短篇,陸續(xù)發(fā)布,這樣你就不用每天花太多時間去閱讀,但是隨著時間積累,你會對以太坊有更深入地理解。

我假設讀者對Python、git以及諸如TCP和UDP這樣的網絡概念知識(不必很專業(yè))有基本的了解,并且不怕使用原始字節(jié)。除此之外,我會盡量做詳細解釋。今天,我從介紹加密貨幣的概念開始,然后搭建Python開發(fā)環(huán)境,最后在以太坊網絡上實現(xiàn)ping。讓我們開始吧。

加密貨幣的概念

加密貨幣是一種無需中央結算機構參與的,以電子方式存儲和轉移價值的方式。中央結算機構扮演所有交易可信的第三方,它跟蹤所有賬戶,并為每筆交易做更新。在美國,聯(lián)邦儲備系統(tǒng)就是中央結算機構。所有銀行賬戶都在美聯(lián)儲,銀行利用其權威來結算賬戶之間的交易。如果沒有中心化的結算,一方難以向另一方證明他們擁有自己宣稱的東西——他們有可能撒謊。

加密貨幣讓每個人都保存一份賬本記錄,以此解決沒有中央權威機構參與的結算問題。為了讓這些賬本在發(fā)生交易之后保持一致,更新信息和一個可解的數(shù)學問題會廣播給整個網絡,求得解的人始終把信息更新到最長的賬本上。只要網絡超過50%的人按照這個規(guī)則來,這個策略就有效,因為人越多數(shù)學問題解得越快,最終會生成一條最長的鏈。當信息更新到區(qū)塊鏈被所有人共識后,交易就被證明有效且真正發(fā)生。

因此,為了實現(xiàn)加密貨幣,我們需要搞清楚幾件事,節(jié)點是如何對話的,交易是如何存儲的,以及,如何與其他人一起解數(shù)學問題。

建立開發(fā)環(huán)境

(略去了virtualenv的介紹,不知道的話請自行 Google。譯者用的操作系統(tǒng)是OSX+virtualenvwrapper)。

讓我們?yōu)檫@個項目搭建一個Python的虛擬環(huán)境:

$ mkvirtualenv pyeth

注意Python的版本,我用的是2.7.13,不能保證本項目代碼在其他版本下也可以同樣運行。

(pyeth)$ python --version
Python 2.7.13

最后我要做的是用一個叫做cookiecutter的pip庫搭建一個軟件包骨架。

(pyeth)$ pip install cookiecutter

我將使用最小骨架以便能夠進行pip發(fā)布和測試。

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

執(zhí)行時會提示你回答幾個問題。比如項目名稱、作者、版本等。我給這個項目取名為pyeth。之后,我設置了git來跟蹤我的項目代碼。

讓我們安裝nose軟件包用于單元測試。

(pyeth)$ pip install nose

我們可以在軟件包根目錄使用nosetests命令運行tests目錄下的所有測試案例。

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

好了,我想我們準備好開車了。

開始實現(xiàn)

我們先要搞清楚如何與節(jié)點對話,谷歌一下,我找到了以太坊線路協(xié)議,文檔寫到:

運行以太坊客戶端的節(jié)點之間的點對點通訊底層采用DΞVp2p線路協(xié)議。

基本鏈同步

  • 兩個對等端連接打招呼并發(fā)送狀態(tài)消息。狀態(tài)包含總難度(TD)和最佳區(qū)塊的哈希。

于是,我去看了devp2p線路協(xié)議文檔

DΞVp2p節(jié)點通過發(fā)送使用了RLPx(一種加密和認證的傳輸協(xié)議)的消息進行通訊。對等端可以在他們想要的任意TCP端口上自由發(fā)布通告和接受連接,但是,恐怕得在一個默認的30303端口上創(chuàng)建和監(jiān)聽連接。雖然TCP提供了面向連接的介質,但是DΞVp2p節(jié)點以包(packets)為單位通訊。RLPx提供發(fā)送和接收數(shù)據(jù)包的設施。了解RLPx的更多信息,請參考協(xié)議規(guī)范

DΞVp2p節(jié)點通過RLPx發(fā)現(xiàn)協(xié)議DHT找到其他的對等端。對等連接也可以通過將對等端點提供給客戶端特定的RPC API來創(chuàng)建。

所以,我們使用RLPx協(xié)議默認通過30303端口發(fā)送數(shù)據(jù)包。devp2p協(xié)議有兩種不同的模式:使用TCP的主協(xié)議和使用UDP的發(fā)現(xiàn)協(xié)議。今天我只想要搞明白怎樣用發(fā)現(xiàn)協(xié)議DHT找到對等端。DHT是“分布式哈希表(Distributed Hash Table)”的縮寫。你連接到被稱為引導節(jié)點(在BitTorrent中,這些服務器是router.bittorrent.comrouter.utorrent.com)的特定服務器,它們會給你一個對等端的小清單。一旦有了這些對等端,你就可以連接它們,它們又會和你共享它們的對等端,你再連接這些對等端,如此延展,直到你擁有網絡中所有對等端的完整清單。

聽上去已經足夠簡單,但是我們還要讓它再簡單一點。在RLPx規(guī)范最后一個塊引用中有一節(jié)稱為節(jié)點發(fā)現(xiàn)(Node Discovery)的提示。它介紹了如何通過UDP端口30303發(fā)送消息,并明確規(guī)定以下的包結構:

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。

和不同類型的數(shù)據(jù)包:

所有的數(shù)據(jù)結構都是RLP編碼。
包(除了IP頭)的數(shù)據(jù)體大小不能超過1280字節(jié)。
NodeId: 節(jié)點的公鑰。
inline: 屬性被追加到當前列表而不是編碼成列表。
包的最大字節(jié)大小僅標記為參考。
timestamp: 包何時創(chuàng)建(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; //一個節(jié)點的Id。響應節(jié)點將會發(fā)回離目標最近的那些節(jié)點。
    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字節(jié)或16字節(jié)地址 (大小取決于ipv4 vs ipv6)
    uint16_t udpPort; // 大端編碼的16位無符號整型
    uint16_t tcpPort; // 大端編碼的16位無符號整型
}

消息類型用近似C語言的數(shù)據(jù)結構表示。今天,我們可以做的最簡單的事情就是實現(xiàn)PingNode,它由一個version,兩個EndPoint對象和一個timestamp組成。EndPoint對象由一個IP地址,分別用兩個整數(shù)表示的UDP和TCP端口組成。

為了把這些結構體發(fā)送到線路上,我們把它們放進RLP,即遞歸長度前綴編碼(recursive length prefix)。詳情請查看RLP編碼原理RLP。

在任何東西被轉為RLP編碼之前,我們首先需要把結構體轉化為“item”:字符串或多個item的列表(item的定義是遞歸的)。編碼后輸出形式是<LENGTH><BYTES>,因此叫做“遞歸長度前綴”。就如文檔所說,RLP只編碼結構體,把BYTES的解釋留給更高階的協(xié)議。

因為我更愿意實現(xiàn)協(xié)議本身,所以我將使用rlp庫,用它的encodedecode函數(shù)來做RLP編碼。使用pip install rlp將它包含到本地的軟件包中。

我們已經有了發(fā)送PingNode數(shù)據(jù)包所需的一切東西。在下面的Python程序中,我們將創(chuàng)建一個PingNode類,將它打包,并發(fā)給自己。為了打包數(shù)據(jù),我們將從結構體的RLP編碼值開始,添加一個字節(jié)表示結構體的類型,加上加密簽名,最后添加一個用來驗證數(shù)據(jù)包完整性的哈希值。

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

根據(jù)規(guī)范,第一個類是EndPoint類。端口是整數(shù),地址是包含有“.”的格式如“127.0.0.1”。我們把地址傳給ipaddress庫,以便利用其實用函數(shù)將地址轉化為二進制格式,就如我在pack方法中所做的。使用pip install ipaddress安裝這個軟件包。pack方法把對象轉化為字符串列表,供后面rlp.encode使用。在EndPoint的規(guī)范中,地址要求是大端編碼的4字節(jié)數(shù)據(jù),由self.address.packed輸出。對于端口,EndPoint規(guī)范把他們的數(shù)據(jù)類型列為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當做常量字段,填入原始字節(jié)值,后面就不需要再轉化了。在構造函數(shù)中你必須傳入from和to端點對象,正如規(guī)范中羅列的。在pack方法中,我在時間戳上加了60,給這個包額外60秒時間去到達目的地(規(guī)范說收到過去時間的包會被丟棄,以防止重放攻擊)。

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。這個類打開網絡套接字,簽名和散列化消息,然后把消息發(fā)給其他服務器。構造函數(shù)接收EndPoint對象,在網絡空間中指代它自己。發(fā)送數(shù)據(jù)包的時候,服務器用這個對象作為from地址。服務器對象創(chuàng)建的時候,它的私鑰也會被加載——我們需要事先生成。

以太坊使用secp256k1,一個橢圓曲線用于非對稱加密。已實現(xiàn)的Python庫是secp256k1-py。你可以用pip install secp256k1安裝。

為了生成一把私鑰,需要以None為參數(shù)調用PrivateKey的構造函數(shù),然后將其serialize()輸出的內容寫到文件中。

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

我把它跟源文件放一起。如果你使用git的話,記得將它添加到你的.gitignore文件中,以免一不小心發(fā)布出去。

wrap_packet方法將包編碼為:

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

首先要做的事情是把包類型添加到RLP編碼的包數(shù)據(jù)前。然后用私鑰的ecdsa_sign_recoverable函數(shù)簽名已經散列的數(shù)據(jù)體。raw參數(shù)被設置為True,因為我們已經自己做了散列。然后我們序列化簽名并把它添加到之前的數(shù)據(jù)體前。簽名序列化后是一個元組對象,其第二個元需要用chr轉化為字符串。最后,散列化整個數(shù)據(jù)體,把獲得的哈希值添加到前面,數(shù)據(jù)包就可以準備發(fā)送了。

你可能已經注意到,我們還沒有定義keccak256函數(shù)。以太坊使用叫做keccak-256的非標準sha3算法。已經實現(xiàn)的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()

這個函數(shù)很簡單。

回到PingServer。第二個函數(shù)udp_listen,監(jiān)聽流入的傳輸。它創(chuàng)建socket對象,并將它綁定到服務器端點的UDP端口上。然后我在函數(shù)里面定義了receive_ping函數(shù),它的功能是在這個套接字上監(jiān)聽流入的數(shù)據(jù),打印傳輸?shù)膽{證地址并返回。函數(shù)最后返回一個Thread線程對象,receive_ping將在這個線程中運行,這樣我們就可以監(jiān)聽接收的同時發(fā)送pings了。

最后的ping方法接收一個目的地端點,為它創(chuàng)建一個PingNode對象,用wrap_packert將這個對象轉化成消息,最后用UDP協(xié)議將消息發(fā)送出去。

send_ping.py,現(xiàn)在我們可以啟動一個腳本來發(fā)送一些包。

# -*- 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)

當我們執(zhí)行這段代碼的時候,我們可以看到:

(pyeth)$ python send_ping.py
sending ping
listening...
received message[ ('127.0.0.1', 58974) ]

我已經成功的和自己打招呼。我還沒有連接任何的引導節(jié)點,那是下一篇帖子計劃做的。請繼續(xù)關注本系列的第二部分。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容