如何用 OpenZeppelin Contracts 的 Multicall 把多个调用合并成一次原子交易?
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
如果你的合约需要用户在单笔交易里完成多个操作(例如连续多次 ERC-20transfer),单独发多笔交易会留下中间状态,而且后一笔失败时前面几笔已经确认。OpenZeppelin Contracts 的Multicall抽象合约把这个问题收敛为一个multicall(bytes[])入口:外部账户只发一次交易,合约按顺序执行所有子调用,任何一步失败时整笔交易回滚,之前的状态修改全部撤销。本文基于仓库中的 Multicall 实现、Utilities 文档 中的示例和 官方测试,走通"继承 → 编码参数 → 发起交易 → 验证结果"这条路径。
前提:Multicall 是抽象合约,必须被继承
Multicall位于 contracts/utils/Multicall.sol,是一个abstract contract,文件头部的 pragma 为^0.8.20(仓库 package.json 中当前库版本为 5.7.0)。它不能单独部署,你需要在自己的合约里继承它,然后外部账户直接调用继承下来的multicall函数:
function multicall(bytes[] calldata data) public virtual returns (bytes[] memory results) { bytes memory context = msg.sender == _msgSender() ? new bytes(0) : msg.data[msg.data.length - _contextSuffixLength():]; results = new bytes[](data.length); for (uint256 i = 0; i < data.length; i++) { results[i] = Address.functionDelegateCall(address(this), bytes.concat(data[i], context)); } return results; }实现要点:
data的每一项是一条编码好的函数调用数据,multicall按数组顺序逐条执行;- 每个子调用通过
Address.functionDelegateCall(address(this), ...)对本合约自身执行,因此子调用的状态修改直接作用于当前合约的存储,并且整条调用链共享同一次交易,一步失败全部回滚; - 返回值
results是bytes[],第i项是第i个子调用的返回数据,供调用方自行解码。
第一步:写一个继承 Multicall 的合约
Utilities 文档 的 "Multicall" 一节给出的示例合约(对应仓库中的 contracts/mocks/docs/utilities/Multicall.sol)如下:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Multicall} from "../../../utils/Multicall.sol"; contract Box is Multicall { function foo() public { // ... } function bar() public { // ... } }foo/bar是文档里的占位函数。在你的项目中,把import路径换成contracts/utils/Multicall.sol在你工程里的实际位置,foo/bar换成你真正要打包执行的函数即可。合约里不需要为multicall写任何额外代码——继承即生效,且该函数是virtual的,必要时可以重写。
仓库里的 ERC20MulticallMock 展示了最典型的一种组合:一个既继承ERC20又继承Multicall的合约,abstract contract ERC20MulticallMock is ERC20, Multicall {},用于测试对transfer等 ERC-20 函数的批量调用。
第二步:用 Ethers.js 把多个调用编码进一笔交易
文档给出的调用方式(Ethers.js)是:
// scripts/foobar.js const instance = await ethers.deployContract("Box"); await instance.multicall([ instance.interface.encodeFunctionData("foo"), instance.interface.encodeFunctionData("bar") ]);instance.interface.encodeFunctionData("foo")生成foo的编码调用数据,带参数时写成encodeFunctionData("transfer", [recipient, amount])。数组里有几条编码数据,这一笔交易就会执行几条子调用。
第三步:验证执行结果与原子性
验证分两层,仓库自己的测试 test/utils/Multicall.test.js 就是按这两层断言的。
检查每个子调用的返回值。multicall返回的results[i]是第i个子调用的原始返回数据,需要按该函数的返回类型解码。仓库的 MulticallHelper 展示了这种检查方式:对transfer(返回bool)批量执行后,逐条解码并要求为真:
bytes[] memory results = multicallToken.multicall(calls); for (uint256 i = 0; i < results.length; i++) { require(abi.decode(results[i], (bool))); }检查事件、余额与回滚行为。测试batches function calls中,把两笔transfer(分别转amount / 2和amount / 3)放进同一个multicall,断言会按顺序发出两条Transfer事件,且两个收款地址的balanceOf分别等于对应金额——这对应文档示例中foo/bar被同一次交易执行的预期。
测试reverts previous calls则验证原子性:第一个子调用转账amount、第二个子调用再转amount(余额不够),整笔交易以自定义错误ERC20InsufficientBalance回滚,而第一个子调用本应增加的余额保持为 0——即"后面的调用失败时,前面的调用一并撤销"。回滚原因会原样冒泡到最外层交易,方便你在ethers报错中直接看到是哪个子调用失败(测试bubbles up revert reasons用的正是同一场景)。
使用边界
两点限制来自 Multicall.sol 的源码注释,使用这个合约时应当知道:
- 调用方对 calldata 的校验假设可能失效。注释明确提醒:如果发送方假设交易 calldata 会被某种方式校验(例如按函数选择器过滤的 relay 地址),它不会过滤
multicall内部的嵌套调用。也就是说,子调用不受外层选择器白名单的约束。 - 与
ERC2771Context的兼容性。自 5.0.1 / 4.9.4 起,合约会识别"非规范上下文"(msg.sender与Context._msgSender()不一致的情形,例如经 meta-transaction 中继时),并在自delegatecall时把msg.data末尾的上下文后缀追加到子调用数据中,使其可安全地与ERC2771Context搭配使用;不影响_msgSender解析的上下文则不会传递给子调用。
如果后续还要处理子调用返回的复杂数据,解码逻辑放在合约外(如MulticallHelper所示)或合约内均可,关键是逐项解码results后再使用,而不是假设整个批量一定全部成功——批量整体要么成功、要么整体回滚。
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考