数据同步协议
比原链网络数据同步协议栈如下图所示。协议栈基于tcp/ip,Encryption完成数据的加密传输,Wire Protocol完成数据序列化,最上层为同步协议。
Tx Sync/Block Sync/Fast Sync/Spv |
---|
Wire Protocol |
Encryption |
TCP/IP |
基于tcp/ip同步协议
数据同步首先会在节点之间建立连接MConnection,建立连接后会对连接进行加密处理,区块,交易数据序列化为二进制数据流通过加密通道传递给其它节点。
建立加密连接
建立多路复用连接
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 以进行多路复用。
1 | type msgPacket struct { |
msgPacket
用go-wire进行序列化,并以0x3为前缀。接收到的一组数据包的“字节”被附加在一起直到收到带有EOF = 1
的数据包,然后完整的序列化消息由相应channel的onReceive函数处理。
多路复用
消息从sendRoutine 发送,它循环在select状态上并发送ping,pong或msg消息。该批数据消息可以包括来自多个channel的消息。消息字节排队等待在各自的通道中发送,每个通道一次取一个未发送的消息。从最近发送的字节与信道优先级的比最低的信道选择一个消息发送。
发送消息
发送消息有两种方法:1
2func (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
的传入消息将通过该reactor
的Receive
方法处理,并且输出消息由每个节点的Reactor
直接发送。 reactor
使用节点之间的go-routing
来处理这些。
连接加密及身份确认
在节点拨号成功后,执行两次握手:第一次进行通道加密、身份验证,第二次进行版本、网络类型验证。
Peer Identity
节点每次启动都会随机产生一个public key作为节点的id。当尝试连接到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:
1 | type NodeInfo struct { |
如果出现以下情况则断开连接:
peer.NodeInfo.Version
未格式化为X-X-X
,其中X是称为Major,Minor和Revision的整数。peer.NodeInfo.Version
主版本号与我们的不一样。peer.NodeInfo.Network
网络类型与我们的不一样。
此时,如果没有断开连接,则节点有效。它通过AddPeer
方法添加到switch
中,因此被添加到所有reactor
中。
数据序列化协议
支持的类型
- 原始类型
uint8
(akabyte
),uint16
,uint32
,uint64
int8
,int16
,int32
,int64
uint
,int
: variable length (un)signed integersstring
,[]byte
time
- 派生类型
- structs
- 特定类型的变长数组
- 特定类型的固定长度数组
- interfaces:注册的联合类型,前面是
type byte
- 指针
二进制编码
固定长度基本类型 用1,2,3或4个大端字节编码。
uint8
(又名byte
),uint16
,uint32
,uint64
:分别占用1,2,3和4个字节int8
,int16
,int32
,int64
:分别占用1,2,3和4个字节time
:int64
表示自纪元以来的纳秒
可变长度整数 用一个前导字节编码,表示后续大端字节的长度。对于有符号的负整数,前导字节的最高有效位为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 通过按声明顺序对字段值进行编码来编码。
1 | type Foo struct { |
可变长度数组 用前导“int”编码,表示数组的长度,后跟项目的二进制表示。 固定长度数组 类似,但前面没有前导int
。
1 | foos:= [] Foo {foo,foo} |
接口 可以代表任意数量的具体类型之一。必须首先使用相应的type byte
声明接口的具体类型。然后使用前导“类型字节”对接口进行编码,然后对底层具体类型进行二进制编码。
注意:字节x00
保留用于nil
接口值和nil
指针值。
1 | type Animal interface{} |
指针 用一个前导字节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 |
---|---|---|
BlocMessage | HeadersMessage | FilterLoadMessage |
StatusMessage | BlocksMessage | FilterClearMessage |
TransationMessage | FilterAddMessage | |
MineBlockMessage | MerkleBlockMessage |
节点协议握手
节点协议握手首先会向对方发送状态信息,同时通过状态信息获取对方当前状态,同步协议在获取状态之后。
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 | 交易消息 |
同步协议
目前支持普通同步和快速同步两种模式,区块同步程序定时检查所有连接的节点状态,判断是否需要同步,当需要同步时,判断节点满足快速同步条件时则进行快速同步,否则进行普通同步。为了使挖矿区块能快速同步到全网,当收到挖矿区块时会触发同步流程,使新区块快速上链,并及时更新挖矿区块高度,从而减少孤儿块产生的概率。
MineBlockMessage
Bytes | Name | Data Type | Description |
---|---|---|---|
Varies | RawBlock | []byte | 挖矿产生的区块信息 |
普通同步模式
普通同步模式下,节点按高度获取高度并进行全区块验证。使用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 | 序列化的区块信息 |
快速同步模式
快速同步模式下,通过在代码中加入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)它提供了区块被添加到链中的确认(工作证明)。
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 |