Solidity-abi编码规范
交易的构建过程
1. ABI
当我们在remix上通过solidity写好一份智能合约的时候编译部署并对它进行点点点调用函数和变量的时候,你是否想过一份智能合约是如何完成这种交互的?
ABI便是智能合约交互的关键,ABI(Application Binary Interface)是一个用于在以太坊网络对合约以及合约与合约之间进行交互的标准。它是一套标准化接口方案,而不是只局限于一个json文件,这是因为ABI还具体定义了如何编码各种传输的数据来和底层的以太坊生态进行交互。
1.1 ABI编码
具体而言,当我们的对一个合约的函数进行调用时,此时调用的函数和具体参数将通过ABI范式来进行编码成字节码,并传入交易的数据中去,交易数据在签名和验证之后就会在以太坊网络中改变状态数据并同步到各个节点中。
具体编码方法有两大块,一个是对函数选择器进行编码,这一步编码能让以太坊网络知道我们需要调用的是哪个函数,函数选择器的编码较为简单,它是此函数的函数签名的 Keccak 哈希的前 4 字节。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract Foo {
function bar(bytes3[2] memory) public pure {}
function baz(uint32 x, bool y) public pure returns (bool r) { r = x > 32 || y; }
function sam(bytes memory, bool, uint[] memory) public pure {}
}
函数签名是函数名称加参数的数据类型;比如上述函数bar的函数签名为: bar(bytes3[2])
之后将该函数签名用Keccak得到一个32字节的哈希值,我们截取该哈希值的前4字节,即为函数选择器的编码结果:0xfce353f6
另一个是对参数编码,这部分将告诉以太坊我们所传入的函数对应的参数是什么,具体编码规则较多,可参考引用中的solidity官方编码规范。
接着上面例子我们要调用bar这个函数,并传入参数["abc", "def"],我们通过参数编码的规则得到了如下两个参数对应的编码结果:
abc:0x6162630000000000000000000000000000000000000000000000000000000000
def:0x6465660000000000000000000000000000000000000000000000000000000000
而最后abi编码并上传的字节数据为函数选择器编码+参数编码的拼接:
0xfce353f661626300000000000000000000000000000000000000000000000000000000006465660000000000000000000000000000000000000000000000000000000000
我们也可以试着用foundry的call-data方法来运行编码,在foundry中使用cast calldata 传入函数setNumber,以及对应的uint参数2023和一个地址参数,最后它将返回我们一个编译好的字节码。
值得注意的是,foundry中cast abi-encode方法将会编译参数,而不包括选择器的编码结果,此时我们可以得到一个没有函数选择器的编译结果,比上面少了一段0x9e6b154b的内容,这段缺失的内容就是函数选择器所被编译的内容。
实际操作中,我们一般不需要如此麻烦地去手动编码发交易给节点,如ether.js,viem等库已经封装好了所需的一切操作流程。以viem的调用函数读取数据的代码为例:
import { publicClient } from './client'
import { wagmiAbi } from './abi'
const data = await publicClient.readContract({
address: '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2',
abi: wagmiAbi,
functionName: 'totalSupply',
})
// 69420n
这段代码将直接调用FBA开头的合约地址的函数totalSupply,viem根据配置好的abi.json文件找到对应函数选择器的信息并编码发送给client所配置的链和对应节点。
具体内容:readContract · Viem
这也就是abi.json文件的用处,作为一个信息配置文件整合好所有可调用的函数信息以便于我们的操作。
1.2 ABI解码
编译好的字节码本身是人类不可读的,如果我们有对应合约的abi.json文件,我们就能通过所包含的函数信息来找到字节码对应调用的函数。但是在没有的情况下,我们通常会在4bytes上寻找可能的函数信息。
比如以下的字节码,我们取前四字节,0xfce353f6在https://www.4byte.directory/中搜索
0xfce353f661626300000000000000000000000000000000000000000000000000000000006465660000000000000000000000000000000000000000000000000000000000
没错,我们找到了,而且就是之前内容中我们讲过的函数bar(),但是值得注意的是,同一个4字节哈希非常有可能对应多个函数签名,所以这里的解码只是一种分析方法,并不保证一定正确
当我们寻找到了对应的函数签名,就可以用foundry的cast calldata-decode方法来解码得到对应的参数信息。
那么为什么我们会需要用到解码呢?
解码通常在我们需要监听某些事件时需要使用,比如viem提供了createEventFilter的方法,它可以监听某一地址下的某一个事件函数的信息,该过程就是通过提供具体函数,再通过parseAbiItem解析为函数选择器的字节码,再解码来获取对应的参数信息;这样我们就能获得区块链上的转账内容。
代码文件:https://viem.sh/docs/actions/public/createEventFilter
import { parseAbiItem } from 'viem'
import { publicClient } from './client'
const filter = await publicClient.createEventFilter({
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'),
})
Recursive-length prefix (RLP) serialization | ethereum.org
2.RLP编码
说完ABI编码,我们再继续弄懂另一个编码范式,RLP编码,Recursive-length prefix(递归长度前缀编码)
为什么会有这东西?
RLP编码是对标了比特币的Base58编码规范,其目的是为了以太坊能跨平台,跨编程语言地运行在不同的节点,并完成如交易,账户,区块信息等各种复杂数据的存储和传输。所以其本质是一种和json,Protocol Buffers一样的序列化格式方法。
序列化格式方法是一种将复杂的数据结构(如对象、数组、结构体等)转换为可以在存储介质(如文件、数据库)中存储,或者在网络中传输的格式的过程。简单来说,就是把内存中的数据以某种特定的格式 “打包”,方便后续的存储和传输,并且在需要的时候能够按照相反的过程(反序列化)将其还原为原始的数据结构。 |
- 编码规则示例:
- 嵌套结构(列表):对于列表(或更复杂的嵌套结构),RLP 会递归地对每个元素进行编码。如果编码后的元素总长度
T
小于 56,RLP 编码结果的第一个字节是0xc0 + T
,然后是各个元素的编码结果依次拼接。如果T
大于等于 56,类似长字节数组的编码方式,第一个字节是0xf7 + B
(B
是表示T
所需的字节数),接着是T
的字节序列,然后是各个元素的编码结果依次拼接。例如,对于列表[0x41, [0x42, 0x43]]
,先分别编码0x41
(结果是0x41
)和[0x42, 0x43]
(假设这个子列表编码后是0x824243
),总长度T = 1 + 3 = 4
,第一个字节是0xc0 + 4 = 0xc4
,最终结果是0xc441824243
。 - 长字节数组(长度大于等于 55):对于长度
L
大于等于 55 的字节数组,RLP 编码会稍微复杂一些。首先计算表示长度L
所需的字节数B
,然后编码结果的第一个字节是0xb7 + B
,接着是表示长度L
的字节序列(以大端序存储),最后是字节数组本身。例如,一个长度为 1000(0x3e8
)的字节数组,L = 1000
,B = 2
(因为 1000 至少需要 2 个字节来表示)。编码后的第一个字节是0xb7 + 2 = 0xb9
,然后是长度的字节序列(以大端序存储,即0x03e8
),最后是字节数组本身。 - 短字节数组(长度小于 56):如果数据是一个字节数组,长度
L
在 0 - 55 范围内,RLP 编码后的结果是一个字节,其值为0x80 + L
,后面跟着字节数组本身。例如,字节数组[0x41, 0x42]
(长度为 2),RLP 编码后的第一个字节是0x80 + 2 = 0x82
,然后跟着0x41
和0x42
,最终结果是0x824142
。 - 单字节数据(0 - 127):如果数据是一个单字节,且其值在 0 - 127 范围内,那么 RLP 编码后的结果就是这个字节本身。例如,字节值为
0x41
(字符 'A' 的 ASCII 码),RLP 编码后仍然是0x41
。
- 嵌套结构(列表):对于列表(或更复杂的嵌套结构),RLP 会递归地对每个元素进行编码。如果编码后的元素总长度