深度解析:在发送1个DAI时发生了什么?

23-05-16 18:29
阅读本文需 159 分钟
总结 AI 总结
看总结 收起
原文来源:NOTONLYOWNER
原文作者:tincho
原文编译:登链社区翻译小组


你有 1 个 DAI,使用钱包(如 Metamask)发送 1 个 DAI 到「0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045」(就是 vitalik.eth),点击发送。


一段时间后,钱包显示交易已被确认。突然,vitalik.eth 现在有了 1 个 DAI 的财富。这背后到底发生了什么?


让我们回放一下。并以慢动作回放。


准备好了吗?


构建交易


钱包是便于向以太坊网络发送交易的软件。


交易只是告诉以太坊网络,你作为一个用户,想要执行一个行动的一种方式。在此案例中,这将是向 Vitalik 发送 1 个 DAI。而钱包(如 Metamask)有助于以一种相对简单的方式建立这种交易。


让我们先来看看钱包将建立的交易,可以被表示为一个带有字段和相应数值的对象。


我们的交易开始时看起来像这样:



其中字段 to 说明目标地址。在此案例中,「0x6b175474e89094c44da98b954eedeac495271d0f」是 DAI 智能合约的地址。


等等,什么?


我们不是应该发送 1 个 DAI 给 Vitalik 吗?to 不应该是 Vitalik 的地址吗?


嗯,不是。要发送 DAI,必须制作一个交易,执行存储在区块链(以太坊数据库的花哨名称)中的一段代码,将更新 DAI 的记录余额。执行这种更新的逻辑和相关存储都保存在以太坊数据库中的一个不可改变的公共计算机程序中 - DAI 智能合约。


因此,你想建立一个交易,告诉合约「嘿,伙计,更新你的内部余额,从我的余额中取出 1 个 DAI,并添加 1 个 DAI 到 Vitalik 的余额」。在以太坊的行话中,「hey buddy」这句话翻译为在交易的「to」字段中设置 DAI 的地址。


然而,「to」字段是不够的。从你喜欢的钱包的用户界面中提供的信息,钱包会要求你填写其他几个字段,以建立一个格式良好的交易:



所以你给 Vitalik 发送 1 个 DAI,你既没有使用 Vitalik 的地址,也没有在 amount 字段里填上 1。这就是生活的艰难(而我们只是在热身)。amount 字段实际上包含在交易中,表示你在交易中发送多少 ETH(以太坊的原始货币)。由于你现在不想发送 ETH,那么钱包会正确地将该字段设置为 0。


至于「chainId」,它是一个指定交易执行的链的字段。对于以太坊 Mainnet,它是 1。然而,由于我将在 mainnet 的本地 Fork 上运行这个实验,我将使用其链 ID:31337,其他链有其他标识符


那「nonce」字段呢?那是一个数字,每次你向网络发送交易时都应该增加。它是一种防御机制,以避免重放问题。钱包通常为你设置这个数字。为了做到这一点,他们会查询网络,询问你的账户最新使用的 nonce 是什么,然后相应地设置当前交易的 nonce。在上面的例子中,它被设置为 0,尽管在现实中它将取决于你的账户所执行的交易数量。


我刚才说,钱包「查询网络」。我的意思是,钱包执行对以太坊节点的只读调用,而节点则回答所要求的数据。从以太坊节点读取数据有多种方式,这取决于节点的位置,以及它所暴露的 API 种类。


让我们想象一下,钱包可以直接网络访问一个以太坊节点。更常见的是,钱包与第三方供应商 (如 Infura、Alchemy、QuickNode 和许多其他供应商) 交互。与节点交互的请求遵循一个特殊的协议来执行远程调用。这种协议被称为JSON-RPC


一个试图获取账户 nonce 的钱包请求将类似于这样:



其中「0x6fC27A75d76d8563840691DDE7a947d7f3F179ba」将是发起者的账户。从响应中你可以看到,它的 nonce 是 0。


钱包使用网络请求(在此案例中,通过 HTTP)来获取数据,请求节点暴露的 JSON-RPC 端点。上面我只包括了一个,但实际上钱包可以查询任何他们需要的数据来建立一个交易。如果在现实生活中,你注意到有更多的网络请求来查询其他东西,请不要惊讶。例如,下面是一个本地测试节点在几分钟内收到的 Metamask 流量快照:



交易的数据字段


DAI 是一个智能合约。它的主要逻辑在以太坊主网的地址「0x6b175474e89094c44da98b954eedeac495271d0f」实现。


更具体地说,DAI 是一个符合 ERC20 标准的同质 Token -- 一种特殊的合约类型。意思是 DAI 至少实现ERC20 规范中详述的接口。用 (有点牵强的)web2 术语来说,DAI 是一个运行在以太坊上的不可变的开源网络服务。鉴于它遵循 ERC20 规范,我们有可能提前知道 (不一定要看源代码) 与它交互的确切暴露的接口。


简短的附带说明:不是所有的 ERC20 Token 都是这样。实现某种接口(有利于交互和集成),单不能保证具体的行为。不过,在这个练习中,我们可以安全地假设 DAI 在行为上是相当标准的 ERC20 Token。


在 DAI 智能合约中,有许多功能(源代码可在这里),其中许多直接来自 ERC20 规范。特别值得注意的是外部转移(external transferr) 函数。



这个函数允许任何持有 DAI Token 的人将其中一部分转账到另一个以太坊账户。它的签名是「transfer(address,uint256)」。其中第一个参数是接收方账户的地址,第二个参数是无符号整数,代表要转账的 Token 数量。


现在我们不关注该函数行为的具体细节。相信我,你会了解到的,该函数将发送方的余额减去所传递的金额,然后相应地增加接收方的金额。


这一点很重要,因为当建立一个交易与智能合约交互时,人们应该知道合约的哪个函数要被执行。以及要传递哪些参数。这就像在 web2 中,你想向一个网络 API 发送一个 POST 请求。你很可能需要在请求中指定确切的 URL 和它的参数。这也是一样的。我们想转移 1 个 DAI,所以我们必须知道如何在交易中指定它应该在 DAI 智能合约上执行「转移」功能。


幸运的是,这是非常直接和直观的。


哈哈,我开玩笑。不是的。


下面是你在交易中必须包含的内容,以发送 1 个 DAI 给维塔利克(记住,地址「0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045」):



让我解释一下。


为了简化集成,并有一个标准化的方式来与智能合约交互,以太坊生态系统采用(某种形式)的「合约 ABI 规范」(ABI 代表应用二进制接口)。在普通使用场景中,我强调,在普通使用场景中,为了执行智能合约功能,你必须首先按照合约 ABI 规范对调用进行编码。更高级的使用场景可能不遵循这个规范,但我们肯定不会进入这个兔子洞。我只想说,用Solidity编程的常规智能合约,如 DAI,通常遵循合约 ABI 规范。


你可以看到上面是用 DAI 的「transfer(address,uint256)」函数将 1 个 DAI 转移到地址「0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045」的 ABI 编码的结果字节。


现在有很多工具可以对交易进行 ABI 编码(如:https://chaintool.tech/calldata),而且大多数钱包都以某种方式实现 ABI 编码来与合约交互。为了这个例子,我们可以用一个叫做 cast 的命令行工具来验证上面的字节序列是否正确,它能够用特定的参数对调用进行 ABI-编码:



有什么困扰你的吗?有什么问题吗?


哦,对不起,是的。那个 100000000000000。说实话,我真的很想在这里为你提供一个更有力的论据。很多 ERC20 Token 都用 18 位小数表示。比如说 DAI。


在合约里我们只能使用无符号整数。因此,1 个 DAI 实际上被存储为 1 * 10^18 - 这是 100000000000000。


现在我们有一个漂亮的 ABI 编码的字节序列,包含在交易的「data」字段中。现在看来是这样的:



一旦我们进入交易的实际执行阶段,我们将重新审视这个「data」字段的内容。


Gas


下一步是决定为交易支付多少钱。因为请记住,所有交易都必须向花费时间和资源来执行和验证它们的节点网络支付费用。


执行交易的费用是以 ETH 支付的。而 ETH 的最终数额将取决于你的交易消耗了多少净 Gas(也就是计算成本有多高),你愿意为每个 Gas 单位的花费支付多少钱,以及网络愿意接受的最低数额。


从用户的角度来看,通常是,支付的越多,交易的速度就越快。因此,如果你想在下一个区块中向 Vitalik 支付 1 个 DAI,你可能需要设置一个更高的费用,而不是你愿意等待几分钟(或更长的时间),直到 Gas 更便宜。


不同的钱包可能采取不同的方法来决定支付多少 Gas 费。我不知道有什么单一的机制被所有人使用。确定正确费用的策略可能涉及从节点查询与 Gas 有关的信息(如网络接受的最低基本费用)。


例如,在下面的请求中,你可以看到 Metamask 浏览器插件在建立交易时向本地测试节点发送请求,以获取 Gas 费数据:



而简化后的请求-响应看起来像:



「eth_feeHistory」端点被一些节点暴露出来,允许查询交易费用数据。如果你很好奇,可以阅读这里这里玩玩它,或者看看规范这里

流行的钱包也使用更复杂的链外服务来获取 Gas 交易成本来估计,并向用户建议合理的价值。这里有一个例子,一个钱包请求了一个网络服务的公共端点,并收到了一堆有用的 Gas 相关数据:



看一下响应片段:



很酷,对吗?


无论如何,希望你能熟悉设置 Gas 费用价格并不简单,它是建立一个成功交易的基本步骤。即使你想做的只是发送 1 个 DAI。这里是一个有趣的介绍性指南,可以深入挖掘其中的一些机制,在交易中设置更准确的费用。


在一些初步的背景下,现在让我们回到实际的交易。有三个与 Gas 有关的字段需要设置:



钱包将使用一些提到的机制来为你填写前两个字段。有趣的是,每当钱包 UI 让你在某个版本的「慢速」、「常规」或「快速」交易中进行选择时,它实际上是在试图决定什么值最适合这些确切的参数。现在你可以更好地理解上面从钱包收到的 JSON 格式的响应内容了。


为了确定第三个字段的值,即 GasLimit,有一个方便的机制,钱包可以用来在真正提交交易之前模拟交易。这使他们能够确切的估计一笔交易会消耗多少 Gas,从而设定一个合理的 GasLimit。


为什么不直接设置一个巨大的 GasLimit?当然是为了保护你的资金。智能合约可能有任意的逻辑,你是为其执行付费的人。通过在交易开始时就选择一个合理的 GasLimit,你可以保护自己,避免在 Gas 费用中耗尽你账户的所有 ETH 资金的尴尬情况。


可以通过节点的 「eth_estimateGas」 端点进行 Gas 估算。在发送 1 个 DAI 之前,钱包可以利用这一机制来模拟你的交易,并确定你的 DAI 转账的正确 GasLimit。来自钱包的请求-回应可能是这样的:



在响应中,你可以看到,转账将需要大约 34706 个 Gas 单位。


让我们把这些信息纳入交易的有效载荷中:



记住,「maxPriorityFeePerGas」和 「maxFeePerGas」最终将取决于发送交易时的网络条件。上面我只是为了这个例子而设置了一些任意的值。至于为 GasLimit 设置的值,我只是把估计值增加了一点,以提交执行交易的可能性。


访问列表和交易类型


让我们简单评论一下在你的交易中设置的另外两个字段。


首先,「accessList」字段。高级使用场景或边缘场景可能需要交易提前指定要访问的账户地址和合约的存储槽,从而使交易的成本降低一些。


然而,提前建立这样的列表可能并不直接,目前节省的 Gas 可能并不那么显著。特别是对于简单的交易,如发送 1 个 DAI。因此,我们可以直接将其设置为一个空的列表。尽管记住它确实存在有原因,而且它在未来可能变得更有意义。


第二,交易类型。它在 「type」 字段中被指定。类型是交易内部内容的一个指标。我们的将是一个类型 2 的交易--因为它遵循这里指定的格式。



签署交易


节点如何知道是你的账户,而不是其他人的账户在发送交易?


我们已经来到了建立有效交易的关键步骤:签名。


一旦钱包收集了足够的信息来建立交易,并且你点击发送,它将对你的交易进行数字签名。如何签名?使用你的账户的私钥 (你的钱包可以访问),和一个涉及椭圆曲线的加密算法,称为ECDSA


对于好奇的人来说,实际上被签署的是交易类型和 RLP 编码内容之间串联的「keccak256」哈希值。



虽然你不应该有那么多的密码学知识来理解这个。简单地说,这个过程是对交易的密封。它通过在上面盖上一个只有你的私钥才能产生的聪明的印章,使其具有防篡改性。从现在开始,任何能够访问该签名交易的人(例如,以太坊节点)都可以通过密码学来验证是你的账户产生了该交易。


明确一下:签名不是加密。你的交易始终是明文的。一旦它们被公开,任何人都可以从它们的内容中获得其含义。


签署交易的过程中,毫不奇怪,会产生一个签名。在实践中是一堆奇怪的不可读的值,你通常会发现它们被称为「v」,「r」和「s」。

如果你想更深入地了解这些实际代表的内容,以及它们对还原你的账户地址的重要性,互联网是你的朋友。


你可以通过查看@ethereumjs/tx软件包来更好地了解签名实现时的样子。也可以使用ethers包中的一些实用工具。作为一个极其简化的例子,签署交易以发送 1 个 DAI 可以是这样的:



由此产生的对象将看起来像:



序列化


下一步是序列化签名的交易。这意味着将上面的漂亮对象编码成一个二进制字节序列,这样它就可以被发送到以太坊网络并被接收的节点消费。


以太坊选择的编码方法被称为 RLP。交易的编码方式如下:



其中初始字节是交易类型。


在前面的代码片段的基础上,你可以实际看到序列化的交易这样添加:



这就是在我的以太坊主网上的本地 Fork 中向 Vitalik 发送 1 个 DAI 的实际有效载荷。


提交交易


一旦建立、签署和序列化,该交易必须被发送到一个以太坊节点。


节点会提供方便的 JSON-RPC 端点,节点可以在那里接收交易请求。


发送交易使用「eth_sendRawTransaction」。下面是一个钱包在提交交易时使用的网络流量:



总结的请求-响应看起来像:



响应中包含的结果包含交易的哈希值:「bf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5」. 这个 32 字节长的十六进制字符序列是所提交交易的唯一标识符。


节点接收


我们应该如何去弄清楚当以太坊节点收到序列化的签名交易时会发生什么?


有些人可能会在 Twitter 上询问,有些人可能会阅读一些 Medium 文章。其他的人甚至可能会阅读文档,或者看视频


只有一个地方可以找到真相:在源码。让我们用go-ethereum v1.10.18(又名 Geth),一个流行的以太坊节点的实现(一旦以太坊转向 Proof-of-Stake,就是 "执行客户端")。从现在开始,我将包括 Geth 的源代码链接,以便你能跟随。


在收到对其「eth_sendRawTransaction」端点的 JSON-RPC 调用后,该节点需要对请求正文中包含的序列化交易进行分析。所以它开始对交易进行反序列化。从现在开始,节点将能更容易地访问交易的字段。


在这一点上,节点已经开始验证交易了。首先,确保交易的费用(即价格 * GasLimit)不超过节点愿意接受的最大限度(显然,默认情况下,这是一个以太币)。还有然后,确保交易是受重放保护的(按照EIP155--记得我们在交易中设置的「链 ID」字段吗?),或者节点愿意接受不受保护的交易。


接下来的步骤包括发送交易交易池(又称 mempool)。简单地说,这个池子代表了节点在某个特定时刻所知道的交易集合。就只有节点所知,这些还没有被纳入区块链。


在真正将交易纳入池中之前,节点检查它是否已经知道它。而且它的 ECDSA 签名是有效的。否则就抛弃该交易。


然后沉重的 mempool 开始。正如你可能注意到的,有很多琐碎的逻辑来确保交易池是「快乐和健康」的。


这里有相当多的重要验证。例如,GasLimit 低于区块 GasLimit,或者交易的大小不超过允许的最大,或者 nonce 是预期的,或者发送方有足够的资金来支付潜在的成本(即价值 + GasLimit*价格),等等。


虽然我们可以继续下去,但我们在这里不是要成为 mempool 专家。即使我们想这样做,我们也需要考虑,只要他们遵循网络共识规则,每个节点运营商可能采取不同的方法来管理 mempool。这意味着执行特殊的验证或遵循自定义的交易优先级规则。为了只发送 1 个 DAI,我们可以将 mempool 视为一组急切等待被拾取并被纳入区块的交易。


在成功地将交易添加到池中(并做内部记录的事情),节点返回交易哈希值。这正是我们之前在 JSON-RPC 请求-响应中看到的返回内容。


检查 mempool



如果你通过 Metamask 或任何默认连接到传统节点的类似钱包发送交易,在某些时候,它将「降落」在公共节点的 mempools 上。你可以通过自己检查 mempools 来确保这一点。


有一个方便的端点,一些节点暴露了出来,叫做「eth_newPendingTransactionFilter」。它也许是frontrunning(抢跑) bots 的好朋友。定期查询这个端点可以让我们在交易被纳入链中之前观察到,现在 1 个 DAI 进入了本地测试节点的 mempool 中。


在 Javascript 代码中,这可以通过以下方式完成:



要看到实际的「eth_newPendingTransactionFilter」调用,我们可以直接检查网络流量:



从现在开始,脚本将(自动)轮询 mempool 中的变化。这是随后的许多周期性调用中的第一个,检查变化:



在收到交易后,节点最终用它的哈希值来响应:



总结的请求 - 响应看起来像:



早些时候我说过 "传统节点",但没有解释太多。我的意思是,有一些更专业的节点具有私人内存池的特点。它们允许用户在交易被纳入区块之前,从公众那里 "隐藏 "交易。


不管具体情况如何,这种机制通常包括在交易发起者和区块构建者之间建立私人通道。Flashbots 保护服务就是一个明显的例子。实际的结果是,即使你用上面的方法来监控 mempools,你也不能通过私人通道来获取那些进入区块构建者的交易。


假设发送 1 个 DAI 的交易是通过普通通道提交给网络的,没有利用这种服务。


传播


为了使交易被包含在区块中,它需要以某种方式到达能够建立和提出交易的节点。在工作量证明以太坊中,这些节点被称为矿工。在Proof-of-Stake以太坊中,称为验证者。虽然现实往往更复杂一些。请注意,可能有一些方法可以将区块构建外包给专业服务。


作为一个普通用户,你应该不需要知道这些区块生产者是谁,也不需要知道他们在哪里。相反,你可以简单地发送一个有效的交易到网络中的任何常规节点,让它包括在交易池中,并让点对点协议做他们的事情。


一些这样的 p2p 协议将以太坊节点相互连接。除其他事项外,它们允许频繁地交换交易


从一开始,所有节点都与他们的对等节点(默认情况下,最多 50 个对等节点(Peers))一起监听和广播交易。


一旦一个交易到达 mempool,它就会被发送给所有尚未知道该交易的连接对等节点


为了提高效率,只有一个随机的连接节点子集(平方根)被发送完整交易。其余的是只发送交易哈希。如果需要的话,这些节点可以请求返回完整的交易。


一个交易不能永远停留在一个节点的 mempool 中。如果它没有被其他原因首先被丢弃(例如,池子满了,交易价格低了,或者它被更高的 nonce/价格的新交易取代)的话,它可能在一定时间后(默认为3 小时)被自动删除


在 mempool 中被认为可以被区块构建者拾取和处理的有效交易被跟踪在一个待处理交易的列表中。这个数据结构可以被区块构建者查询,以获得被允许进入链上的可处理交易。


工作准备和交易纳入


交易应该在浏览了 mempools 之后到达一个挖矿节点(至少在写这篇文章的时候)。这种类型的节点是特别重的多任务处理器。对于熟悉 Golang 的人来说,这意味着在挖矿相关的逻辑中,有相当多的 go 例程和通道。对于那些不熟悉 Golang 的人来说,这意味着矿工的常规操作不能像我想的那样被线性解释。


本节的目标有两个方面。首先,了解我们的交易是如何以及何时被矿工从 mempool 中提取的。第二,找出交易的执行在哪一点上开始。

当节点的挖矿组件被初始化时,至少有两件相关的事情发生。第一,它开始监听新交易到达 mempool 的情况。第二,一些基本的循环被触发了。


在 Geth 的行话中,用交易建立一个区块并将其密封的行为被称为 "提交工作(committing work)"。因此,我们想了解这是在什么情况下发生的。


重点是"新工作"loop。这是一个独立的例程,当节点收到不同类型的通知时,会触发工作提交。该触发器需要发送一个工作要求到该节点的另一个活跃监听器(运行在矿工的"main" loop中)。当收到这样的工作要求时,提交工作开始


节点开始进行一些初始准备。主要包括建立区块头。这包括寻找父区块,确保正在建立的区块的时间戳是正确的,设置区块编号GasLimitcoinbase 地址基本费用等任务。


之后,共识引擎被调用,进行区块头的"共识准备"。这计算出正确的区块难度取决于当前的网络版本)。如果你听说过以太坊的 "难度炸弹",你就知道了。


译者注: TheMerge 之后已经没有难度炸弹了。


接下来,区块密封上下文被创建。撇开其他动作,这包括获取最后的已知状态。这是正在建立的区块中的第一个交易将被执行的状态。这可能是我们的交易发送 1 个 DAI。


在准备好区块后,它就开始填充交易


我们达到了这里:到目前为止,我们的未决(pending)交易只是舒适地坐在节点的内存池中,与其他交易一起被拾起


默认情况下,交易在一个区块内按价格和 nonce 排序。对于我们的情况,交易在区块中的位置实际上是无关的。


现在开始按顺序执行这些交易。一个交易被执行之后,每个交易都建立在前一个交易的结果状态之上。


执行


一个以太坊交易可以被认为是一个状态转换。


状态 0:你有 100 个 DAI,Vitalik 也有 100 个。


交易:你发送 1 个 DAI 给 Vitalik。


状态 1:你有 99 个 DAI,而 Vitalik 有 101 个。


因此,执行交易需要对区块链的当前状态应用一系列的操作。产生一个新的(不同的)状态作为结果。这将被认为是新的当前状态,直到有另一个交易进来。


在现实中,这更有趣(也更复杂)。让我们来看看。


准备工作(第一部分)


用 Geth 的行话说,矿工在区块中提交交易。提交交易的行为是在一个环境中进行的。这种环境包含一个特定的状态(先不管其他的)。

因此,简而言之,提交一个交易本质上是:(1)记住当前的状态,(2)通过应用交易来修改它,(3)根据交易的成功,要么接受新状态,要么回滚到原来的状态。


有趣的事情发生在(2):应用交易


首先要注意的是,交易被变成了一个 消息「Message」。如果你熟悉 Solidity,在那里你通常会写诸如「msg.data」或「msg.sender」这样的东西,最后在 Geth 的代码中读到「message」就是欢迎你进入友好之地的标志。


当检查消息时,会很快就会注意到它与交易的至少一个区别。一条信息有一个「from」字段! 这个字段是签名者的以太坊地址,它是由交易中的公共签名衍生出来的(还记得奇怪的「v」、「r」和「s」字段吗?)。


现在,执行的环境被进一步准备。首先,与区块相关的环境被创建,其中包括区块编号、时间戳、coinbase 地址和区块 GasLimit 等内容。然后...


野兽走了进来,它就是以太坊虚拟机。


以太坊虚拟机(EVM),负责执行交易的基于堆栈的256 位计算引擎,我们可以期待它做什么?


EVM 是一台机器。作为一台机器,它有一套可以执行的指令(又称操作码)。该指令集多年来一直在变化。因此,一定会有段代码告诉 EVM 今天应该使用哪些操作码。当 EVM实例化解释器时,它选择正确的操作代码集,取决于正在使用的版本。


最后,在真正的执行之前有两个最后步骤。EVM 的交易上下文被创建(在你的 Solidity 智能合约中使用过「tx.origin」或「tx.gasPrice」吗?),EVM 被赋予访问当前状态的权限。


准备工作 (第二部分)


现在轮到 EVM 执行状态转换了。给定一个信息、一个环境和原始状态,它将使用一组有限的指令来转移到一个新的状态。其中,维塔利克有 1 个额外的 DAI。


在应用状态转换之前,EVM 必须确保它遵守特定的共识规则。让我们看看这一点是如何做到的。


验证开始于 Geth 所说的"预检查",它包括:


1. 验证信息的 nonce。必须与信息的 "from"地址的 nonce 匹配。此外,它必须不是可能的最大 nonce(通过检查 nonce +1 是否会导致溢出)。

2. 确保与信息的「from」地址相对应的账户没有代码。也就是说,交易起源是一个外部拥有的账户(EOA)。从而遵守EIP 3607规范。

3. 验证交易中设置的「maxFeePerGas」(Geth 中的「gasFeeCap」)和「maxPriorityFeePerGas」(Geth 中的「gasTipCap」)字段是在预期范围内。此外,优先权费用不大于最大费用。并且 maxFeePerGas大于当前区块的基本费用。

4. 购买 Gas,检查账户是否能够支付它打算消费的所有 Gas。而且该区块中还有足够的 Gas来处理这笔交易。最后让账户提前支付Gas 费用(别担心,以后还有退款机制)。


接下来,EVM核算交易消耗的 "内在 (intrinsic) Gas"。在计算内在 Gas 时,有几个因素需要考虑。首先,交易是否是合约创建。我们这里不是,所以 Gas 初始为 21000 个单位。之后,信息的 "数据 "字段中的非零字节的数量也被考虑在内。每个非零字节收取16 个单位(遵循本规范)。每个零字节只收取4 个单位的费用。最后,如果我们提供访问列表,一些更多的 Gas 将被提前计算。


我们将交易的「value」字段设置为零。如果我们指定一个正值,现在将是 EVM 检查发送方账户是否真的有足够的余额以执行 ETH 转账的时刻。此外,如果我们设置了访问列表,现在它们将被初始化为状态


正在执行的交易并不是在创建一个合约。EVM 知道它因为「to」字段不是零。因此,它将起者的账户 nonce增加1,并执行一个调用


调用将从 from 到 to 信息的地址,传递 data,没有 value,以及消耗内在 Gas 后剩下的任何 Gas。


调用


DAI 智能合约存储在地址「0x6b175474e89094c44da98b954eedeac495271d0f」。这就是我们在交易的「to」字段中设置的地址。这个初始调用是为了让 EVM 执行存储在它那里的任何代码,逐个操作码执行。


操作码是 EVM 的指令,用十六进制数字表示,范围从 00 到 FF。尽管它们通常用它们的名字来指代。例如,「00」是「STOP」,「FF」是「SELFDESTRUCT」。一个方便的操作码列表可以在evm.codes上找到。


那么 DAI 的操作码到底是什么?很高兴你这么问:



不要惊慌。现在要想弄清这一切还为时尚早。


让我们慢慢开始,把初始调用分解。它的简要文档提供了一个很好的总结:



首先,逻辑检查是否已经触及调用深度限制。这个限制是设置为 1024,这意味着在一个交易中最多只能有 1024 个嵌套调用。这里是一篇有趣的文章,可以阅读关于 EVM 这种行为背后的一些推理和微妙之处。稍后我们将探讨如何增加/减少调用深度。


相关的附带说明:调用深度限制 并不是 EVM 的堆栈大小限制 -- 堆栈大小(巧合?)也是 1024 个元素


下一步是确保,如果在调用中指定了一个正的 value,那么发送方有足够的余额来执行转移(执行几步之后)。我们可以忽略这一点,因为我们调用的 value 是零。此外,一个当前状态的快照被拍摄下来。这允许在失败时轻松恢复任何状态变化。


我们知道 DAI 的地址指的是一个有代码的账户。因此,它必须已经存在于以太坊的状态空间里。


然而,让我们暂时想象一下,这不是一个发送 1 个 DAI 的交易。假设它是一个没有价值的垃圾交易,目标是一个新的地址。相应的账户将需要被添加到状态。然而,如果该账户缺只是空的呢?除了浪费节点的磁盘空间之外,似乎没有理由对它进行跟踪。EIP 158对以太坊协议进行了一些修改,以帮助避免这种情况的发生。这就是为什么你在调用任何账户时看到这个「if」条件。


我们知道的另一件事是,DAI不是预编译合约。什么是预编译合约?下面是以太坊黄皮书提供的内容:



简而言之,在以太坊的状态下,(到目前为止)有 9 个不同的特殊合约。这些账户(范围从0x00000000000000000000000000010x0000000000000000000009)开箱即包含执行黄皮书中提到的操作的必要代码。当然,你可以在 Geth 的代码中自己检查其实现


为了给预编译合约的故事增添一些色彩,请注意,在以太坊主网中,所有这些账户的余额至少有 1wei。这是故意的(至少在用户开始错误地发送以太币之前)。看,这里有一个近 5 年的交易向「0x0000000000000000000000000000000009」预编译的账户发送了 1wei。


不管怎样。在意识到调用的目标地址并不对应于预编译的合约后,节点从状态中读取账户的代码。然后确保它是不空的。最后,命令 EVM使用它的解释器,用给定的输入(交易的「data」字段的内容)来运行该代码。


解释器 (第一部分)


现在是 EVM 实际执行 DAI 代码的时候了。为了完成这个任务,EVM 手头有几个元素。它有一个堆栈,可以容纳多达 1024 个元素(尽管只有前 16 个元素可以通过可用的操作码直接访问);它有一个易失性的读/写内存空间;它有一个程序计数器;它有一个特殊的只读内存空间,叫做 calldata保存调用的输入数据。还有一写其他东西。


像往常一样,在进入多汁的东西之前有一些必要的设置和验证。首先,调用深度递增1。其次,如果有必要,只读模式被设置。我们的调用不是只读的(见这里传递的「false」参数)。否则一些 EVM 操作将不被允许。这包括改变状态的 EVM 指令「SSTOR E」「CREAT E」「CREATE2」「SELFDESTRUCT」「CALL」 有正值,和「LOG」 。


解释器现在进入了执行循环。它包括按顺序执行DAI 代码中由程序计数器和当前 EVM 指令集所指示的操作码。目前我们使用的是伦敦指令集--这是在解释器第一次实例化时在跳转表中配置的


循环还负责保持一个健康的堆栈(避免上下溢出)。并花费每个操作的固定 Gas 成本,以及适当时的动态 Gas 成本。这些动态成本包括,例如,EVM 内存的扩展(阅读更多关于内存扩展成本的计算这里)。请注意,Gas 是在执行操作码之前(--而不是之后)消耗的。


每个可能指令的实际行为可以在这个 Geth 文件中找到实现。只要略微浏览一下,就可以开始看到这些指令是如何与堆栈、内存、Calldata 和状态一起工作的。


在这一点上,我们需要直接跳到 DAI 的操作码中,并为我们的交易跟踪它们的执行。然而,我不认为这是处理这个问题的最好方法。我宁愿先从 EVM 和 Geth 中走出来,然后进入 Solidity 领地。这应该给我们一个更有价值的关于 ERC20 转移操作的高级行为的概述。


Solidity 执行


DAI 智能合约是用Solidity编码的。它是一种面向对象的高级语言,当被编译时,输出 EVM 字节码,能够在 EVM 兼容的链上部署智能合约 (在我们的例子中是以太坊)。


DAI 的源代码可以找到在区块浏览器中验证,或在 GitHub。为了便于参考,我将会指向第一个。


在我们开始之前,让我们始终牢记,EVM 对 Solidity 一无所知。它对其变量、函数、合约的布局、ABI 编码等一无所知。以太坊区块链存储的是普通的 EVM 字节码,而不是花哨的 Solidity 代码。


你可能会问,为什么当你去任何区块浏览器时,他们会在以太坊地址上向你显示 Solidity 代码。嗯,这只是一个幌子。在大多数区块浏览器中,人们可以上传 Solidity 源代码,而浏览器则负责用特定的编译器设置来编译该源代码。如果编译器产生的输出与区块链上的指定地址存储的内容相匹配,那么合约的源代码就被称为 "验证"。从那时起,任何导航到该地址的人都会看到该地址的 Solidity 代码,而不是只看到存储在该地址的 EVM 字节码。


上述情况的一个非微不足道的后果是,在某种程度上,我们相信区块浏览器会向我们展示合法的代码(这不一定是真的,即使是意外)。不过这可能有替代方案--除非每次你想读一个合约时,都要对照自己的节点来验证源代码。


无论如何,现在回到 DAI 的 Solidity 代码。


在 DAI 的智能合约上(用 Solidity v0.5.12编译),让我们专注于函数的执行:「transfer」。



当「transfer」运行时,它将调用另一个名为「transferFrom」的函数,然后返回后者返回的任何布尔标志。「transfer」的第一个和第二个参数(这里称为「dst」和「wad」)被直接传递给「transferFrom」。这个函数另外读取发起者的地址(在「msg.sender」中作为一个Solidity 全局变量)。


对于我们的例子,这些将是传递给「transferFrom」的值:



让我们看看「transferFrom」函数,然后:



首先,发起者的余额被检查与被转移的金额相对照。



这很简单:你转移的 DAI 不能多于你的余额。如果我没有 1 个 DAI,执行将在这一点上停止,返回一个错误信息。请注意,每个地址的余额都在智能合约的存储中被跟踪。在一个名为「balanceOf」的 Map 的数据结构中。如果你至少有 1 个 DAI,我可以向你保证你的账户地址在那里的某个地方有记录。


第二,Token allowances 被验证



这与我们现在没有关系。因为我们没有代表另一个账户执行转账。虽然要注意到这是所有 ERC20 Token 应该实现的机制--DAI 不是例外。实质上,你可以授权其他账户从你的账户转账 DAI Token。


第三,实际进行余额互换



当发送 1 个 DAI 时,发送方的余额减少了 100000000000000,接收方的余额增加了 100000000000000。这些操作是在「balanceOf」数据结构上进行读写的。值得注意的是使用了两个特殊的函数「add」和「sub」来进行计算。


为什么不简单地使用「+」和「-」运算符?


记住:这个合约是用 Solidity 0.5.12 编译的。在那个时候,编译器并没有像今天这样包括上/下溢检查。因此,开发者必须记住(或被提醒),在适当的地方自己实现它们。因此在 DAI 合约中使用了「add」和「sub」。它们只是自定义的内部函数,用于执行加法和减法,并带有约束检查以避免算术问题。



add 函数将 x 和 y 相加,如果运算结果小于 x,则停止执行(从而防止整数溢出)。

sub 函数从 x 中减去 y,如果操作的结果大于 x,则停止执行(从而防止整数下溢)。


第四,触发一个转移事件(正如ERC20 规范所建议的)。



一个事件是一个记录操作。在事件中发出的数据后可以从读取区块链的链外服务中获取,但绝不会被其他合约获取。


在我们的转账操作中,发出的事件似乎记录了三个元素。发起者的地址「0x6fC27A75d76d8563840691DDE7a947d7f3F179ba」,接收者的地址「0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045」,和发送金额「100000000000000」。


前两个对应于事件声明中标记为「indexed」的参数。索引参数有利于数据的检索,允许过滤任何相应的记录值。除非事件被标记为「匿名」,否则事件的标识符也会作为一个主题被包含。


因此,更具体地说,我们正在处理的「Transfer」事件实际上记录了 3 个主题(事件的标识符、发起者的地址和接受者的地址)和 1 个值(转移的 DAI 数量)。一旦我们涉及到低级别的 EVM 的东西,我们将涵盖关于这个事件的更多细节。


在函数的最后,布尔值 "true "被返回(正如ERC20 规范所建议的)。



这是一种信号,表明转移被成功执行。这个布尔标志被传递给启动调用的「transfer」函数(它也简单地返回它)。


这就是了! 如果你曾经发送过 DAI,这就是你所执行的逻辑。这就是你花钱让一个全球去中心化的节点网络为你做的工作。


等一下。我可能偏得有点远了。因为正如我之前告诉你的,EVM 对 Solidity 一无所知。节点不执行 Solidity。它们执行的是 EVM 的字节码。


是时候进行真正的交易了。


EVM 执行


在这一节中,将变得相当技术化。我假设你对 EVM 的字节码比较熟悉。如果你不熟悉,我强烈建议你阅读这个专栏这个系列。在那里,你会发现本节中的很多概念都有单独和更深入的解释。


DAI 的原始字节码是很难阅读的 -- 我们已经在上一节见证了它。研究它的一个更漂亮的方法是使用反汇编的版本。你可以在这里找到 Dai 的反汇编字节码 (为了便于参考,我已经把它提取到这个 gist中)。


空闲内存指针和调用的值


如果你已经熟悉 Solidity 编译器,前三条指令不应该感到惊讶。它只是在初始化空闲内存指针。



Solidity 编译器为内部的东西保留了从「0x00」到「0x80」的内存插槽。所以「空闲内存指针」是一个指向 EVM 内存中第一个可以自由使用的插槽的指针。它存储在「0x40」,初始化时指向「0x80」。


请记住,所有 EVM 操作码在 Geth 中都有对应的实现。例如,你可以真正看到「MSTORE」的实现是如何弹出两个堆栈元素并向 EVM 内存写入一个 32 字节的字:



在 DAI 的字节码中,接下来的 EVM 指令确保调用不持有任何价值。如果它有,执行将在「REVERT」指令处停止。注意使用「CALLVALUE」指令(在此实现来读取当前调用的 Value。



我们的调用没有持有任何值(交易的「value」字段被设置为零)--所以我们可以继续。


验证 calldata(第一部分)


接下来:由编译器引入的另一个检查。这一次,它要弄清楚 calldata 的大小(通过「CALLDATASIZE」指令获得--在这里实现是否低于 4 字节(见下面的「0x4」和「LT」指令)。在此案例中,它将跳到「0x142」位置。在「0x146」位置的 REVERT 指令上停止执行。



这意味着在 DAI 智能合约中,calldata 的大小被强制要求为至少 4 字节。这是因为 Solidity 使用的 ABI 编码机制用其签名的 keccak256 哈希值的前四个字节来识别函数 (通常称为 "函数选择器" - 见规范 - Solidity 文档)。


如果 calldata 没有至少 4 个字节,就不可能识别出该函数。所以编译器引入了必要的 EVM 指令,以在此案例中提前失败。这就是你在上面目睹的情况。


为了调用「transfer(address,uint256)」函数,calldata 的前四个字节必须与函数的选择器匹配。这四个字节是:



与我们之前建立的交易的「data」字段的前 4 个字节完全相同:



现在 calldata 的长度已经得到验证,是时候使用它了。请看下面如何将前 4 个字节的 calldata 放在堆栈的顶部(这里需要注意的主要 EVM 指令是「CALLDATALOAD」,这里实现)。



实际上「CALLDATALOAD」将32 字节的 calldata推到堆栈中。它需要用「SHR」指令来截断,以保留前四个字节。


函数调度器


不要试图逐行理解下面的内容。相反,注意突出的高级模式就好。我会添加一些分界线,使之更加清晰:



一些被推送到堆栈的十六进制值有 4 个字节长,这并不是巧合。这些确实是函数选择器


上面这组指令是 Solidity 编译器产生的字节码的一个常见结构。它通常被称为 "函数调度器"。它类似于一个 if-else 或 switch 流程。它只是试图将 calldata 的前四个字节与合约的函数的已知选择器集合相匹配。一旦它找到一个匹配项,执行将跳到字节码的另一个部分。在那里,该特定函数的指令被放置在这个部分。


按照上述逻辑,EVM 将 calldata 的前四个字节与 ERC20「transfer」函数的选择器相匹配:「0xa9059cbb」。并跳转到字节码位置「0x6b4」。这就是告诉 EVM 开始执行 DAI 的转移。


验证 calldata(第二部分)


在匹配了选择器和跳转后,现在 EVM 要开始运行与函数有关的具体代码了。但在跳转到其细节之前,它需要以某种方式记住位置,一旦所有与功能相关的逻辑被执行,在哪里继续执行。


做到这一点的方法是简单地保持堆栈中适当的字节码位置。请看下面正在推送的「0x700」值。它将在堆栈中徘徊,直到在某个时间点(稍后)被检索到,并被用来跳回以结束执行。



现在让我们更具体地了解一下「transfer」函数。


编译器嵌入了一些逻辑,以确保 calldata 的大小对一个有两个「address」和「uint256」类型的参数的函数是正确的。对于「transfer」函数,至少是 68 字节(4 字节用于选择器+64 字节用于两个 ABI 编码的参数)。



如果 calldata 的大小更小,执行将在位置「0x6c9」的「REVERT」处停止。由于我们的交易的 calldata 已经被正确的 ABI 编码,因此有适当的长度,执行会跳到位置「0x6ca」。


读取参数


下一步是让 EVM 读取 calldata 中提供的两个参数。这些是 20 字节长的地址「0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045」和数字「100000000000000」(十六进制的「0x0de0b6b3a7640000」)。两者都是以 32 个字节为一组的 ABI 编码。因此,需要进行一些基本的操作来读取正确的数值,并把它们放在堆栈的顶部。



为了更加直观,在依次应用上述指令集后(直到「0x6fb」),堆栈顶部看起来像这样:



这就是 EVM 如何迅速地从 calldata 中提取两个参数,将它们放在堆栈中供将来使用。


上面的最后两条指令(字节码位置「0x6fc」和「0x6ff」)只是让执行跳到位置「0x1df4」。让我们在那里继续。


「transfer」函数


在简单的 Solidity 分析中,我们看到「transfer(address,uint256)」函数是一个包装器,调用更复杂的「transferFrom(address,address,uint256)」函数。编译器将这种内部调用翻译成这些 EVM 指令



首先注意推送值「0x1e01」的指令。这就是指示 EVM「记住」它应该跳回的确切位置,以便在即将到来的内部调用后继续执行。


然后,注意「CALLER」的使用(因为在 Solidity 中,内部调用使用「msg.sender」)。以及两个「DUP5」指令。这些都是把「transferFrom」的三个必要参数放在堆栈的顶部:调用者的地址,接收者的地址,以及要转移的金额。后两个参数已经在堆栈的某处,因此使用了「DUP5」。现在堆栈的顶部有所有必要的参数:



最后,在指令「0x1dfd」和「0x1e00」之后,执行跳转到位置「0xa25」。在那里 EVM 将开始执行与「transferFrom」函数对应的指令。


「transferFrom」函数


首先需要检查的是发送方是否有足够的 DAI 余额--否则将被回退。发送方的余额被保存在合约存储器中。然后需要的基本 EVM 指令是「SLOAD」。然而,「SLOAD」需要知道什么存储槽需要被读取。对于映射(在 DAI 智能合约中保存账户余额的 Solidity 数据结构的类型),这不是那么直接的告诉。


我不会在这里深入研究合约存储中 Solidity 状态变量的内部布局。你可以阅读它这里是 v0.5.15。我只想说,给定映射「balanceOf」的键地址「k」,它相应的「uint256」值将被保存在存储槽「keccak256(k . p)」,其中「p」是映射本身的槽位置,「.」是连接。你可以自己做数学题。


参考状态变量的存储空间 。


为了简单起见,我们只强调几个需要发生的操作。EVM 必须 i) 计算映射的存储槽,ii) 读取数值,iii) 将其与要转账的数量(已经在堆栈中的数值)进行比较。因此,我们应该看到像 "SHA3 "这样的指令用于散列,"SLOAD" 用于读取存储,"LT "用于比较。



如果发送方没有足够的 DAI,执行将在「0xa6f」处继续,最后在「0xadb」处碰到「REVERT」。由于我没有忘记在我的发送方账户余额中装入 1 个 DAI,那么让我们继续到「0xadc」位置。


下面一组指令对应于 EVM 验证调用者是否与发起者的地址相符(记得合约中的「if (src != msg.sender ...) { ...}」合约中的代码段)。



既然不匹配,就在「0xdb2」位置继续执行。


下面这段代码没有让你想起什么吗?检查一下正在使用的指令。同样,不要单独一行行理解。用你的直觉来发现高级模式和最相关的指令。



感觉它类似于从存储器中读取映射,那是因为它就是这样! 上面是 EVM 从「balanceOf」映射中读取发送方的余额。


然后执行跳转到「0x1e77」的位置,这里是「sub」函数的主体。


「sub」函数将两个数字相减,在整数下溢时恢复到原状。我这里没有写字节码,尽管你可以在这里 找到他。算术运算的结果被保存在堆栈中。


回到对应于「transferFrom」函数主体的指令,现在减法的结果将被写入存储空间-更新「balanceOf」映射。试着注意下面的计算,以获得映射项的适当存储槽,这通过「SSTORE」指令的执行。这条指令是有效地将数据写入状态的指令--也就是更新合约的存储。



一组相当类似的操作码被运行以更新接收者的账户余额。首先是从存储中的「balanceOf」映射中读取。然后使用「add」函数将余额加到正在转移的金额上。最后,结果被写到适当的存储槽


事件记录(Log)


在合约的代码中,「Transfer」事件是在更新余额之后发出的。因此,在分析的字节码中必须有一组指令来处理这种带有适当数据的事件。

然而,事件是另一个属于 Solidity 的幻想世界的东西。在 EVM 世界中,事件对应于记录操作。


记录是通过可用的「LOG」指令集进行的。有几个变体,取决于有多少个主题要被记录。在 DAI 的案例中,我们已经注意到,发出的「Transfer」事件有 3 个主题。


那么找到一组运行「LOG3」指令的指令就不奇怪了。



在这些指令中,至少有一个值是突出的:「0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef」. 这是该事件的主要标识符。也叫主题 0。它是编译器在编译时计算的一个静态值(嵌入在合约的运行时字节码中)。如前所述,事件签名的哈希值:



就在到达「LOG3」指令之前,堆栈看起来像这样:



那么转移的金额在哪里?在内存中! 


在到达「LOG3」之前,EVM 首先被指示将金额存储在内存中。这样它就可以在以后被记录指令消耗掉。如果你看一下「0xf21」位置,你会看到「MSTORE」指令负责这样做。


所以一旦到达「LOG3」,EVM 就可以安全的从内存中抓取实际记录的数值,从偏移量「0x80」开始,读取「0x20」字节(上面的前两个堆栈元素)。


另一种理解日志的方法是看它的在 Geth 中的实现。在那里你会发现一个负责处理所有日志指令的单一函数。你可以看到 i) 一个空的主题数组被初始化,ii) 内存偏移量和数据大小从堆栈中读取,iii) 主题从堆栈中读取插入数组中,iv) 值从内存中读取,v) 包含它被发出的地址、主题和值的日志被附加


这些日志后来是如何还原的,我们很快就会发现。


返回值


「transferFrom」函数的最后一件事是返回布尔值「true」。这就是为什么在「LOG3」之后的第一条指令只是将「0x1」的值推到堆栈中。



接下来的指令准备让堆栈退出「transferFrom」函数,回到它的包装「transfer」函数。请记住,这个下一步跳转的位置已经存储在堆栈中了--这就是为什么你在下面的操作码中没有看到它。



回到「transfer」函数中,要做的就是为最后的跳转准备堆栈。到一个执行将被结束的位置。这个即将跳转的位置之前也已经存储在堆栈中了(还记得被推送的「0x700」值吗?)



剩下的就是为最后一条指令准备堆栈:「RETURN」。这条指令负责从内存中读取一些数据,并将其传回给原始调用者。


对于 DAI 转账,返回的数据将简单地包括由「transfer」函数返回的「true」布尔标志。记住,这个值已经放在堆栈里了。


EVM 开始抓取第一个可用的空闲内存位置。这是通过读取空闲内存的指针来完成的:



接下来,必须用「MSTORE」将该值存储在内存中。虽然不是那么直接地告诉你,下面的指令只是编译器认为最适合为「MSTORE」操作准备堆栈的指令。



「RETURN」指令从内存中复制返回的数据。所以它需要被告知要读取多少内存,以及从哪里开始。下面的指令简单的告诉 EVM 从内存中读取并返回「0x20」字节,从空闲内存指针开始。



返回值 "0x0000000000000000000000000000000000000000000000000001"(对应于布尔值 "true")。


执行停止。


解释器 (第二部分)


字节码的执行已经结束。解释器必须停止迭代。在 Geth 中,它是这样做的:



这意味着「RETURN」操作码的执行应该以某种方式返回一个错误。即使是像我们这样成功的执行。事实上,它确实。尽管它作为一个标志--当它与成功执行「RETURN」操作码所返回的标志相匹配时,错误实际上被删除


Gas 退款和付款


随着解释器运行的结束,我们回到了最初触发它的调用中。该运行被成功完成。因此,返回的数据和任何剩余的 Gas 被简单地返回

调用也完成了。执行是在包裹状态转换之后进行的。


首先提供 Gas 退款。它被添加到交易中任何剩余的 Gas 中。退款金额的上限是所使用 Gas 的 1/5(由于EIP 3529)。所有现在可用的 Gas(剩余的加上退还的)被以 ETH 形式支付到发起者的账户,按照发起者在交易中最初设定的费用价格。所有剩余的 Gas 被重新添加到区块中的可用 Gas 中,以便后续交易可以消耗这些 Gas。


然后向 coinbase 地址(PoW 中的矿工地址,PoS 中的验证者地址)支付最初承诺小费。有趣的是,对执行过程中使用的所有 Gas 都进行支付。即使其中一些后来被退还了。此外,请注意这里*有效的小费是如何计算的。不仅注意到它被 "maxPriorityFeePerGas" 交易字段所限制。但更重要的是,意识到它不包括基本费用(base-fee)! 这没有错 -- 以太坊喜欢看着 ETH 燃烧


最后执行结果被包裹在一个更漂亮的结构中。包括使用的 Gas,任何可能中止执行的 EVM 错误(在我们的例子中没有),以及从 EVM 返回的数据。


建立交易收据


代表执行结果的结构现在被传递 向上 返回 。在这一点上,Geth 对执行状态做了一些内部清理。一旦完成,它就会累积交易中使用的 Gas(包括退款)。


最重要的是,现在是创建交易收据的时候了。收据是一个总结与交易执行有关的数据的对象。它包括的信息有:执行状态(成功/失败),交易的哈希值使用的 Gas 单位创建合约的地址(在我们的例子中没有),发出的日志交易的 bloom 过滤器,和其他


我们很快就会检索到我们交易的收据的全部内容。


如果你想深入了解交易的日志和 bloom filter 的作用,请查看noxx 的文章


挖掘区块


后续交易的执行继续发生,直到区块的空间耗尽。


这时节点会调用共识引擎来最终完成该区块。在 PoW 中,这需要积累挖矿奖励(向 coinbase 地址发放 ETH 的全额奖励,以及其他区块部分奖励)并相应地更新区块的最终状态根


接下来,实际的区块被组装,将所有数据放在正确的位置。包括头的交易哈希收据哈希等信息。


现在为真正的 PoW 挖矿做好了所有准备。一个新的 "任务"被创建并推送给正确的监听者。委托给共识引擎的挖掘任务开始。


我不会详细解释 PoW 的实际开采是如何进行的。互联网上已经有很多关于它的内容。只需注意,在 Geth 中,这涉及一个多线程的尝试和错误过程,以找到一个数字满足一个必要条件。不用说,一旦以太坊切换到权益证明,挖掘过程的处理方式将有很大不同。


挖出的区块被推送到适当的 channel在结果循环中接收。其中收据和日志会相应地更新,并在其被有效挖出后提供最新的区块数据。


区块最后被写入链中,置于链的顶端。


广播区块


下一步是向整个网络宣布,一个新的区块已经被开采出来。同时,该区块在内部存储到待定区块集合。耐心地等待其他节点的确认。

公告已经完成发布一个特定的事件,被挖掘的广播循环 (loop)接收。在那里,该区块被完全传播到一个子网的对等节点,并以较轻的方式提供给其他人


更具体地说,广播需要向连接的对等节点的平方根发送块数据。在内部实现是将数据推送到块通道(channel)队列,直到其通过p2p 层发送。p2p 消息被识别为 NewBlockMsg。其余节点的收到一个包括区块哈希的轻量级通告


请注意,这只对 PoW 有效。在权益证明中,区块传播将发生在共识引擎上


验证区块


对等节点不断监听消息。每种类型的可能的消息都有一个相关的处理程序,一旦收到相应的消息,就立即调用


因此,在得到带有区块数据的 "NewBlockMsg "消息时,其相应的处理程序被执行。处理程序对消息进行解码并对传播的块运行一些早期验证。这些验证包括对报头数据的初步理智检查,主要是确保它们被填充和约束。以及对区块的uncle交易哈希值进行验证。


然后发送消息的对等节点被标记为拥有该区块。从而避免了以后将区块传播回给它。


最后,数据包被向下传递第二个处理程序,在那里区块将被入队导入到链的本地副本。入队是通过向相应的通道(channel)直接发送导入请求完成的。当请求被拾取时,它就会触发实际的入队操作。最后推送区块数据到队列中。


该区块现在在本地队列中,准备被处理。这个队列在节点的区块提取器主循环中被定期读取。当区块到达前面时,节点将拾取它并尝试导入


在实际插入候选块之前,至少有两个值得强调的验证。


首先,本地链必须已经包括被传播块的父节点


第二,区块的头必须是有效的。这些验证是真正的验证。意思是说,那些对共识真正重要的,并且在以太坊的黄皮书中被指定。因此,它们是由共识引擎处理


举例来说,引擎会检查区块的工作证明是否有效,或者区块的时间戳是否不在过去不在未来太远,或者区块高度是否已经正确增加,等等。


在验证了它符合共识规则之后,整个区块被进一步传播到一个对等节点的子集。然后才是实际的导入运行


在导入过程中会有很多事情发生。所以我将直接切入正题。


几个额外的验证之后,父区块状态被检索。这是新区块的第一个交易将被执行的状态。以它为参考点,整个区块被处理。如果你曾经听说过所有以太坊节点都要执行和验证每一笔交易,现在你可以确定了。之后,完成后状态被验证(见如何这里)。最后,该区块被写入到本地链。


成功导入继续发布该区块到其他节点的对等物上(不是完全广播)。


整个验证过程被复制到所有收到区块的节点。很大一部分会接受它进入他们的本地链,以后会有更多的区块到达,插入到它的上面。


检索交易


在包含交易的区块上挖出几个区块后,就可以开始安全地假设交易确实已经被确认。


从链上找回交易是非常简单的。我们所需要的是它的哈希值。方便的是,在我们第一次提交交易的时候就已经获得了它。


交易本身的数据,加上区块的哈希值和编号,总是可以在节点的「eth_getTransactionByHash」端点上检索到。不出所料,它现在返回:



交易的收据可以在「eth_getTransactionReceipt」端点请求。根据你运行这个查询的节点,你可能还会得到预期交易收据数据之外的额外信息。这是我从 mainnet 的本地分叉中得到的交易收据:



你看到了吗?它说「"status":1」.


这只能说明一件事:成功


后记


这个故事绝对可以有更多的内容。


从某种意义上说,它是永无止境的。总会有一个更多的注意事项。还有一个附带说明。一个替代的执行路径。另一个节点的实现。另一个我可能已经跳过的 EVM 指令。另一个以不同方式处理事情的钱包。所有的事情都会使我们更接近于找到当你发送 1 个 DAI 时发生的「真相」。


幸运的是,这不是我打算做的事。我希望最后的+1 万字没有让你这么想。请允许我在这里阐明一些情况。


事后看来,这篇文章是混合了好奇心和挫折感的副产品。


好奇是因为我做以太坊智能合约安全已经超过 4 年了,但我还没有像我希望的那样花更多的时间来手动深入探索基础层的复杂性。我真的想获得第一手的经验,看看以太坊本身的实际实现。但智能合约总是被挡在中间。现在我终于找到了更多的和平时光,这似乎是回归本源并开始这次冒险的正确时机。


但是好奇心是不够的。我需要一个借口。一个触发点。我知道我所想的会很艰难。所以我需要一个足够强大的理由,不仅是为了开始工作。而且,更重要的是,每当我觉得自己厌倦了试图从以太坊的代码中找出意义时,就会重新开始。


我在我没有注意的地方找到了它。我在挫折中发现了它。


沮丧的是,我们在汇款时已经习惯了绝对令人震惊的缺乏透明度的情况。如果你曾经需要在一个资本管制日益严格的发展中国家进行汇款,毫无疑问,你会感受到我的心情。所以我想提醒自己,我们可以做得更好。我决定用文字来表达我的挫折感。


这篇文章也给我提了个醒。如果你能避开那些模糊的东西、价格、JPEG 中的猴子、Ponzis、Rugpulls 和盗窃,这里仍然有价值。这不是「神奇」的互联网货币。这里有真正的数学、密码学和计算机科学。作为开放源码,你可以看到每一块的移动。你几乎可以触摸到它们。不管是哪一天,也不管是什么时候。无论你是谁。无论你来自哪里。



原文链接


欢迎加入律动 BlockBeats 官方社群:

Telegram 订阅群:https://t.me/theblockbeats

Telegram 交流群:https://t.me/BlockBeats_App

Twitter 官方账号:https://twitter.com/BlockBeatsAsia

举报 纠错/举报
选择文库
新增文库
取消
完成
新增文库
仅自己可见
公开
保存
纠错/举报
提交