Cobo 安全团队对 Weiroll 组件的设计原理和实际应用进行了深入探索。
作者: Cobo Global
封面:Photo by Shubham's Web3 on Unsplash
近期,Berachain 生态火热,链上新项目层出不穷,推动着整个区块链技术的发展。在深入研究 Berachain 生态的过程中,Cobo 安全团队发现了一个非常有趣且强大的组件——Weiroll。Weiroll 组件虽然早在 2021 年便已推出,但是较少运用在大型项目上。在调研 Berachain 的 CCDM(Cross Chain Deposit Module) 过程中,我们发现该组件承载着重要作用。出于对技术的好奇,我们决定深入探索 Weiroll 的设计原理和实际应用,并与大家分享一些研究心得。
Weiroll 简介
在以太坊链上交易执行的过程中,为了保证交易的原子性(确保交易执行过程不受其他交易干扰,防止状态污染,例如 MEV 问题),通常会使用 MultiCall 合约来达成这一目的。但是 MultiCall 合约存在一个无法解决的问题:在 Multicall 调用之间,后续调用无法依赖前面调用的返回结果来进行。 举例说明:假设需要通过多个 DEX 进行 USDT-ETH-BTC 的代币兑换路径,此时需要发起两笔调用,分别是在 A DEX 中进行 USDT-ETH 兑换和在 B DEX 中进行 ETH-BTC 的兑换。看起来这个需求 MultiCall 合约是可以满足的,但是如果需要使用根据 A DEX 兑换出的 ETH 数量作为参数填入 在 B DEX 的兑换过程的调用,此时,这个需求在 MultiCall 中就无法满足了。
Weiroll 是为了改进上述 MultiCall 无法满足的需求而诞生的合约。在 Weiroll 中,用户可以在调用之间根据不同的返回值来发起不同的调用,让调用之间更加灵活。为了让大家有一个更加直观的了解,我们制作了两个简单的流程图方便对比。

Weiroll 的技术原理
传统的 MultiCall 合约中,交易执行的函数接口形式如下:
function multicall(bytes[] calldata data, address[] calldata addr) external virtual returns (bytes[] memory results)
{
//....ignore...
for (uint256 i = 0; i < data.length; i++) {
results[i] = Address.functionDelegateCall(addr[i], data[i]);
//....ignore...
}
}
通过对函数参数以及核心执行部分的分析,我们可以看到参数中包含的只是一串简单的交易数据,且在调用过程中,各自的调用之间相互独立,只是单纯的发起调用。
为了实现在 MultiCall 调用之间可以互相引用各自的返回值,Weiroll 合约需要抛弃原 MultiCall 合约中简单的 data 数组参数,重新设计接口中的参数,并进行更加精细化的定义。
在 Weiroll 中 MultiCall 的执行接口转换为如下形式:
function _execute(bytes32[] calldata commands, bytes[] memory state) internal returns (bytes[] memory)
其中,comands 参数是一个调用数组,与 MultiCall 不同,数组中的每一个 commands 包含了调用中需要的目标地址,参数类型和返回值类型。同时对参数的位置进行了指定。而 state 数组则用于存储在调用过程中需要用到的状态数据,包含各个调用之间的返回数据。
根据 Weiroll 对 command 定义,每个 command 的格式分成如下几个部分:

- sel:要调用的目标地址的函数签名,由四个字节组成;
- f:标记位,标记调用方式为 call/delegatecall/staticcall/value_call(带 eth);
- in: 参数类型和位置的列表,每个字节可以单独标注不同参数的类型和参数对应从 state 列表中的位置;
- o:标记位,标记返回值类型,只有一个字节。
其中,f 和 in/o 又可以分别根据其内容中二进制位值的不同对调用的行为进行不同的指定,先说 f,其定义如下:

由于 f 是一个字节,其可以分成 8 个不同的二进制位。第一和第二个位用于指定返回值的处理方式,如果指定为 tup 模式,则返回值将不做任何处理直接插入到 state 状态列表中,如果选择为 ext 模式,则会根据 command 中 o 标记位指定的的位置来进行存储到 state 列表中。
对于 in/o 而言,其格式定义如下:

和 f 一样,in/o 中每个字节都可以单独拆分成 8 个二进制。其中第一个二进制位表示参数的类型为定长还是不定长。如果是定长,则对应的值为 0b0,且 idx 中指示的值为该参数在 state 数组中的参数位置。在定长情况下,参数的大小必须为 32 字节。如果为不定长,则表示为 0b1,idx 中指示的值为该参数在 state 数组中的参数位置,此时,参数的长度必须为 32 字节的整数倍。
最后,整体的调用形式会类似的形式如下:
for (uint256 i; i < commandsLength;) { command = commands[i]; flags = uint256(uint8(bytes1(command << 32))); if (flags & FLAG_EXTENDED_COMMAND != 0) { indices = commands[i++]; } else { indices = bytes32(uint256(command << 40) | SHORT_COMMAND_FILL); } if (flags & FLAG_CT_MASK == FLAG_CT_DELEGATECALL) { (success, outdata) = address(uint160(uint256(command))).delegatecall( // target state.buildInputs( bytes4(command), indices ) ); } else if (flags & FLAG_CT_MASK == FLAG_CT_CALL) { (success, outdata) = address(uint160(uint256(command))).call( // target // inputs state.buildInputs( //selector bytes4(command), indices ) ); } else if (flags & FLAG_CT_MASK == FLAG_CT_STATICCALL) { (success, outdata) = address(uint160(uint256(command))).staticcall( // target // inputs state.buildInputs( //selector bytes4(command), indices ) ); } else if (flags & FLAG_CT_MASK == FLAG_CT_VALUECALL) { uint256 calleth; bytes memory v = state[uint8(bytes1(indices))]; require(v.length == 32, "_execute: value call has no value indicated."); assembly { calleth := mload(add(v, 0x20)) } (success, outdata) = address(uint160(uint256(command))).call{ // target value: calleth }( // inputs state.buildInputs( //selector bytes4(command), bytes32(uint256(indices << 8) | CommandBuilder.IDX_END_OF_ARGS) ) ); } else { revert("Invalid calltype"); }
根据上面的分析,Weiroll 合约便可以根据 command 数组中每一个元素拼凑出一个完整的调用结构,并从 state 数组中取出对应的值来根据调用的上下文构建出不同的调用,完成 MultiCall 无法做到的事情。
Weiroll 的应用
在了解完对应的基础知识后,似乎 Weiroll 只是 MultiCall 的一种拓展,仅仅是多了可以获取上文调用返回值的这么一个特性,但加入了很多的复杂性。那么,Weiroll 合约是否还存在其他更有效的应用吗?答案是肯定的。
预先指定执行脚本
熟悉 Timelock 合约的朋友对交易内容指定应该并不陌生。Timelock 交易一般通过预先在提案中进行指定交易内容,然后进行投票,并在投票结束后执行预定的交易,从而保证交易内容不被篡改。
通过 Weiroll 同样可以达到类似的目的。由于 Weiroll 合约本身是链上合约,且所有 command 都是链上执行的。用户可以通过继承 Weiroll 合约本身,在合约创建时指定一个执行列表,并在后续过程中根据列表中指定的行为来发起对应的操作。通过这种方式,可以实现对合约能做的操作进行审查,确保合约中的行为在预期范围内,不必担心合约会做出预期之外的行为。
一个更加具体的例子是在涉及链上进行 RWA 收益的基金合约场景中。基金经理拥有调配基金中资金流向的权力,这可能会引发用户对其权限过大的担忧。为消除这种疑虑,在用户存入资金前,基金经理可以通过 Weiroll 预先明确指定自己后续的交易行为,用户在仔细审查其指定的行为后,再把资金存入到合约中。通过这种方式,可以有效防范基金经理可能出现的恶意操作,同时也会让各种交易行为变得更加透明。
Weiroll 的安全问题
虽然 Weiroll 对 MultiCall 进行了拓展,但是由于其引入了更加复杂的设计模式,导致相对于 MultiCall 而言,同样增加了更多的安全风险。
交易内容审查问题
通过上文,我们可以知道每个 Weiroll 调用都需要组合 command 和 state 数组列表中的数据才能变成一个完整的调用。而某些调用的参数又是需要根据执行过程中的返回值才能知道具体的值。这无疑大大增加了审查的难度。作为用户,可能无法直接根据 command 列表直接推算出该交易的最终状态和具体的行为,从而增加了审查上的风险。
目标合约作恶问题
虽然所有的 Weiroll 命令都能够预先指定,然而仅对返回值的格式和位置进行了规定。这就使得目标合约可以依据已知的交易行为,提前构造一个恶意的返回值,进而可能导致资金损失。此外,由于在交易内容审查上无法提前确定实际调用时产生的返回值,这种恶意行为无法提前察觉,在一定程度上增加了用户面临的风险。
总结
Weiroll 堪称针对 MultiCall 合约的强大扩展工具 ,它凭借引入对调用返回值的引用支持,衍生出更为复杂且实用的特性,极大地拓展了合约应用的边界。但务必留意,随之而来的安全性风险不容小觑。用户务必要强化对交易内容的审查力度,全方位、多层次地审视交易细节,以此防范潜在的资金损失,确保自身资产安全。
参考材料:
Weiroll github 官方代码库:
https://github.com/weiroll/weiroll。
免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。