使用Golang来Ping NEO 网络
虽然很多的NEO核心库都是用C#或Python编写的,但在本教程中,我们将使用Golang语言。通信基础对任何语言而言都是相同的。
NEO协议定义了一个区块头和一个payload。每个报文都需要按照特定的格式发送,即24字节长的协议头以及相应的payload:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 魔法数: 0x00746E41 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ +
| 命令: 12 字节 |
+ +
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-++-+-++-+-++-+-++-+-+-+-+-+-+-+-+
| Payload 长度 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-++-+-++-+-++-+-++-+-+-+-+-+-+-+-+
| Payload 校验和 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-++-+-++-+-++-+-++-+-+-+-+-+-+-+-+
| |
| Payload |
与其他NEO节点的通信是通过TCP或WebSockets进行的。使用WebSockets的好处是,你可以通过Web浏览器连接到NEO节点。不过在本教程中,我们使用的是TCP协议。
协议头包含一个4字节的魔法数,代表的是“Ant”(小端存储),这个名称是在NEO品牌重塑之前使用的。测试网使用的魔法数有些许不同,从而来区分主网和测试网的消息。
练习1: 请回答以下问题:
尽管兼容性非常重要,而更改协议的格式可能会对客户端的运行产生影响,不过理论上来说,可以对协议头做哪些优化呢?
为了加入到NEO分布式系统,首先,我们需要先建立一个到NEO节点的连接。由于我们会用到ping/pong命令,这个命令在2019年4月4日发布的NEO v2.10.1版本中已经实现了,所以我们需要确保我们的节点运行了这个版本。可以点击此处 http://monitor.cityofzion.io/查看运行了该版本的NEO节点列表。在编写本文档的时候,以下几个节点使用了该版本:
- node1.plutolo.gy:10333
- seed.neoeconomy.io:10333
- seed10.ngd.network:10333
请注意,节点可能会随时下线,上面这几个节点也可能不会再上线了。可以替换为运行了v2.10.1以上版本的任意的NEO节点。
这些节点必须能够在相应的端口上提供很好的可访问性。主网的默认端口是10333。如果节点开启了防火墙,可以使用UPnP配置路由从而接受发送到节点的传入连接请求。目前不支持NAT-PMP。在本教程中,我们不会接受任何的传入连接,这意味着不会有任何NAT问题。
让我们来实现这个协议。我们需要做的是:首先设置魔法数,然后设置紧跟着payload长度的命令。之后计算校验和并附加payload信息。现在,我们可以将报文发送到一个NEO节点。听起来很简单,对吧?
小端和大端
对于每个协议,都必须定义使用的是哪种字节顺序。NEO中使用的是小端存储。因此,魔法数字节的编码如下所示:
0x00746E41 -> [41][6E][74][00]
大端编码看起来是这样的:[00][74][6E][41]。许多编程语言都提供了相应的转换工具,Golang中的操作是这样的:
binary.LittleEndian.PutUint32(b[0:], 0x00746E41)
虽然大多数CPU使用的是小端编码,但是网络协议(如TCP或UDP)使用的是大端序。有关端序的更多信息,请查看https://en.wikipedia.org/wiki/endianness。在本教程中,我们使用的是小端序,因为NEO中的大部分数据都是使用小端编码的,并且我们不会涉及到异常问题。
校验和
协议头中的校验和字段是针对payload计算出的校验和。虽然TCP在其payload上也有校验和,但TCP的校验和只有16位。NEO中的payload校验和是对payload进行两次SHA256哈希之后取最终结果的前4字节(32位)。在Golang中,校验和的计算方法如下所示:
tmp := sha256.Sum256(payload)
hash := sha256.Sum256(tmp[:])
...
copy(b[20:], hash[0:4])
如果校验和不匹配,节点应该忽略这条消息。
—
练习2: 编写用于协议头的编码器和解码器。使用底下附录A中 “快速而粗糙”的模板,实现以下两个功能:encodeheader(cmd string,payload[]byte)[]byte
,其中返回的数组是组合了payload的协议头。编码器应该返回可以发送到链路的完整的字节数组。另一个是decodeheader(b[]byte)(uint32,uint32)
,其中第一个返回值是payload的长度,第二个值是校验和。
提示:魔法数可以编码为:
binary.LittleEndian.PutUint32(b[0:], 0x00746E41)
解码为:
fmt.Printf("magic: 0x%x\n", binary.LittleEndian.Uint32(b))
Payload协议
实现了NEO协议头的编码和解码功能之后,我们可以专注于payload了。NEO协议支持以下这些命令,不过并不是所有命令都具有payload。
*version - 节点版本的相关信息,包括版本号
*verack - 成功接收到版本信息时,发送应答信息verack(无payload)
*getaddr - 请求活跃的NEO节点列表
*addr - 对getaddr请求的响应
*getBlocks - 请求区块
*block - 对getblocks或getdata的响应
*consensus - 对getdata的响应
*filteradd - 向布隆过滤器添加数据
*filterclear - 清除布隆过滤器
*filterload - 创建带有初始设置的布隆过滤器。布隆过滤器只返回我们感兴趣的交易的区块
*getdata - 请求特定对象。交易、区块、默克尔区块或共识报文中有响应信息。
*GetHeaders - 请求区块头
*headers - 对getHeaders的响应
*inv - 发送有关交易、区块或共识的信息
*mempool - 请求内存池中已验证的交易。inv报文中带有响应信息
*ping - 检查节点是否处于活跃状态
*pong - 对ping消息的响应
*tx - 对getData消息的响应
要编写NEO ping报文,必须知道待发布的命令是有严格的顺序要求的。对于每个连接,version及其应答verack需要进行两次握手通信:一次从你的节点发出,一次从远程节点发出。以下流程图显示了这个命令序列:
+-------+ +-------+
| NEO1 | | NEO2 |
+-------+ +-------+
| |
| 命令version |
|-------------------->|
| |
| 命令version |
|<--------------------|
| |
| 命令verack |
|-------------------->|
| |
| 命令verack |
|<--------------------|
| |
| 命令ping |
|-------------------->|
| |
| 命令pong |
|<--------------------|
| |
对于NEO ping,我们只使用带有payload的version和ping命令。verack命令不带有任何的payload,这里不做说明。这两个命令的payload定义如下:
“version”命令
*version(uint32)指定协议的版本,当前版本为0。
*Services(uint64)- 指定服务,当前设置为1
*Timestamp(uint32)- 自1970年1月1日起经过的时间(单位:秒)
*Port(uint16)- 监听的端口号,如果节点不处理传入连接,可以设为0
*Nonce(uint32)- 随机数
*Useragent 最多1024 字节- 本教程使用1字节长,字符串的格式。不要超过253字节(0xfd)
*Blockheight(uint32)- 区块高度,你的区块高度可以是0
*Relay(uint8)- 如果是中继节点,则设置为false(0)
“ping”命令
*Blockheight(uint32)- 区块高度,你的区块高度可以是0
*Timestamp(uint32)- 自1970年1月1日起经过的时间(单位:秒)
*Nonce(uint32)- 随机数
协议
利用这些信息,我们就可以实现NEO节点的ping命令。首先,我们需要编写针对version/ping的payload的编码器和解码器。
练习3: 编写用于ping/version命令的编码器和解码器。使用https://github.com/tbocek/vss-neo-tutorial/blob/master/neo-ping-template.go提供的模板实现以下两个功能:一个是encodeversion(useragent string)[]byte
,encodeping()[]byte
,decodeversion(b[]byte)string
,返回用户代理;另一个是 decodePing(b[]byte)
。在解码器中将相关信息输出到控制台。
提示:timestamp可以编码为:
binary.LittleEndian.PutUint32(b[12:], uint32(time.Now().Unix()))
解码为:
fmt.Printf("time: %v\n", time.Unix(int64(binary.LittleEndian.Uint32(b[12:])), 0))
进行组装
首先,连接到支持ping/pong命令的NEO节点。后面我们会检查版本的正确性
func main() {
remote, err := net.Dial("tcp", "node1.plutolo.gy:10333") // check: http://monitor.cityofzion.io/
if err != nil {
panic(err)
}
defer remote.Close()
fmt.Println("Conneced to: %v", remote.RemoteAddr())
现在我们发送version信息到远程的NEO节点。
payloadVersion := encodeVersion("/Our NEO client:0.0.1/")
packetVersion := encodeHeader("version", payloadVersion)
n, err := remote.Write(packetVersion)
if err != nil {
panic(err)
}
fmt.Printf("wrote version packet: %v, %d\n", packetVersion, n)
发送了version,现在我们可以等远程主机发送version。实际上,一些客户端也会在连接建立后立即发送version信息。
//获取远程节点的版本,并显示
read := make([]byte, 24)
n, err = io.ReadFull(remote, read) //读取报头
plen,rcvChecksum := decodeHeader(read)
read = make([]byte, plen)
n, err = io.ReadFull(remote, read) //读取payload
userAgent := decodeVersion(read)
在接收到NEO节点的版本信息时,我们还会额外检查校验和是否匹配。校验和是payload的两次SHA256哈希后结果的前4个字节。
tmp := sha256.Sum256(read)
hash := sha256.Sum256(tmp[:])
checksum := binary.LittleEndian.Uint32(hash[0:4])
fmt.Printf("read version payload: %v, %d\n", read, n)
if rcvChecksum != checksum {
panic(errors.New("checksum mismatch in version!"))
}
由于ping/pong是最近才实现的,因此我们需要确保我们的版本是否支持这个功能。看起来版本使用了语义版本控制。但是,Python实现使用了不同的版本控制,因此我们还应该检查使用了哪个用户代理。由于还没有高于v2.10.1的实现版本,因此我们只需检查这个版本(快速而粗糙:)。
//检查版本是否正确
start := strings.Index(userAgent, ":")
end := strings.Index(userAgent[start:], "/")
if start < 0 && end < 0 {
panic(errors.New(fmt.Sprintf("cannot parse version in %s", userAgent)))
}
semVer := userAgent[start+1:start+end]
fmt.Printf("parsed semver: %v\n", semVer)
v1, err := version.NewVersion(semVer)
min, err := version.NewVersion("2.10.1")
if v1.LessThan(min) {
panic(errors.New(fmt.Sprintf("%s is less than %s", v1, min)))
}
由于我们发送了一个version数据包,所以我们需要接收一个verack包,而又由于我们也收到了version包,所以我们还需要发送回verack:
////////// 获取version,发送verack
packetVerack := encodeHeader("verack", []byte{})
n, err = remote.Write(packetVerack);
if err != nil {
panic(err)
}
///////// 等待verack 确认
read = make([]byte, 24)
n, err = io.ReadFull(remote, read)
plen, rcvChecksum = decodeHeader(read)
fmt.Printf("read verack array: %v, %d\n", read, plen)
if rcvChecksum != 3806393949 {
panic(errors.New("checksum mismatch in verack!"))
}
在version/verack之后,现在我们可以发送ping了。没有这些versions信息,就不能发送任何命令
/////// 发送ping
packet2 := encodeHeader("ping", encodePing())
n, err = remote.Write(packet2)
if err != nil {
panic(err)
}
fmt.Printf("wrote ping: %v, %d\n", packet2, n)
发送ping之后,我们可以期待接收到一个pong报文。
//////// 收到pong
read = make([]byte, 36)
_, err = io.ReadFull(remote, read)
_, rcvChecksum = decodeHeader(read)
decodePing(read[24 : 24+12])
fmt.Printf("read array: %v\n", read)
tmp = sha256.Sum256(read[24 : 24+12])
hash = sha256.Sum256(tmp[:])
checksum = binary.LittleEndian.Uint32(hash[0:4])
if rcvChecksum != checksum {
panic(errors.New("checksum mismatch in pong!"))
}
remote.Close()
}
练习4: 利用模板将编码器/解码器代码与main函数进行合并,并在主网上运行。你能看到什么? —
在成功发送NEO ping并接收到返回的pong之后,我们可以发送getaddr
并获取更多NEO网络中的节点信息。这样你就可以使用已经建立的连接了。对于P2P和分布式系统来说,寻找其他对等节点至关重要,因为节点随时可能下线,需要连接到其他节点。
练习5: 发送完ping报文后,发送getaddr
报文。分析输出。
提示:输出包含一个IP地址列表(16字节长的IPv6/4,2字节长的端口),这些地址是经过大端编码的。数据还包含一个service标志位(设置为1)和一个时间戳timestamp。
现在,你已经成功地实现了一个NEO客户端,它可以建立一个到其他NEO节点的连接,发送ping命令,接收pong命令以及获取更多节点的列表。
参考链接和文献
- https://medium.com/neoresearch/understanding-neo-network-in-five-pictures-e51b7c19d6e0
- https://github.com/neo-ngd/NEO-Tutorial
- https://en.wikipedia.org/wiki/NEO_(cryptocurrency)
- https://github.com/neo-project
- https://docs.neo.org/en-us/network/network-protocol.html
- https://github.com/O3Labs/neo-transaction-watcher/blob/master/neotx/network.go
##附录A:快速而粗糙的模板
package main
import (
"bytes"
//"bytes"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/hashicorp/go-version"
"io"
//"math/rand"
"net"
"strings"
//"time"
)
func main() {
remote, err := net.Dial("tcp", "node1.plutolo.gy:10333") //check: http://monitor.cityofzion.io/
if err != nil {
panic(err)
}
defer remote.Close()
fmt.Println("Conneced to: %v", remote.RemoteAddr())
payloadVersion := encodeVersion("/The HSR NEO client:0.0.1/")
packetVersion := encodeHeader("version", payloadVersion)
n, err := remote.Write(packetVersion)
if err != nil {
panic(err)
}
fmt.Printf("wrote version packet: %v, %d\n", packetVersion, n)
//从远程节点获得version,并加以显示
read := make([]byte, 24)
n, err = io.ReadFull(remote, read) //读取报头
plen, rcvChecksum := decodeHeader(read)
read = make([]byte, plen)
n, err = io.ReadFull(remote, read) //读取payload
userAgent := decodeVersion(read)
tmp := sha256.Sum256(read)
hash := sha256.Sum256(tmp[:])
checksum := binary.LittleEndian.Uint32(hash[0:4])
fmt.Printf("read version payload: %v, %d\n", read, n)
if rcvChecksum != checksum {
panic(errors.New("checksum mismatch in version!"))
}
//检验版本是否正确
start := strings.Index(userAgent, ":")
end := strings.Index(userAgent[start:], "/")
if start < 0 && end < 0 {
panic(errors.New(fmt.Sprintf("cannot parse version in %s", userAgent)))
}
semVer := userAgent[start+1 : start+end]
fmt.Printf("parsed semver: %v\n", semVer)
v1, err := version.NewVersion(semVer)
min, err := version.NewVersion("2.10.1")
if v1.LessThan(min) {
panic(errors.New(fmt.Sprintf("%s is less than %s", v1, min)))
}
////////// 收到version, 发送verack
packetVerack := encodeHeader("verack", []byte{})
n, err = remote.Write(packetVerack)
if err != nil {
panic(err)
}
///////// 等到verack 确认
read = make([]byte, 24)
n, err = io.ReadFull(remote, read)
plen, rcvChecksum = decodeHeader(read)
fmt.Printf("read verack array: %v, %d\n", read, plen)
if rcvChecksum != 3806393949 {
panic(errors.New("checksum mismatch in verack!"))
}
/////// 发送ping
packet2 := encodeHeader("ping", encodePing())
n, err = remote.Write(packet2)
if err != nil {
panic(err)
}
fmt.Printf("wrote ping: %v, %d\n", packet2, n)
//////// 接收pong
read = make([]byte, 36)
_, err = io.ReadFull(remote, read)
_, rcvChecksum = decodeHeader(read)
decodePing(read[24 : 24+12])
fmt.Printf("read array: %v\n", read)
tmp = sha256.Sum256(read[24 : 24+12])
hash = sha256.Sum256(tmp[:])
checksum = binary.LittleEndian.Uint32(hash[0:4])
if rcvChecksum != checksum {
panic(errors.New("checksum mismatch in pong!"))
}
remote.Close()
}
func encodeHeader(cmd string, payload []byte) []byte {
b := make([]byte, 24+len(payload))
binary.LittleEndian.PutUint32(b[0:], 0x00746E41)
//此处进行编码
copy(b[4:], cmd)
//payload长度
binary.LittleEndian.PutUint32(b[16:], uint32(len(payload)))
//payload校验和
tmp := sha256.Sum256(payload)
hash := sha256.Sum256(tmp[:])
copy(b[20:], hash[0:4])
//payload
copy(b[24:], payload)
return b
}
func encodeVersion(userAgent string) []byte {
userAgentLen := len(userAgent)
b := make([]byte, 27+userAgentLen+1)
// 此处进行编码
b[27+userAgentLen] = 0
return b
}
func encodePing() []byte {
b := make([]byte, 12)
// 此处进行编码
return b
}
func decodeHeader(b []byte) (uint32, uint32) {
fmt.Printf("magic: 0x%x\n", binary.LittleEndian.Uint32(b))
fmt.Printf("command: %v\n", string(bytes.Trim(b[4:16], "\x00")))
len := binary.LittleEndian.Uint32(b[16:])
fmt.Printf("payload len: %d\n", len)
checksum := binary.LittleEndian.Uint32(b[20:])
fmt.Printf("checksum: 0x%x\n", checksum)
return len, checksum
}
func decodeVersion(b []byte) string {
// 此处进行解码
//返回userAgent
return ""
}
func decodePing(b []byte) {
// 此处进行解码
}