# P2P网络

# 节点发现协议

节点发现主要负责发现网络中的其它运行Bytom协议节点。Bytom实现了类Kademlia的DHT存储有关网络中节点的信息。协议维护了一份节点路由表,节点路由表依据距离把网络中节点信息存储到表中不同的bucket中。

# 节点ID及距离

网络中每个节点都有一个唯一ID,节点第一次启动时都会产生ED25519公私钥对,并保存在本地,其中公钥作为节点的ID,长度32byte。 两个节点之间的距离是以下公式计算而来,所以节点之间的距离代表逻辑距离,而不是节点间的真实物理距离。

distance(n1, n2) = hash(IDn1) + hash(IDn2)

# 节点路由表

bucket index node node cache
k-bucket 0 距离[1,2) 距离[1,2)
k-bucket 1 距离[2,4) 距离[2,4)
k-bucket 2 距离[4,8) 距离[4,8)
k-bucket i 距离 [2^i,2^(i+1) 距离 [2^i,2^(i+1)
k-bucket 255 距离[2^255, 2^(255+1)) 距离[2^255, 2^(255+1))

节点路由表

根据Kademilia协议节点需要保留其邻居节点的信息。邻居节点存储在由“k-buckets”组成的路由表中。对于每个bucketi,存储距离自己“2^i”和“2^(i + 1)”之间的距离节点。 协议使用“k = 16”,即每个k-bucket最多存储16个节点。bucket中节点按上次查看的时间排序 - 最近看到的节点位于bucket头部,最先看到的在bucket尾部。 每当遇到新节点N1时,它就可以插入相应的桶中。如果桶中包含少于“k”的节点,则可以简单地将N1添加到桶头部。如果该桶已经包含k`个节点,则取桶中最先看到的节点N2,需要通过发送ping数据包重新验证节点是否在线。如果没有从N2收到答复,那就是被认为是无效的,N2被移除bucket并且N1被添加到桶的前面。 否则将N2加入cache中,如果cache也满了,则把最先放入cache中的节点N3删除。当有节点掉线时则会把cache中的最新节点放入bucket中。

路由表刷新

节点通过定期迭代查找距离targetID距离近的节点来更新路由表。工作过程如下:

a. 随机生成目标节点Id,记为TargetId,从1开始记录发现次数和刷新时间

b. 在当前节点的K桶里查找与目标节点最近的16个节点

c. 向b中得到的每个节点发送findnode命令,接收到每个节点传回的neighbours节点

d. 对c返回的每个节点进行ping-pong测试然后更新到本地k桶

如果一轮FindNode查询无法返回比已知最近的节点更近的节点,启动器将FindNode重新发送到k个最近节点中未查找过得节点。

协议消息及编码

协议实现了Kademlia协议的两组节点发现命令:

ping<------------->pong
findnode <---------->neighbors

节点发现消息作为UDP数据报发送。数据包的最大大小为1280字节。

packet = packet-header || packet-data

每个数据包都以header开头:

packet-header = hash ||nodeID ||signature||packet-type
hash = sha3(nodeID || signature || packet-type || packet-data)
signature = sign(packet-type || packet-data)

hash 字段使在同一个UDP端口上运行多个协议时可识别。 packet-type 是定义消息类型。

Ping Packet(0x01)

packet-data = [version,from,to,expiration]
from = [sender-ip,sender-udp-port,sender-tcp-port]
to = [recipient-ip,recipient-udp-port,0]

expiration 字段是绝对的UNIX时间戳。包含过去时间戳的数据包会因为过期而无法处理。 收到ping数据包后,收件人应使用pong数据包进行回复。

Pong Packet(0x02)

packet-data = [to,ping-hash,expiration]

Pong是对ping的回复。 ping-hash 应该等于相应ping包的hash。应该忽略没有包含最新ping-hash的pong数据包,因为不是对最新ping消息做出的响应。

FindNode Packet(0x03)

packet-data = [target,expiration]

FindNode 数据包请求接近target的节点的信息。收到FindNode后,收件人应回复_Neighbors_数据包,在其本地表中找到最接近target的16个节点并返回。

Neighbors Packet(0x04)

packet-data = [node,expiration]
nodes = [[ip,udp-port,tcp-port,node-id],...]

Neighbors是对FindNode的回复。

# 数据同步协议

Bytom网络数据同步协议栈如下图所示。协议栈基于tcp/ip,Encryption完成数据的加密传输,Wire Protocol完成数据序列化,最上层为同步协议。

Tx Sync/Block Sync/Fast Sync/Spv
Wire Protocol
Encryption
TCP/IP

数据同步首先会在节点之间建立连接,建立连接后会对连接进行加密处理,区块,交易数据序列化为二进制数据流通过加密通道传递给其它节点。

# 建立加密连接

建立多路复用连接 MConnection 是在单个tcp连接上支持多个独立流传输的多路复用连接,并且每个流提供了单独的服务质量保证。每个流称为_Channel_,每个_Channel_具有全局唯一的_ byte id _。每个_channel_也具有决定服务质量的相对优先级。byte id 和每个Channel的相对优先级在初始化时配置。 MConnection支持三种数据包类型:

  • Ping
  • Pong
  • Msg

Ping和Pong ping和pong消息向连接写入单个字节;分别为0x1和0x2。 当我们在pingTimeout周期没有及时收到MConnection上的任何消息时,我们发送一条ping消息。 当在MConnection上收到ping消息时,会发送一个pong作为响应。如果在ping之后没有及时收到pong消息,则节点将断开连接。

Msg 通道中的消息被切割成较小的_msgPacket_ 以进行多路复用。

type msgPacket struct {
   ChannelID byte
   EOF       byte // 1 means message ends here.
   Bytes     []byte
}

go-wire进行序列化,并以0x3为前缀。接收到的一组数据包的“字节”被附加在一起直到收到带有EOF = 1 的数据包,然后完整的序列化消息由相应_channel_的_onReceive_函数处理。

多路复用 消息从_sendRoutine_ 发送,它循环在select状态上并发送ping,pong或msg消息。该批数据消息可以包括来自多个_channel_的消息。消息字节排队等待在各自的通道中发送,每个通道一次取一个未发送的消息。从最近发送的字节与信道优先级的比最低的信道选择一个消息发送。

发送消息 发送消息有两种方法:

func (m MConnection) Send(chID byte, msg interface{}) bool {}
func (m MConnection) TrySend(chID byte, msg interface{}) bool {}

Send(chID,msg)是一个阻塞调用,等待_msg_成功排队到给定id字节_chID_的通道。消息_msg_被序列化使用_wire_子模块的WriteBinary()反射函数。 TrySend(chID,msg)是一个非阻塞调用,它将消息_msg_排入_chID_通道如果队列未满;否则立即回_false_。 Send()TrySend()对每个_Peer_可见。

Peer 每个_peer_都有一个_MConnection_实例,并含有其他信息,例如是否是outbound(主动拨号其它节点),关于节点的各种身份信息,以及reactor使用的其他更高级别的线程安全数据。

Switch/Reactor Switch 控制peer连接,以在Reactor上接收传入消息。每个Reactor负责处理一个或多个channel传入的信息。因此,通常通过peer发送消息,在Reactor上接收传入的消息。 新添加peer后,给定reactor的传入消息将通过该reactorReceive方法处理,并且输出消息由每个节点的Reactor直接发送。 reactor使用节点之间的go-routing来处理这些。

连接加密及身份确认 在节点拨号成功后,执行两次握手:第一次进行通道加密、身份验证,第二次进行版本、网络类型验证。

Peer Identity 当尝试连接到peer时,我们使用PeerURL:<ID> @ <IP>:<PORT>。我们将尝试连接_IP:PORT_上的节点,并验证身份,通过经过身份id的签名,只有拥有相应私钥的节点可以建立连接。这可以防止对节点的中间人攻击。

通信加密、身份验证 节点建立加密连接时使用Diffie-Helman密钥交换协议生成共享秘钥,使用NACL SecretBox对通信数据进行对称加密。 工作流程如下:

  • 生成一个临时的ED25519密钥对
  • 将临时的公钥发送给对等方
  • 等待接收对等方的临时公钥
  • 使用对方临时公钥和我们的临时私钥计算Diffie-Hellman共享密钥
  • 生成两个用于加密(发送和接收)的随机数,流程如下:
    • 按升序对临时的公钥进行排序并将它们连接起来
    • 进行RIPEMD160运算
    • 附加4个空字节(将散列扩展为24个字节)
    • 结果是nonce1
    • 翻转nonce1的最后一位以获得nonce2
    • 如果我们有一个较小的临时pubkey,使用nonce1接收,nonce2发送;否则相反
  • 从现在开始的所有通信都使用共享密钥和随机数进行加密,其中每个随机数每次使用时增加2
  • 我们现在有一个加密通道,但仍需要进行身份验证
  • 签名共同挑战:
    • 对排序和连接的短暂pubkey进行SHA256运算
  • 使用我们的持久私钥签署共同挑战
  • 将go-wire编码的持久性pubkey和签名发送给节点
  • 等待从节点接收持久公钥和签名
  • 使用节点的持久公钥验证消息签名

如果这是一个outgoing连接(主动连接其它节点)并且使用了节点ID,然后最后验证节点的持久公钥是否与我们拨号的节点ID相对应,即。 peer.PubKey.Address() == <ID>。 现在连接现已通过身份验证,并且所有流量都已加密。 注意:只有拨号节点可以验证节点的身份,但这是我们关心的,因为当我们加入网络时我们希望确保已经连接了目标节点(而不是被中间人攻击)。

版本确认 版本确认允许节点交换其NodeInfo:

type NodeInfo struct {
	PubKey     crypto.PubKeyEd25519 
	Moniker    string               
	Network    string               
	RemoteAddr string               
	ListenAddr string               
	Version    string               
	Other      []string 
}

如果出现以下情况则断开连接:

  • peer.NodeInfo.Version 未格式化为X-X-X,其中X是称为Major,Minor和Revision的整数。
  • peer.NodeInfo.Version 主版本号与我们的不一样。
  • peer.NodeInfo.Network 网络类型与我们的不一样。

此时,如果没有断开连接,则节点有效。它通过AddPeer方法添加到switch中,因此被添加到所有reactor中。

# 数据序列化协议

支持的类型

  • 原始类型
    • uint8 (aka byte), uint16uint32uint64
    • int8int16int32int64
    • uintint: variable length (un)signed integers
    • string[]byte
    • time
  • 派生类型
    • structs
    • 特定类型的变长数组
    • 特定类型的固定长度数组
    • interfaces:注册的联合类型,前面是type byte
    • 指针

# 二进制编码

固定长度基本类型 用1,2,3或4个大端字节编码。

  • uint8(又名byte),uint16uint32uint64:分别占用1,2,3和4个字节
  • int8int16int32int64:分别占用1,2,3和4个字节
  • timeint64 表示自纪元以来的纳秒

可变长度整数 用一个前导字节编码,表示后续大端字节的长度。对于有符号的负整数,前导字节的最高有效位为1。

  • uint:1字节长度前缀可变大小(0~255字节)无符号整数
  • int:1字节长度前缀变量大小(0~127字节)有符号整数

注意:虽然数字0(零)用单个字节x00编码,但数字1用两个字节表示:x0101。这不是最高效的表示,但规则更容易记住。

号码 二进制uint 二进制int
0 x00 x00
1 x0101 x0101
2 x0102 x0102
256 x020100 x020100
2 ^(127 * 8)-1 x7FFFFF ... x7FFFFF ...
2 ^(127 * 8) x800100 ...... 溢出
2 ^(255 * 8)-1 xFFFFFF ... 溢出
-1 不适用 x8101
-2 不适用 x8102
-256 不适用 x820100

Structures 通过按声明顺序对字段值进行编码来编码。

type Foo struct {
    MyString string
    MyUint32 uint32
}
ar foo = Foo {“626172”,math.MaxUint32}
foo的二进制表示:
0103626172FFFFFFFF
0103:`int`编码的字符串长度,这里是3
626172:3个字节的字符串“bar”
FFFFFFFF:uint32 MaxUint32的4个字节

可变长度数组 用前导“int”编码,表示数组的长度,后跟项目的二进制表示。  固定长度数组 类似,但前面没有前导int

foos:= [] Foo {foo,foo}
foos的二进制表示:01020103626172FFFFFFFF0103626172FFFFFFFF
0102:`int`编码的数组长度,这里2
0103626172FFFFFFFF:第一个`foo`, 0103626172FFFFFFFF:第二个`foo`
foos:= [2] Foo {foo,foo} //固定长度数组
foos的二进制表示:0103626172FFFFFFFF0103626172FFFFFFFF
0103626172FFFFFFFF:第一个`foo`, 0103626172FFFFFFFF:第二个`foo`

接口 可以代表任意数量的具体类型之一。必须首先使用相应的type byte声明接口的具体类型。然后使用前导“类型字节”对接口进行编码,然后对底层具体类型进行二进制编码。

注意:字节x00保留用于nil接口值和nil指针值。

type Animal interface{}
type Dog uint32
type Cat string
RegisterInterface(
   struct{ Animal }{},          // Convenience for referencing the 'Animal' interface
   ConcreteType{Dog(0),  0x01}, // Register the byte 0x01 to denote a Dog
   ConcreteType{Cat(""), 0x02}, // Register the byte 0x02 to denote a Cat
)
var animal Animal = Dog(02)
The binary representation of animal: 010102
01:     the type byte for a `Dog`
0102: the bytes of Dog(02)

指针 用一个前导字节x00编码为nil指针,否则用前导字节x01编码,然后是指向的值的二进制编码。 注意:将指针类型转换为接口类型很容易,因为type byte x00总是nil

# JSON编码

JSON编解码器与[binary](#binary)编解码器兼容,如果您已经熟悉golang的JSON编码,则相当直观。下面提到了一些特殊规定:

  • 可变长度和固定长度字节编码为大写十六进制字符串
  • 接口值被编码为两个项的数组:[type_byte,concrete_value]
  • 次数被编码为rfc2822字符串

# 同步协议

bytom 目前支持普通同步模式,快速同步模式,SPV Proof。

Normal Fast Sync SPV
BlockMessage HeadersMessage FilterLoadMessage
StatusMessage BlocksMessage FilterClearMessage
TransationMessage FilterAddMessage
MineBlockMessage MerkleBlockMessage

# 节点协议握手

12.png

节点协议握手首先会向对方发送状态信息,同时通过状态信息获取对方当前状态,同步协议在获取状态之后。

StatusRequestMessage

Bytes Name Data Type Description
0 null 消息体为空,用于向对方获取状态信息

StatusResponseMessage

Bytes Name Data Type Description
8byte Height uint64 当前本地高度
32byte RawHash [32]byte 当前最高区块hash
[32]byte GenesisHash [32]byte 创世块hash

在握手后会进行交易池同步,交易池同步会把当前池中的交易打包发给对方,发送交易使用_TransactionMessage_。

TransactionMessage

Bytes Name Data Type Description
Varies RawTx []byte 交易消息

# 同步协议

17.png

目前支持普通同步和快速同步两种模式,区块同步程序定时检查所有连接的节点状态,判断是否需要同步,当需要同步时,判断节点满足快速同步条件时则进行快速同步,否则进行普通同步。为了使挖矿区块能快速同步到全网,当收到挖矿区块时会触发同步流程,使新区块快速上链,并及时更新挖矿区块高度,从而减少孤儿块产生的概率。

MineBlockMessage

Bytes Name Data Type Description
Varies RawBlock []byte 挖矿产生的区块信息

# 普通同步模式

14.png

普通同步模式下,节点按高度获取高度并进行全区块验证。使用_GetBlockMessage_和_BlockMessage_消息。

GetBlockMessage

Bytes Name Data Type Description
8byte Height uint64 使用高度获取区块,如果高度为0,则使用hash获取区块
4byte RawHash [32]byte 使用hash获取区块

BlockMessage

Bytes Name Data Type Description
Varies RawBlock []byte 序列化的区块信息

# 快速同步模式

16.png

快速同步模式下,通过在代码中加入checkpoint(已确认的区块的hash),这样同步时只需要比较某些高度区块hash是否和checkpoint区块hash一致,即可判断区块头正确性。通过计算区块中交易merkle树roothash是否和区块头中roothash一致,即可判断区块中的交易正确性。快速同步模式下批量获取区块头以及区块,可以极大提高同步速度。

GetHeadersMessage

Bytes Name Data Type Description
Varies RawBlockLocator [][32]byte 区块头定位器,用于定位获取区块头的开始位置
32 byte RawStopHash [32]byte 用于定位获取区块头结束的位置。

HeadersMessage

Bytes Name Data Type Description
Varies HeadersMessage [][]byte 打包的区块头信息

GetBlocksMessage

Bytes Name Data Type Description
Varies RawBlockLocator [][32]byte 区块定位器,用于定位获取区块的开始位置
32 byte RawStopHash [32]byte 用于定位获取区块结束的位置。

BlocksMessage

Bytes Name Data Type Description
Varies RawBlocks [][]byte 打包的区块信息

# SPV Proof

简单支付验证(SPV)是Satoshi Nakamoto的论文中描述的一种技术。 SPV允许轻量级客户端验证区块链中是否包含交易,而无需下载整个区块链。 SPV客户端只需要下载块头,这些块头比完整块小得多。 为了验证交易是否在块中,SPV客户端以Merkle block的形式请求包含交易证明。 SPV提供了两个关键要素:a)它确保您的交易处于一个区块中; b)它提供了区块被添加到链中的确认(工作证明)。

13.png

SPV 轻客户端首先连接全节点,当与全节点成功建立连接后。轻客户端向全节点注册地址过滤器,过滤器是一个地址集合,包含SPV节点账户的地址。全节点使用地址过滤器对交易进行过滤,并将相关交易发送给轻客户端。轻客户端使用_GetMerkleBlockMessage_命令向全节点获取_MerkleBlockMessage_消息。

FilterLoadMessage

Bytes Name Data Type Description
Varies Addresses [][]byte 地址集合,用于SPV客户端向全节点注册需要过滤的地址

FilterAddMessage

Bytes Name Data Type Description
Varies Address []byte 地址信息,用于SPV客户端向全节点添加需要过滤的地址

FilterClearMessage

Bytes Name Data Type Description
0 null 消息体为空,用于SPV客户端向全节点发送清除地址过滤器消息

GetMerkleBlockMessage

Bytes Name Data Type Description
8byte Height uint64 根据高度获取merkle block,如果为0则通过hash获取
32byte RawHash [32]byte 根据hash获取merkle block

MerkleBlockMessage

Bytes Name Data Type Description
Varies RawBlockHeader []byte 区块头信息
Varies TxHashes [][32]byte 交易或交易merkle树 node hash,用于计算交易merkle root
Varies RawTxDatas [][]byte 满足地址过滤器的交易
Varies StatusHashes [][32]byte 状态或状态merkle树 node hash,用于计算状态merkle root
Varies Flags []byte 用于分配TxHashes和StatusHashes到merkle树的特定node
上次更新: 3/30/2020, 11:49:24 AM