跳到主要内容

ABI 应用二进制接口

什么是ABI

大家应该很熟悉 API(Application Programming Interfaces),API 是一个接口,用它来访问某个服务,可以把API 理解两个软件彼此之间进行通信的桥梁。

ABI (Application Binary Interfaces),则是用来定义了智能合约中可以进行交互的方法、事件和错误,类似可以把 ABI 理解为与EVM 进行交互的桥梁。

EVM 是以太坊虚拟机,和其他的机器一样,他们无法执行人类可读代码的, 只能够识别和运行二进制数据,这是一串由 0 和 1 所组成的数据流。因此在调用函数时,需要借助 ABI ,把人类可读函数转化为EVM可读的字节码。

Solidity - ABI - EVM 字节码

一句话总结:ABI 是 编码和解码规范,用来规范外部与 EVM 的交互,也可用于合约间的交互。

ABI 接口描述

在 Solidity 中,我们编译代码以后,会得到两个重要东西(称为artifact):bytecode(字节码) 和 ABI 接口描述。

参考 Remix IDE 一文,合约的编译与部署

ABI 接口描述是 JSON 格式的文件,定义了智能合约中外部可以进行交互的方法事件和可解释的错误

例如,下面的 Counter :

contract Counter {
uint public counter;
address private owner;

error NotOwner();
event Set(uint _value); // 定义事件

constructor() {
owner = msg.sender;
}

function set(uint x) public {
if(owner != msg.sender) revert NotOwner();
counter = x;
emit Set(x);

}
}

编译之后生成的 ABI 为:

[
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "_value",
"type": "uint256"
}
],
"name": "Set",
"type": "event"
},
{
"inputs": [],
"name": "counter",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "x",
"type": "uint256"
}
],
"name": "set",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]

这是一个 JSON 格式的数组,每个对象定义了合约方法中可公开调用的方法(函数), 合约声明的事件及错误等。

set 函数为例:

    {
"inputs": [
{
"internalType": "uint256",
"name": "x",
"type": "uint256"
}
],
"name": "set",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}

可以里面定义了type : 定义是函数、事件或错误等,name :表示函数名称、事件名称、自定义错误名称, inputs: 函数输入参数,outputs : 函数输出参数,

ABI JSON 的详细的规范可参考 Solidity 文档,这里不重复。

当我们要调用一个函数时,使用 ABI JSON 的规范的要求,进行编码,传给 EVM, 同时在 EVM 层生成的字节数据(如时间日志等),ABI JSON 的规范进行解码。

EVM - ABI 编码规范

ABI 编码

我们以调用 set() 函数为例,看看 ABI 是如何进行的,合约部署在 sepolia 网络,调用 set(10):

remix - call

区块链浏览器交易记录如下:

input -data

调用 set()函数,经过 ABI 编码后,提交到链上的数据是: 0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a

调用合约底层表现

该编码数据是如何生成的呢?

它包含两个部分:

  1. 函数选择器(前 4 个字节)
  2. 参数编码

0x60fe47b1 是函数选择器, 它是 ABI 描述中函数的签名:set(uint256) 进行 keccak256 哈希运算之后,取前4个字节:

  bytes4(keccak256("set(uint256)")) == 0x60fe47b1

参数部分 10 的十六进制是 a, 然后扩展到 32个字节, 参数编码细节可以参考文档 应用二进制接口说明

大部分时候,我们不需要了解详细的编码规则,Solidity / web3.js / ethers.js 库都提供了编码函数,例如在 Solidity 中,可通过以下代码获得完整的编码:

// 编码函数及参数 
abi.encodeWithSignature("set(uint256)", 10)

// 编码参数
uint a = 10;
abi.encode(a); // 0x000000000000000000000000000000000000000000000000000000000000000a

Solidity 编码函数

Solidity 中有 5 个函数:abi.encode, abi.encodePacked, abi.encodeWithSignature, abi.encodeWithSelectorabi.encodeCall 用于编码。

我们可以在 Chisel 里演练这几个编码函数,Chisel 是Foundry 提供的 Solidity 交互式命令工具

abi.encode

encode() 方法按EVM标准规则对参数编码,编码时每个参数按32个字节填充0 再拼在一起,当与合约交互时需要编码参数时,就使用该方法。

// 单个参数
uint a = 10;
abi.encode(a); // 0x000000000000000000000000000000000000000000000000000000000000000a

uint8 s = 2; // 占一个字节
abi.encode(s); // 0x0000000000000000000000000000000000000000000000000000000000000002

address addr = 0xe74c813e3f545122e88A72FB1dF94052F93B808f;
abi.encode(addr); // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f

// 多个参数
abi.encode(addr, a); // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a

bool b = true;
abi.encode(addr, a, b); // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001

若是动态类型的数据,编码会更加复杂:

// 动态类型的数据
uint[] memory arr = new uint[](2);
arr[0] = 1;
arr[1] = 2;

abi.encode(addr, a, b, array) // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002

参数的编码规则,可查看这里

abi.encodePacked

encodePacked 称为紧密编码,和 encode() 方法不同,参数在编码拼接时不会填充0, 而是使用实际占用的空间然后把各参数拼在一起,如果编码结果不是32字节整数倍数时,再末尾依旧会填充0)。例如在使用EIP712 时,需要对一些数据编码,就需要使用到 encodePacked

// 单个参数
uint a = 10;
abi.encodePacked(a); // 0x000000000000000000000000000000000000000000000000000000000000000a

uint8 s = 2; // 占一个字节
abi.encodePacked(s); // 0x0000000000000000000000000000000000000000000000000000000000000002

address addr = 0xe74c813e3f545122e88A72FB1dF94052F93B808f;
abi.encodePacked(addr);

bool b = true;
// 多个参数
abi.encodePacked(addr, s, b); // 0xe74c813e3f545122e88a72fb1df94052f93b808f020100000000000000000000

abi.encodeWithSignature

对函数签名及参数进行编码,第一个参数是函数签名,后面按EVM标准规则对参数进行编码,这样就可以直接获得 调用函数所需的 ABI 编码数据。

abi.encodeWithSignature("set(uint256)", 10) // 0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a

// 参考上方 addr, s, b 的定义
abi.encodeWithSignature("addUser(address,uint8,bool)", addr, s, b) // 0x63f67eb5000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001

abi.encodeWithSelector

它与abi.encodeWithSignature功能类似,只不过第一个参数为4个字节的函数选择器,例如:

abi.encodeWithSelector(0x60fe47b1, 10);
// 等价于
abi.encodeWithSelector(bytes4(keccak256("set(uint256)")), 10); // 0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a


abi.encodeWithSelector(0x63f67eb5, addr, s, b);
// 等价于
abi.encodeWithSelector(bytes4(keccak256(""addUser(address,uint8,bool)")), addr, s, b) // 0x63f67eb5000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001

abi.encodeCall

encodeCall 可以通过函数指针,来对函数及参数编码,在执行编码时,执行完整的类型检查, 确保类型匹配函数签名。例如:

interface IERC20 {
function transfer(address recipient, uint amount) external returns (bool);
}

contract EncodeCall {
function encodeCallData(address _to, uint _value) public pure returns (bytes memory) {
return abi.encodeCall(IERC20.transfer, (_to, _value));
}
}

ABI 解码

解码是编码的”逆过程“, 区块链浏览器为何能把我们提交给链上的 0x60fe47b1000000...0a 显示为函数set(uint256 x), 就是对数据进行了解码。

input -data

准确来说,仅能对参数进行解码,函数选择器的计算过程中, 使用了 keccak256 哈希运算,哈希是不可逆的。

但当我们开源合约代码之后, 区块链浏览器可以计算出所有函数的函数选择器,从而可以通过函数选择器匹配对应的函数签名。

ABI 解码一个重要的使用场景是,解析交易中的事件日志

在刚才的交易中,链上记录了如下日志:

solidity logs 日志

日志的包含Topics 和 Data 两部分,我们该如何获知其表示的含义呢?

其实,Topics 的第一个主题是事件签名的 Keccak256 哈希,在上面 ABI JOSN 描述中,包含 Set 事件的描述,对应的事件签名是 Set(uint256), Keccak256 哈希结果是主题值。

solidity 日志主题

通过匹配,我们就可以知道 EVM 产生的该条日志是由 Set(uint256) 事件生成, 从而根据事件的参数列表解析日志数据。Solidity / web3.js / ethers.js 库都提供了解码函数, 例如:

// solidity decode
(x) = abi.decode(data, (uint));

// ethers.js
const SetEvent = new ethers.utils.Interface(["event Set(uint256 value)"]);
let decodedData = SetEvent.parseLog(event);

ABI 编解码可视化工具

ChainToolDAO 开发了几个可视化工具,帮助我们来编解码。

  1. 函数选择器的查询及反查 :https://chaintool.tech/querySelector
  2. 事件签名的 Topic 查询:https://chaintool.tech/topicID
  3. Hash 工具提供Keccak-256 及 Base64:https://chaintool.tech/hashTool
  4. 交易数据(calldata)的编码与解码: https://chaintool.tech/calldata

EVM- 交易数据(calldata)的编码与解码

小结

本文,我们学习了 ABI 的概念,ABI 是一个编解码的规范,是人类可读信息与以太坊虚拟机执行二进制数据的桥梁。

在理解 ABI 之上,分别介绍了 ABI 接口描述,ABI 编码与ABI 解码。


DeCert.me 码一个未来,DeCert 让每一位开发者轻松构建自己的可信履历。

DeCert.me 由登链社区 @UpchainDAO 孵化,欢迎 Discord 频道 一起交流。

本教程来自贡献者 @Tiny熊