Filscan: FEVM合约验证的那些事
TL;DR
合约验证是对给定高级语言(例如 Solidity )智能合约的源代码进行验证,确保给定源代码编译生成的字节码与在合约地址上字节码相同。源代码验证不仅提供了对智能合约的验证,还提供了代码相关的文档。通过阅读源代码和代码文档,用户可以方便地了解智能合约的设计目的和具体实现逻辑。合约验证确保了链上实际部署的智能合约与项目声称的功能一致,增强了合约代码的可信度,预防了潜在的漏洞和错误,并减少了对第三方的信任。
区块链具有去中心化、无须许可、不可篡改和不可否认的特性被广为接受。智能合约扩展了区块链的功能,允许了在没有第三方的情况下进行可信交易,当某种触发条件,合约会自动执行部署时代码中定义的业务逻辑,而且合约一旦部署,任何人无法篡改合约代码,确保了行为可信。
虽然每个智能合约的编译字节码都可以在区块链上公开获得,但低级语言对于开发人员和用户来说都很难理解。对于去信任的智能合约,合约代码应该可供独立验证。进行合约验证有如下的优点:
确保代码执行的一致性:合约验证的主要目的是确保宣传的合约源代码与实际部署在区块链上执行的字节码一致。这是因为在部署合约之前,源代码经过编译成字节码,而字节码才是在以太坊虚拟机(EVM)中执行的指令。
因此,合约验证在区块链中非常重要,确保了合约代码的一致性和可信度,预防了潜在的漏洞和错误,并减少了对第三方的信任。通过合约验证,用户和参与者可以更加放心地与智能合约进行交互,并在去中心化的网络中进行可信交易。
合约源代码验证是提升在以太坊虚拟机(EVM)上部署的智能合约透明度和安全性的有效方法。合约通过验证过后能增强用户使用体验:
要了解合约验证的过程需要我们了解智能合约是如何创造的,在区块链中是怎么样个形态,在链上是如何存储的。理解init code的作用,才会明白合约验证为什么要进行字节码对比。
在网络中,交易可以分为普通转账交易、消息调用交易和合约创建交易。
a)用户交易:这是最基本和直接的方式,用户通过发送一笔交易将合约的字节码部署到区块链网络上。
b) 工厂合约:用户通过发送交易调用工厂合约的函数来创建新的智能合约。工厂合约会根据预定义的逻辑和参数创建一个新的合约实例,并返回该合约的地址。这种方式允许用户使用相同的合约模板来创建多个合约实例,提供了更高的可扩展性和便利性。 并且,工厂合约可以通过create2操作码提供部署者地址、salt、部署合约初始化代码即可预先计算,确定想要部署的合约地址。
以下是部署交易的流程:
如图所示,在智能合约部署过程中,合约的编译后的字节码以及经过ABI(Application Binary Interface)编码的构造函数的参数一起作为交易的 data 字段通过发送交易的方式提交到区块链中,改变了区块链的世界状态。
以太坊虚拟机 (EVM)是智能合约在区块链上的运行环境,用于执行链上的智能合约。但是EVM并不能直接解释高级指令,因此智能合约的逻辑必须先被编译成字节码(即低级机器指令),然后才能在 EVM 中执行。当交易包含对合约的调用时,EVM 会按照合约的字节码指令来执行相应的操作,并对状态进行修改。
EVM 作为一个堆栈机(opens in a new tab)运行,其栈的深度为 1024 个项。 每个项目都是 256 位字,为了便于使用,选择了 256 位加密技术(如 Keccak-256 哈希或 secp256k1 签名)。在执行期间,EVM 会维护一个瞬态内存(作为字可寻址的字节数组),该内存不会在交易之间持久存在。已编译的智能合约字节码作为许多 EVM opcodes执行,它们执行标准的堆栈操作,例如 XOR、AND、ADD、SUB等。
以太坊自然语言规范格式,自然语言来描述测试用例的行为和预期结果。建议使用 NatSpec 对所有公共接口(ABI 中的所有内容)对 Solidity 合约进行完全注释。 可用于输出更规范,更可读的文档。
Solidity 编译器编译过程中生成的 JSON 文件。该文件包含有关已编译合约的两种信息:
其中源文件通过 keccak256 来锁定文件,确保文件内容不会发生变化。以 remix 编辑器中默认的 1_Storage.sol 合约为例,编译过后生成的 Metadata 如下所示:
通常 Solidity 编译器会将经过CBOR方式编码的 Metadata 文件的哈希值及一些其他信息(如 ipfs、solc 版本等)附加到合约字节码的末尾,字节码末尾的 Metadata 哈希成为锁定整个编译过程所涉及的相关信息的指纹。
当然可以通过命令行标志--no-cbor-metadata可用于跳过将 Metadata 附加到已部署字节码的末尾。同样,可以通过标准 JSON 输入中的布尔字段settings.metadata.appendCBOR设置为 false 进行跳过。
我们使用 https://cbor.me/ 来对字节码末尾的附加内容进行解析进行观察,以 remix 编辑器中默认的 1_Storage.sol 合约为例,我们从编译过后的字节码中取出 CBOR 编码内容,a26469706673582212205774c71bc1f1fa9ac0bd2216cf5308f60b734e4d6647ca359475919ba8422fb564736f6c63430008120033,其中0033为CBOR长度,即前面51个字节与 Metadata 数据相关。将该51个字节进行解析:
通过解析我们可以看到 solc 的版本及 Metadata 文件在 ipfs 上的地址,通过该地址访问 ipfs 文件系统,可以获得 Metadata 文件(前提是我们需要将该文件进行发布),注意,CBOR 字段有时候不止包括上述两个字段,长度不一定都是51个字节,请参考https://docs.soliditylang.org/en/latest/metadata.html#encoding-of-the-metadata-hash-in-the-bytecode 。
源代码的某些部分(如注释或变量名称)不会影响编译后的字节码。恶意行为者就可以在源代码中添加欺骗性注释或给出误导性的变量名称,并使用与原始源代码不同的源代码来验证合约。
Metadata 文件包含有关合约编译的信息,包括源文件及其哈希值。如果任何编译优化信息或者源文件中增加或修改了任何内容,Metadata 文件就会发生变化。因此,附加到字节码的 Metadata 哈希值也会发生变化。因此,如果给定的源代码与对应的编译设置编译过后,与链上字节码没有一个字节不同,则称为完全匹配。在完全匹配的情况下,我们可以根据链上字节码获取 Metadata 的 ipfs 地址,进而获取 Metadata 文件与合约源文件,进行编译验证。
通过上面描述我们可以知道,更改源代码中任意一字节 → 源代码哈希变化 → Metadata 变化 → Metadata 哈希变化 → 部署的字节码变化。
部分匹配是指链上合约部署的字节码与除 Metadata 哈希之外的通过 Metadata 和源文件重新编译得到的字节码完全匹配。部署的合约和给定的源代码+ Metadata 功能完全相同,但在源代码注释、变量名称或 Metadata 其他字段(例如源路径)方面可能存在差异。这是目前验证合约的常见方法。由于大多数开发者不知道完整的验证,源文件可能有些许变动,也没有保留其编译的 Metadata 文件,因此部分验证已经成为迄今为止验证合约的事实上的方法。
这种类型的匹配类似于 Etherscan 验证合约的方式。理论上匹配的源代码的功能与部署的合约相同,但显示的源代码可能会产生误导。
与 Solidity 编译器交互的推荐方法是 JSON 输入输出接口,特别是对于更复杂和自动化的设置。编译器的所有发行版都提供相同的接口。其中有些字段可能会发生变化,有些字段是可选的,但 solidity 编译器官方只进行向后兼容的更改。编译器 API 要求使用JSON格式作为输入,并且以 JSON 格式输出编译结果。无论是否存在错误,该进程都会以”成功”状态终止,不使用标准错误输出。所有错误都会以 JSON 输出的形式进行报告。以下是通过 Metadata 方式进行合约验证中的一个流程:
在合约验证过程中,需要重点考虑一些影响编译结果的关键因素。对这些因素的忽视可能会导致编译出的字节码与预期不同,从而导致合约验证失败。这些关键因素包括下面这些部分。
库代码是在发起合约(主动发起 DELEGATECALL 调用的合约)的上下文中执行的,使用 this 将会指向到主调合约,而且库代码可以访问主调合约的存储( storage )。库合约是一个独立的代码,它仅可以访问主调合约明确提供的状态变量。
如果调用 solc 时带有 --link 选项,所有输入文件都被编译成格式为 __$53aea86b7d70b31448b230b20ae141a537$__ 形式的未链接的二进制文件(十六进制编码),并被本地链接(如果从标准输入(stdin)读取输入,则被写到标准输出(stdout))。
当您的合约中有使用库合约, 您会注意到字节码中含有 __$53aea86b7d70b31448b230b20ae141a537$__ 形式的字符串。 这些是实际库的地址的占位符。此占位符是完全限定库名的keccak256哈希的十六进制编码的34个字符前缀。注意,从Solidity v0.5.0开始,占位符的格式为__$<keccak256LibraryNameHash>$__,之前的格式为__<LibraryName>__。
完全限定的库名是其源文件的路径和用 : 分隔的库名,可以通过--libraries "file.sol:Math=0x1234567890123456789012345678901234567890 file.sol:Heap=0xabCD567890123456789012345678901234567890" 来为每个库提供一个地址(用逗号或空格作为分隔符),也可以将字符串存储在一个文件中(每行一个库), 用 -libraries fileName 进行指定替换文件。不鼓励在生成的字节码上手动链接库,因为它不会更新合约 Metadata 。由于Metadata 包含编译时指定的库列表,而字节码包含 Metadata 哈希,因此您将获得不同的二进制文件,具体取决于执行链接的时间。
替换占位符的过程通常涉及将占位符与实际的库地址进行映射,并将映射关系应用于合约编译结果中的相应位置,这确保了验证过程能够正确地定位和访问库的相关代码和功能。因此在验证合约时,必须确保这些占位符被正确替换。如果占位符没有被正确替换,验证过程可能无法识别库的存在,从而导致验证失败。
在合约编译过程中,优化选项允许编译器对源代码进行一系列优化,以提高合约的执行效率和最终生成的字节码的质量。solidity 编译器使用两种不同的优化方式,老的优化器直接优化编译过后的 opcode,新的优化器优化 Yul IR 代码。
当您编译时使用 solc --optimize --bin sourceFile.sol 激活优化器。 优化器将假设合约的每个操作码在其生命周期内被调用200次。 如果您想让最初的合约部署更便宜,而后来的函数执行更昂贵,请设置为 --optimize-runs=1。 如果您期望有很多交易,并且不在乎更高的部署成本和输出大小,那么把 --optimize-runs 设置成一个高的数字。 这个参数对以下方面有影响(将来可能会改变):
常见的优化操作还包括:
总的来说,优化器旨在简化复杂的表达式,以减少代码大小和执行成本,从而降低部署合约和进行外部调用所需的 gas 费用。优化器还会针对函数进行专门优化或内联处理,以优化可能导致更多代码操作的情况,进而提高合约的效率和性能。
为了能够在所有平台上支持可重复的构建,Solidity 编译器必须抽象出存储源文件的文件系统的细节。 编译器维护一个内部数据库( 虚拟文件系统,VFS ), 每个源单元被分配一个唯一的源单元名称,这是一个不透明的非结构化的标识符。 当您使用 import 语句时, 您需要指定引用源单元名称的导入路径。
solc contract.sol \
--base-path . \
--include-path node_modules/ \
--include-path /usr/local/lib/node_modules/
当访问文件系统搜索导入文件时, --base-path 用于指定基本路径,其路径内的所有内容都是允许访问的, --include-path 选项指定相对于基本路径搜寻的目录。通过这些选项添加的路径部分将不会出现在合约Metadata中。
出于安全考虑,编译器限制了它可以访问的目录。 在命令行中只有指定的源文件的目录和重映射的目标路径是允许访问的。 通过 --allow-paths /sample/path,/another/sample/path 选项可以允许访问其他路径。导入重映射允许您将导入重定向到虚拟文件系统的不同位置。 关于重映射的信息被存储在合约Metadata中。 由于编译器产生的二进制文件中嵌入了Metadata的哈希值,对重映射的任何修改都会导致不同的字节码。因此,注意不要在重映射目标中包含任何本地信息。 例如,如果您的库位于 /home/user/packages/mymath/math.sol, 像 @math/=/home/user/packages/mymath/ 这样的重映射会导致您的主目录被包含在Metadata中。 为了能够在不同的机器上用这样的重映射重现相同的字节码,您需要在VFS和(如果您依赖主机文件系统加载器)主机文件系统中重新创建您的本地目录结构。
为了避免Metadata中嵌入您的本地目录结构,建议将包含库的目录指定为 include paths。 例如,在上面的例子中, --include-path /home/user/packages/ 会让您使用以 mymath/ 开始的导入。 与重映射不同,该选项本身不会使 mymath 显示为 @math, 但这可以通过创建符号链接或重命名软件包子目录来实现。
尽管合约验证过程中可能会遇到诸多细节性问题,但好消息是, filscan 浏览器为您屏蔽了这些繁琐细节。您只需简单几步,便能轻松完成验证,无需为烦杂配置项等问题而烦恼。合约验证地址:https://filscan.io/contract/verify/
合约验证仅需要开发者提供合约一些合约相关信息即可完成:
官方不推荐使用 enabled 和 runs 字段,仅用于向后兼容。
但在很多情况下,我们编译合约没有使用更复杂编译优化选项 detail,因此我们采取第一种模式,即可通过验证。
通常情况下我们要处理的不是单一的 Solidity 文件,因为我们在智能合约编写中经常会引入其他的合约,接口和库。这个命令会生成一个新的文件叫做“PriceFeedConsumer_flat.sol”,这个文件将会把所有的 import 都换成被引入合约,接口或者库的源代码。
在 remix 中通过 flatten 插件,右键点击需要验证的合约,进行 flatten 即能得到整合过后的文件。
假如开发者的合约项目使用 hardhat,则可以使用如下方式获取 flatten 后的合约。
npx hardhat flatten ./contracts/yourContract.sol > yourContract_flatten.sol
该命令执行完之后,项目根目录下会有一个完整的合约代码的 yourContract_flatten.sol 文件。
如果您在编译的时候使用其他优化参数。因此,简单的优化 runs 参数不能够完成验证任务,您需要在上传 Metadata 来辅助合约验证,因为 Metadata 包括您在编译合约时所需要的全部相关配置。如您在 remix 中编译时使用配置文件的方式如下所示:
编译配置如图所示
为了更好的方便用户进行合约的验证,做到无感知、无配置,零基础可以上手,我们可以选择第三种hardhat支持专用模式。Hardhat 将编译输出存储在项目内的artifacts/build-info/目录中。 该目录包含一个 .json 文件,其中包含所有合约的 Standard JSON Input-Output,这是与 Solidity 编译器交互的推荐方法,特别是在高级和自动化配置中。将其 json 文件上传即可进行验证。
注意:代理合约的验证只能验证代理合约的源代码经过编译后是否与链上的字节码一致,但实际上代理合约通过 delegatecall 来让逻辑合约完成实际的操作,因此,为了确保可靠性,需要对逻辑合约也经过验证。即不能仅仅通过验证过的单个代理合约文件来判断合约的可靠性,请仔细鉴别。
为什么合约验证不通过?
合约验证不通过,大概率是因为某个参数设置不正确,比如编译器版本,是否开启优化,优化参数,库的链接方式等。推荐使用 Metadata 方式及 hardhat 支持。
Reference Link:
联系我们
推特:https://twitter.com/FilscanOfficial
Telegram:http://t.me/filscanofficial