数据结构
下图是以太坊交易数据结构,根据用途,我将其划分为四部分。
开头是一个 uint64 类型的数字,称之为随机数。用于撤销交易、防止双花和修改以太坊账户的 Nonce 值(细节在讲解交易执行流程时讲解)。
第二部分是关于交易执行限制的设置,gasLimit 为愿意供以太坊虚拟机运行的燃料上限。 gasPrice 是愿意支付的燃料单价。gasPrcie * gasLimit 则为愿意为这笔交易支付的最高手续费。
我从程序执行逻辑上可以这样解释第三部分。是交易发送者输入以太坊虚拟机执行此交易的初始信息: 虚拟机操作对象(接收方 To)、从交易发送方转移到操作对象的资产(Value),以及虚拟机运行时入参(input)。其中 To 为空时,意味着虚拟机无可操作对象,此时虚拟机将利用 input 内容部署一个新合约。
第四部分是交易发送方对交易的签名结果,可以利用交易内容和签名结果反向推导出签名者,即交易发送方地址。
四部分内容的组合,解决了交易安全问题、实现了智能合约的互动方式以及提供了灵活可调整的交易手续费。
定义
将交易对象定义为一个对外可访问的Transation对象和内嵌的对外部包不可见的txdata 。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24type Transaction struct {
data txdata
// caches
hash atomic.Value
size atomic.Value
from atomic.Value
}
type txdata struct {
AccountNonce uint64
Price *big.Int
GasLimit uint64
Recipient *common.Address
Amount *big.Int
Payload []byte
// Signature values
V *big.Int
R *big.Int
S *big.Int
// This is only used when marshaling to JSON.
Hash *common.Hash `json:"hash" rlp:"-"`
}
签名
算法归类:
比特币与以太坊的区别
比特币
比特币在 BIP66 中对签名数据格式采用严格的 DER 编码格式,其签名数据格式如下:0x30 [total-length] 0x02 [R-length] [R] 0x02 [S-length] [S]
以太坊
对比比特币签名,以太坊的签名格式是r+s+v。 r 和 s 是 ECDSA 签名的原始输出,而末尾的一个字节为 recovery id 值,但在以太坊中用V表示,v 值为 1 或者 0。recovery id 简称 recid,表示从内容和签名中成功恢复出公钥时需要查找的次数(因为根据r值在椭圆曲线中查找符合要求的坐标点可能有多个),但在比特币下最多需要查找两次。这样在签名校验恢复公钥时,不需要遍历查找,一次便可找准公钥,加速签名校验速度。
签名流程
1 | func Sign(hash []byte, prv *ecdsa.PrivateKey) ([]byte, error) { |
❶ 首先,签名是针对 32 字节的 byte,实际上是对应待签名内容的哈希值,以太坊中哈希值common.Hash长度固定为 32。比如对交易签名时传入的是交易哈希crypto.Sign(tx.Hash()[:], prv)。
❷ 确保私钥的曲线算法是比特币的 secp256k1。目的是控制所有签名均通过 secp256k1 算法计算。
❸ 调用比特币的签名函数,传入 secp256k1 、私钥和签名内容, 并说明并非压缩的私钥。此时 SignCompact 函数返还一定格式的签名。其格式为:[27 + recid] [R] [S]
❹ 以太坊将比特币中记录的 recovery id 提取出。减去 27 的原因是,比特币中第一个字节的值等于27+recid,因此 recid= sig[0]-27。
❺ 以太坊签名格式是[R] [S] [V],和比特币不同。因此需要进行调换,将 R 和 S 值放到前面,将 recid 放到最后。
交易数据签名
❶ 交易签名时,需要提供一个签名器 (Signer) 和私钥(PrivateKey)。需要 Singer 是因为在 EIP155 修复简单重复攻击漏洞后,需要保持旧区块链的签名方式不变,但又需要提供新版本的签名方式。因此通过接口实现新旧签名方式,根据区块高度创建不同的签名器。
❷ 重点介绍 EIP155 改进提案中所实现的新哈希算法,主要目的是获取交易用于签名的哈希值 TxSignHash。和旧方式相比,哈希计算中混入了链 ID 和两个空值。注意这个哈希值 TxSignHash 在 EIP155 中并不等同于交易哈希值。
❸ 内部利用私钥使用 secp256k1 加密算法对TxSignHash签名,获得签名结果sig。
❹ 执行交易WithSignature方法,将签名结果解析成三段R、S、V,拷贝交易对象并赋值签名结果。最终返回一笔新的已签名交易。
上图中还有一个关键数据,则 Signer 是如何生成 R 、S、V 值的。从前面的签名算法过程,可以知道 R 和 S 是 ECDSA 签名的原始输出,V 值是 recid,其值是 0 或者 1。但是在交易签名时,V 值不再是 recid, 而是 recid+ chainID*2+ 35
。
校验
关键点在于调用校验签名函数时,第三个参数 sig 送入的是 sig[:len(sig)-1]
去掉了末尾的一个字节。这是因为函数VerifySignature要求 sig参数必须是[R] [S]
格式,因此需要去除末尾的[V]
。
交易回执receipt
回执信息分为三部分:共识信息、交易信息、区块信息。下面分别介绍各类信息。
交易回执内容介绍
交易回执共识信息
共识意味在在校验区块合法性时,这部分信息也参与校验。这些信息参与校验的原因是确保交易必须在区块中的固定顺序中执行,且记录了交易执行后的状态信息。这样可强化交易顺序。
- Status: 成功与否,1表示成功,0表示失败。注意在高度1035301前,并非1或0,而是 StateRoot,表示此交易执行完毕后的以太坊状态。
1
2
3
4
5
6
7
8var root []byte
if config.IsByzantium(header.Number) {
statedb.Finalise(true)
} else {
root = statedb.IntermediateRoot(config.IsEIP158(header.Number)).Bytes()
}
//...
receipt := types.NewReceipt(root, failed, *usedGas) - CumulativeGasUsed: 区块中已执行的交易累计消耗的Gas,包含当前交易。
- Logs: 当前交易执行所产生的智能合约事件列表。
- Bloom:是从 Logs 中提取的事件布隆过滤器,用于快速检测某主题的事件是否存在于Logs中。
这些信息是如何参与共识校验的呢?实际上参与校验的仅仅是回执哈希,而回执哈希计算只包含这些信息。
首先,在校验时获取整个区块回执信息的默克尔树的根哈希值。再判断此哈希值是否同区块头定义内容相同。1
2
3
4
5receiptSha := types.DeriveSha(receipts)
if receiptSha != header.ReceiptHash {
return fmt.Errorf("invalid receipt root hash (remote: %x local: %x)",
header.ReceiptHash, receiptSha)
}
而函数types.DeriveSha中生成根哈希值,是将列表元素(这里是交易回执)的RLP编码信息构成默克树,最终获得列表的哈希值。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17func DeriveSha(list DerivableList) common.Hash {
keybuf := new(bytes.Buffer)
trie := new(trie.Trie)
for i := 0; i < list.Len(); i++ {
keybuf.Reset()
rlp.Encode(keybuf, uint(i))
trie.Update(keybuf.Bytes(), list.GetRlp(i))
}
return trie.Hash()
}
func (r Receipts) GetRlp(i int) []byte {
bytes, err := rlp.EncodeToBytes(r[i])
if err != nil {
panic(err)
}
return bytes
}
继续往下看,交易回执实现了 RLP 编码接口。在方法EncodeRLP中是构建了一个私有的receiptRLP。1
2
3
4func (r *Receipt) EncodeRLP(w io.Writer) error {
return rlp.Encode(w,
&receiptRLP{r.statusEncoding(), r.CumulativeGasUsed, r.Bloom, r.Logs})
}
从代码中可以看出 receiptRLP 仅仅包含上面提到的参与共识校验的内容。1
2
3
4
5
6type receiptRLP struct {
PostStateOrStatus []byte
CumulativeGasUsed uint64
Bloom Bloom
Logs []*Log
}
交易回执交易信息
这部分信息记录的是关于回执所对应的交易信息,有:
- TxHash : 交易回执所对应的交易哈希。
- ContractAddress: 当这笔交易是部署新合约时,记录新合约的地址。
1
2
3if msg.To() == nil {
receipt.ContractAddress = crypto.CreateAddress(vmenv.Context.Origin, tx.Nonce())
} - GasUsed: 这笔交易执行所消耗的Gas燃料。
这些信息不参与共识的原因是这三项信息已经在其他地方校验。
- TxHash: 区块有校验交易集的正确性。
- ContractAddress: 如果是新合约,实际上已经提交到以太坊状态 State 中。
- GasUsed: 已属于CumulativeGasUsed的一部分。
交易回执区块信息
这部分信息完全是为了方便外部读取交易回执,不但知道交易执行情况,还能方便的指定该交易属于哪个区块中第几笔交易。 - BlockHash: 交易所在区块哈希。
- BlockNumber: 交易所在区块高度.
- TransactionIndex: 交易在区块中的序号。
这三项信息,主要是在数据库 Leveldb 中读取交易回执时,实时指定。
交易回执构造
交易回执是在以太坊虚拟机处理完交易后,根据结果整理出的交易执行结果信息。反映了交易执行前后以太坊变化以及交易执行状态。
构造细节,已经在前面提及,不再细说。这里给出的完整的交易回执构造代码。
交易回执存储
交易回执作为交易执行中间产物,为了方便快速获取某笔交易的执行明细。以太坊中有跟随区块存储时实时存储交易回执。但为了降低存储量,只存储了必要内容。
首先,在存储时,将交易回执对象转换为精简内容。
精简内容是专门为存储定义的一个结构ReceiptForStorage。存储时将交易回执集进行RLP编码存储。
所以看存储了哪些内容,只需要看 ReceiptForStorage的 EncodeRLP方法。
根据EncodeRLP方法实现,可以得出在存储时仅仅存储了部分内容,且 Logs 内容同样进行了特殊处理LogForStorage。
交易回执示例
上面讲完交易回执内容与构造和存储,下面我从etherscan上查找三中不同类型的交易回执数据,供大家找找感觉。
一笔包含日志的交易回执
交易 0x01e180……0a4021 执行成功,且包含了两个事件日志。
一笔成功部署合约的交易回执
如果是部署合约的交易,可以看到 contractAddress 有值。
一笔含 StateRoot的交易回执
和其他交易回执内容不同,在高度1035301 前的交易并无 status 字段,而是 root 字段。是在后续改进中去除 root 采用 status 的。
一笔交易失败的交易回执
如果是失败的交易,则 status为0。
交易池
关键流程
区分本地交易和远程交易。
设计
配置
- Locals: 定义了一组视为local交易的账户地址。任何来自此清单的交易均被视为 local 交易。
- NoLocals: 是否禁止local交易处理。默认为 fasle,允许 local 交易。如果禁止,则来自 local 的交易均视为 remote 交易处理。
- Journal: 存储local交易记录的文件名,默认是 ./transactions.rlp。
- Rejournal:定期将local交易存储文件中的时间间隔。默认为每小时一次。
- PriceLimit: remote交易进入交易池的最低 Price 要求。此设置对 local 交易无效。默认值1。
- PriceBump:替换交易时所要求的价格上调涨幅比例最低要求。任何低于要求的替换交易均被拒绝。
- AccountSlots: 当交易池中可执行交易(是已在等待矿工打包的交易)量超标时,允许每个账户可以保留在交易池最低交易数。默认值是 16 笔。
- GlobalSlots: 交易池中所允许的可执行交易量上限,高于上限时将释放部分交易。默认是 4096 笔交易。
- AccountQueue:交易池中单个账户非可执行交易上限,默认是64笔。
- GlobalQueue: 交易池中所有非可执行交易上限,默认1024 笔。
- Lifetime: 允许 remote 的非可执行交易可在交易池存活的最长时间。交易池每分钟检查一次,一旦发现有超期的remote 账户,则移除该账户下的所有非可执行交易。默认为3小时。
链状态
所有进入交易池的交易均需要被校验,最基本的是校验账户余额是否足够支付交易执行。或者 交易 nonce 是否合法。在交易池中维护的最新的区块StateDB。当交易池接收到新区块信号时,将立即重置 statedb。核心是将交易池中已经不符合要求的交易删除并更新整理交易。
本地交易
在交易池中将交易标记为 local 的有多种用途:
- 在本地磁盘存储已发送的交易。这样,本地交易不会丢失,重启节点时可以重新加载到交易池,实时广播出去。
- 可以作为外部程序和以太坊沟通的一个渠道。外部程序只需要监听文件内容变化,则可以获得交易清单。
- local交易可优先于 remote 交易。对交易量的限制等操作,不影响 local 下的账户和交易。
只有属于 local 账户的交易才会被记录。如果仅仅是这样的话,journal 文件是否会跟随本地交易而无限增长?答案是否定的,虽然无法实时从journal中移除交易。但是支持定期更新journal文件。
journal 并不是保存所有的本地交易以及历史,他仅仅是存储当前交易池中存在的本地交易。因此交易池会定期对 journal 文件执行 rotate,将交易池中的本地交易写入journal文件,并丢弃旧数据。
新交易信号
交易池支持外部订阅新交易事件信号。任何订阅此事件的子模块,在交易池出现新的可执行交易时,均可实时接受到此事件通知,并获得新交易信息。
需要注意的是并非所有进入交易池的交易均被通知外部,而是只有交易从非可执行状态变成可执行状态后才会发送信号。
交易存储
下图是交易池对本地待处理交易的磁盘存储管理流程,涉及加载、实时写入和定期更新维护。
加载已存储交易
在交易池首次启动 journal 时,将主动将该文件已存储的交易加载到交易池。
交易并非单笔直接载入交易池,而是采用批量提交模式,每 1024 笔交易提交一次。
存储交易
当交易池新交易来自于本地账户时,如果已开启记录本地交易,则将此交易加入journal。到交易池时,将实时存储到 journal 文件中。
而 journal.insert则将交易实时写入文件流中,相当于实时存储到磁盘。而在写入时,是将交易进行RLP编码。
journal定期更新
journal 的目的是长期存储本地尚未完成的交易,以便交易不丢失。而文件内容属于交易的RLP编码内容,不便于实时清空已完成或已无效的交易。因此以太坊采取的是定期将交易池待处理交易更新到 journal 文件中。
首先,在首次加载文件中的交易到交易池后,利用交易池的检查功能,将已完成或者已完成的交易拒绝在交易池外。在加载完成后,交易池中的交易仅仅是本地账户待处理的交易,因此在加载完成后,立即将交易池中的所有本地交易覆盖journal文件。
交易入队
校验交易合法性
- 首先是防止DOS攻击,不允许交易数据超过32KB。
- 接着不允许交易的转账金额为负数,实际上这次判断难以命中,原因是从外部接收的交易数据属RLP编码,是无法处理负数的。当然这里做一次校验,更加保险。
- 区块中的GAS量是每笔交易执行消耗GAS之和,故不可能一笔交易的GAS上限超过区块GAS限制。
- 每笔交易都需要携带交易签名信息,并从签名中解析出签名者地址。只有合法的签名才能成功解析出签名者。一旦解析失败拒绝此交易。
- 既然知道是交易发送者(签名者),那么该发送者也可能是来自于交易池所标记的local账户。因此当交易不是local交易时,还进一步检查是否属于local账户。如果不是local交易,那么交易的GasPrice 也必须不小于交易池设定的最低GasPrice。
- 交易池中不允许出现交易的Nonce 小于此账户当前Nonce的交易。
- 检查该账户余额,只有账户资产充足时,才允许交易继续,否则在虚拟机中执行交易,交易也必将失败。
- 检查gasLimit。
入队
以太坊将交易按状态分为两部分:可执行交易和非可执行交易。分别记录在pending容器中和 queue 容器中。
如上图所示,交易池先采用一个 txLookup (内部为map)跟踪所有交易。同时将交易根据本地优先,价格优先原则将交易划分为两部分 queue 和 pending。而这两部交易则按账户分别跟踪。
在进入交易队列前,将判断所有交易队列 all 是否已经达到上限。如果到了上限,则需要从交易池或者当前交易中移除优先级最低交易。
交易是有根据 from 分组管理,且一个 from 由分非可执行交易队列(queue)和可执行交易队列(pending)。新交易默认是要在非可执行队列中等待指示,但是一种情况时,如果该 from 的可执行队列中存在一个相同 nonce 的交易时,需要进一步识别是否能替换。只要价格(gasPrice)高于原交易,则允许替换。一旦可以替换,则替换掉旧交易,移除旧交易,并将交易同步存储到 all 交易内存池中。
检查完是否需要替换 pending 交易后,则将交易存入非可执行队列。同样,在进入非可执行队列之前,也要检查是否需要替换掉相同 nonce 的交易。
最后,如果交易属于本地交易还需要额外关照。如果交易属于本地交易,但是本地账户集中不存在此 from 时,更新本地账户集,避免交易无法被存储。另外,如果已开启存储本地交易,则实时存储本地交易。
容量内存限制
删除旧交易
当新区块来到时,很有可能包含交易内存池中一些账户的交易。一旦存在,则意味着账户的 nonce 和账户余额被存在变动。而只有高于当前新 nonce 交易才能被执行,且账户余额不足以支撑交易执行时,交易也将执行失败。
因此,在新区块来到后,删除所有低于新nonce的交易。 再根据账户可用余额,来移除交易开销(amount+gasLimit*gasPrice
)高于此余额的交易。
转移交易或释放
在非可执行队列中的交易有哪些可以转移到可执行队列呢?因为交易 nonce 的缘故,如果queue队列中存在低于 pending 队列的最小nonce的交易,则可直接转移到pending中。
转移后,该账户的交易可能超过所允许的排队交易笔数,如果超过则直接移除超过上限部分的交易。当然这仅仅针对remote交易。
检查pending数量
如果超过上限,则分两种策略移除超限部分。
优先从超上限(pool.config.AccountSlots)的账户中移除交易。在移除交易时,并非将某个账户的交易全部删除,而是每个账户轮流删除一笔交易,直到低于交易上限。同时,还存在一个特殊删除策略,并非直接轮流每个账户,而是通过一个动态阀值控制,阀值控制遍历顺序,存在一定的随机性。
如果仍然还超限,则继续采用直接遍历方式,删除交易,直到低于限制。
检查queue数量
删除交易的策略完成根据每个账户pending交易的时间处理,依次删除长时间存在于pending的账户交易。在交易进入pending 时会更新账户级的心跳时间,代表账户最后pending交易活动时间。时间越晚,说明交易越新。