前言
区块链是一个以 " 去中心化 "、" 去信任化 " 方式集体维护的分布式账本,这里的 " 分布式 " 不仅体现在数据的分布式存储,也体现在数据的分布式记录,即由系统参与者共同维护,作为 " 账本 " 的区块链自然少不了记账,而交易自然而然的成为了重中之重。知道创宇区块链安全实验室将从源代码视角对以太坊交易池数据结构、交易费用设置、交易构建、交易入池、交易签名、交易验证等逻辑设计进行简要浅析,并通过对以太坊交易安全机制设计来研究公链安全机制设计。
基本概念
交易流程示意图大致如下所示:
流程说明:
首先由用户通过网络发起交易请求,并使用自己的私钥对交易进行签名,之后进行交易广播,进而将交易添加到交易池中,矿工从交易池中获取交易信息,然后将其进行打包并生成区块,之后通过进行共识出块,最后向全网广播交易区块。
数据流向:
交易池的数据来源:
本地提交,第三方应用通过调用本地以太坊节点的 RPC 服务提交交易;
远程同步,通过广播同步的形式,将其他以太坊节点的交易数据同步至本地节点。
交易池的数据去向:由 miner(矿工) 获取并验证,用于挖矿,挖矿成功后写进区块被广播,交易被写入规范链后会从交易池中进行删除,如果交易被写进分叉则交易池中的交易不会减少,之后等待重新打包。
数据结构
首先来看一下 TxPoolConfig 的配置信息:
默认配置如下:
TxPool 数据结构如下所示:
基础配置
在分析交易执行我们首先需要来看一些基本的配置,例如:交易手续费是有有最大的上限 / 下限、交易池配置、交易最大信息检索数量等,在这里我们仅对一些关键的点进行查看:
01 交易手续费
02 交易池配置
03 交易检索数量
初始化池
交易池的初始化通过 NewTxPool 来实现,具体代码如下所示:
在这里首先调用 sanitize 函数 对配置参数进行校验,以规避设置不合理的 gas prices。
之后使用默认配置初始化一个交易池 ( txpool ):
之后初始化本地账户并将配置的本地账户地址加到交易池:
之后创建更加 gasprices 排序的交易:
具体实现代码如下所示:
之后调用 reset 更新交易池:
reset 具体实现如下:
之后启动 reorg 循环,使其能够处理日志加载期间生成的请求:
scheduleReorgLoop 具体实现代码如下所示,该函数主要用于 reset 和 promoteExecutable 的执行计划。
此时如果本地交易开启那么从本地磁盘加载本地交易。
之后订阅相关交易事件并开启主循环:
主循环 loop 具体实现代码如下,它是 txPool 的一个 goroutine,也是主要的事件循环,它主要用于等待和响应外部区块链事件以及各种报告和交易驱逐事件 :
构建交易
交易有用户发起,使得资产从一方转移至另一方,即所谓的价值转移,我们最直观的交易构建就是通过钱包来进行转账,在这里我们直接以 eth_sendTransaction 这一个 RPC 为例进行分析交易的构建流程,eth_sendTransaction 请求示例如下:
参数示例:
from: DATA,20 字节 - 发送交易的源地址
to: DATA,20 字节 - 交易的目标地址,当创建新合约时可选
gas: QUANTITY - 交易执行可用 gas 量,可选整数,默认值 90000,未用 gas 将返还
gasPrice: QUANTITY - gas 价格,可选,默认值:待定 (To-Be-Determined)
value: QUANTITY - 交易发送的金额,可选整数
data: DATA - 合约的编译带啊或被调用方法的签名及编码参数
nonce: QUANTITY - nonce,可选,可以使用同一个 nonce 来实现挂起的交易的重写
响应示例:
下面我们来跟踪一下 eth_sendTransaction 这一个 RPC 的执行过程,在这里首先检索账户是否存在,之后检查 Nonce 是否为空,紧接着调用 SingTx 进行签名操作,之后调用 SubmitTransaction 来提交交易:
SignTx 实现代码如下所示,在这里会继续调用 SignTx 进行签名操作,这里不再深入,后续的 " 交易签名 " 会进行纤细分析:
签名之后返回 SendTransaction 中去调用 SubmitTransaction 来提交签名,在这里会首先检查交易费用是否足够,之后调用 SendTx 来发送交易:
SendTx 的具体实现如下,在这里会调用 AddLocal 来添加交易到交易池中去,这里不再深入后续会有 " 添加交易 " 这一个分析单元模块:
之后检查接受地址是否为空,如果为空则创建一个地址 (一般在合约创建时出现),之后打印一份完整的 TX 详细信息的日志便于后续手动调查分析,之后返回交易的 hash 值:
交易入池
我们知道交易的来源有两个方面:一个方面是本地提交的,另一个方面是远程提交的,这两个的具体实现代码分别为 AddLocals 和 AddRemotes,这两个函数在添加交易到交易池时都是通过调用 addTxs 来实现的:
addTxs 代码如下所示:
首先会对交易进行过滤,检查是否是一个已知的交易 (即添加过或广播过的),之后调用 send 函数校验通过 secp256k1 椭圆曲线从签名 (v,r,s) 派生的地址,如果派生失败或签名不正确,则返回错误 :
之后将交易添加到交易池中去 (注意 : 这里有事务锁)
addTxsLocked 的具体实现如下所示,它会将有效的交易进行排队处理,同时调用 pool.add 函数将交易添加到交易队列中去:
add 函数的具体实现如下所示:
在这里会首先检查当前的交易是否已经知晓 (即被广播过或者添加到池子里过),如果已知晓则直接丢弃:
之后鉴别交易是本地提交还是远程提交,并调用函数 validateTx 来验证交易,如果验证不通过则直接丢弃:
之后检查交易池是否满了,如果满了则放弃交易队列中定价过低的交易,GlobalSlots 和 GlobalQueue 为 pending 和 queue 的最大容量:
之后判断当前交易在 pending 队列中是否存在 nonce 值相同的交易,如果存在则判断当前交易所设置的 gasprice 是否超过设置的 PriceBump 百分比,超过则替换覆盖已存在的交易,否则报错返回替换交易 Gasprice 过低,并且把它扔到 queue 队列中 ( enqueueTx ):
之后调用 enqueueTx 将添加到交易队列中去,同时检查 from 账户是否为本地地址,如果是则添加到交易池本地地址中去:
enqueueTx 代码如下所示,该函数主要将新的交易插入到交易队列中去:
最后会到 addTx 函数中在这里会调用 requestPromoteExecutables 函数进行一次交易提升请求操作,它主要将交易从 queue 投放到 pending 中去 :
交易签名
交易签名主要通过函数 SignTx 来实现,首先检查钱包是否关闭,之后检查钱包账户中是否包含发情交易请求的账户,之后调用 SignTx 进行签名处理:
SignTx 的具体实现代码如下所示:
校验过账户的有效性后我们可以通过 SignTx 来使用 keystore 进行签名处理,在这里紧接着调用 LatestSignerForChainID 进行签名:
之后再 SignTx 函数 中使用私钥进行签名:
在 sign 中使用 ECDSA (椭圆曲线加密算法) 进行签名,之后返回签名的结果:
交易验证
交易验证时整个交易环节最重要的一环,对于用户来说,交易验证时保证用户财产安全的重要手段,而对于整个以太坊来说,交易验证时保证以太坊稳定运行和持续发展的重要方式,交易验证主要出现在以下几个场景中:
用户完成一笔交易的签名时,需要将交易提交到区块链网络中,是交易能够尽快确认,节点在提交交易之前需要先验证交易,确认交易的合法性;
节点收到其他节点广播的交易时,节点需要先验证交易是否合法,合法的交易才会加入节点的交易池;
当一个挖矿节点成功计算出符合要求的哈希值后,节点会将交易池中的交易打包到区块中,接地那在打包交易的时候需要验证交易的合法性;
节点收到其他节点同步到的区块是,也需要验证区块中包含的交易。
交易验证由 validateTx 函数来完成,其逻辑代码如下所示,在这里会检查 eip2718 是否开启以及交易的类型,之后检查交易的 size、交易转账的额度、交易的 gas、交易签名的正确性、确保交易遵循 Nonce 顺序、交易人资产是否足够、确保交易的 gas price 币基本的交易费用要高:
交易升级
交易升级主要是指将交易放入 pending 列表中去,该方法与 add 方法的不同之处在于 add 函数 是将获得到的新交易插入 pending,而 PromoteExecutables 是将把给定的账号地址列表中可以执行的交易从 queue 列表中插入 pending 中,并检查失效的交易,然后发送交易池更新事件,其实现代码如下所示:
在这里通过一个 for 循环来迭代所有的账户并升级交易,在这里首先将所有 queue 中 nonce 低于账户当前 nonce 的交易删除:
之后将所有 queue 中消费大于账户所持余额或者 gas 大于最大 gas 限制的交易移除:
之后将所有可执行的交易从 queue 里面添加到 pending 里面,在这里会调用 promoteTx 方法将队列中的交易 ( Txs ) 放入 pending:
promoteTx 实现代码如下所示,该函数首先将交易插入到 pending 队列中去,如果旧交易更好 (交易 Gasprice 大于或等于原交易价值的 110% 为标准,具体跟 pricebump 设定有关系) 则删除当前这个交易,如果当前交易相较于旧的交易更好则删除旧的交易,之后更新列表:
之后回到 promoteExecutables 函数中,如果非本地账户 queue 小于限制 ( AccountQueue ) 则进行移除操作:
最后记录移除的条目并更新 queuedGauge,如果队列中此账户的交易为空则删除此账户:
交易降级
交易降级是指当出现新的区块时,已被打包的交易将从 padding 中降级到 queue 中,或者当另外一笔交易的 Gas price 更高时则会从 padding 中降级到 queue 中,降级操作的关键实现函数为 demoteUnexecutables,交易降级主要出现在以下三种情况中:
分叉导致 Account 的 Nonce 值降低:假如原规范链 A 上交易序号 m 花费了 20,且已经上链,而分叉后新规范链上交易序号 m 未上链,从而导致在规范链上记录的账户的 Nonce 降低,这样交易 m 就必须要回滚到交易池,放到 queue 中去;
分叉后出现间隙:这种问题出现通常是因为交易余额问题导致的,假如原规范链上交易 m 花费 100,分叉后该账户又发出一个交易 m 花费 200,这就导致该账户余额本来可以支付原来规范链上的某笔交易,但在新的规范链上可能就不够了,这个余额不足的交易如果是 m+3,那么在 m+2,m+4 号交易之间就出现了空隙,这就导致从 m+3 开始往后所有的交易都要降级;
分叉导致 pending 最前一个交易的 nonce 值与状态的 nonce 值不等。
demoteUnexecutables 代码如下所示,在这里首先通过遍历 pending 列表来获取每个 addr 的最新 Nonce 值,之后删除 Nonce 小于之前查询所得 Nonce 值的交易,之后返回账户余额已经不足以支付交易费用和一些暂时无效的交易,并将暂时无效的交易放到 queue 中,此时如果有间隙,则将后面的交易移动到 queue 列表中,如果经过上面的降级,如果 pending 里某个 addr 一个交易都没有,就把该账户给删除:
池子重置
我们可以通过 reset 来重置交易池,该方法具体代码如下所示:
如果老区块不为空且老区块不是新区块的父区块,则检查老区块和新区块之间的差值是否大于 64,如果超过 64 则不进行重组,否则获取旧头和新头的最新区块,如果旧头为 null 则检查新头的高度是否小于旧头的高度,则打印日志并直接 return,如果不满足则继续向下执行;
如果旧头不为 null 则开始进行重组,此时如果旧链的头区块大于新链的头区块高度时则旧链先后回退并回收所有回退的交易,如果新链的头区块大于旧链的头区块则新链后退并回收交易,当新链和旧链的到达同一高度时则同时回退直到找到共同的父节点,之后找出所有存储在 discard 里面但是不在 included 里面的值,之后将这些交易重新插入到 pool 里面:
之后设置最新的世界状态、设置新链头区块的状态,然后把旧链回退的交易放入交易池:
文末小结
区块链由区块以链式结构相互链接而成,每一个区块有区块头和区块主体两部分组成,其中区块主体存储交易记录,故而 " 交易 " 成为了链上数据的关键所在,也是链上价值转移的主要途径,在公链体系中交易的构建流程、交易的验证、交易的签名、Gas 费用的设计等环节都存在值得考虑的安全风险,例如:当交易费用 ( GasPrice ) 可为 0 时的零手续费恶意 DOS 攻击、交易签名伪造、双花攻击、交易签名数据长度未校验导致签名时节点 OOM 等。
本篇文章通过从源代码角度对以太坊交易池数据结构、交易手续费设置、交易构建、交易签名、交易入池、交易验证、交易升级、交易降级、交易池重置等功能模块的分析,探索了以太坊交易处理的流程以及安全设计,而公链安全体系的建设依旧是长路漫漫,有待进一步深入探索。
参考链接:
作者:创宇区块链安全实验室;来自链得得内容开放平台“得得号”,本文仅代表作者观点,不代表链得得官方立场凡“得得号”文章,原创性和内容的真实性由投稿人保证,如果稿件因抄袭、作假等行为导致的法律后果,由投稿人本人负责得得号平台发布文章,如有侵权、违规及其他不当言论内容,请广大读者监督,一经证实,平台会立即下线。如遇文章内容问题,请联系微信:chaindd123