Compound V3 Bulker 详解:把多笔操作打包进一笔交易

理解 invoke 批量动作、ETH 包装、非托管设计与 msg.value 循环的双花安全考量

6 分钟阅读
Compound V3 Bulker 详解:把多笔操作打包进一笔交易

Compound V3 Bulker 详解:把多笔操作打包进一笔交易

目录


一、Bulker 是什么

Bulker 是 Compound V3 里的类 multicall 合约,能把多笔交易打包成一个操作。比如用户可以”在一笔交易里供应 ETH、LINK、wBTC 作抵押,并借出 USDC”。

没有 Bulker 时,这套操作要分好几笔交易(approve、supplyCollateral × 3、borrow),既麻烦又费 Gas。Bulker 把它们合成一笔,大幅改善体验。

二、核心功能:invoke 函数

Bulker 的主方法是 invoke(),接收两个参数:

function invoke(bytes32[] calldata actions, bytes[] calldata data) external payable;
  1. actions:一个动作类型列表(枚举值);
  2. data:每个动作对应的参数(ABI 编码的 bytes)。

与传统 multicall 接受任意 calldata 不同,Bulker 采用结构化方式,只允许预定义的动作类别——更安全,避免任意调用带来的风险。

三、六种动作类型

动作含义
ACTION_SUPPLY_ASSET供应 ERC-20 代币作抵押
ACTION_SUPPLY_NATIVE_TOKEN直接供应 ETH(内部包装成 WETH)
ACTION_TRANSFER_ASSET转移资产(配合 Comet 的 rebasing 行为)
ACTION_WITHDRAW_ASSET提取 ERC-20 代币
ACTION_WITHDRAW_NATIVE_TOKEN提取 ETH(内部把 WETH 解包成 ETH)
领取 COMP 奖励与独立的 CometRewards.sol 交互

invoke 内部按顺序遍历 actions,对每个动作解码对应的 data 并执行。ETH 相关动作自动处理 WETH 的包装/解包,用户无感。

四、非托管设计

一个关键架构特性:Bulker 永远不持有用户的代币

机制:用户给 Bulker 授权(通过 Comet 的 allow() 把 Bulker 设为操作者),Bulker 代表用户执行交易,但代币直接在用户和 Comet 之间流转。

好处:保持可组合性——其他合约能代表用户操作,而无需假定 msg.sender 就是原始存款人。Bulker 不碰资金,即使有 bug 也不会卷走用户的钱。

五、msg.value 的安全考量(为什么独立合约)

为什么 Bulker 代码要和主 Comet 合约分开?因为在循环 + delegatecall 中使用 msg.value 有安全风险。

Compound 小心确保 msg.value扣减而非重复使用,否则会导致双花(double spending)。

举例:如果 invoke 在循环里多次用到 msg.value,而每次都读同一个 msg.value(它在整笔交易里是常量),攻击者就能用 1 份 ETH 完成多个”花费 ETH”的动作——凭空多花钱。Compound 通过跟踪剩余可用 ETH、每次扣减,防止这种重复花费。

这是 multicall + payable 的经典陷阱,文章特别推荐两个练习理解它:RareSkills Riddles 的 MultiDelegateCall、DamnVulnerableDefi 的 Free Rider 关卡。

六、管理员函数

Bulker 含 sweepToken(),允许管理员找回误转入的代币。救援卡住的 ETH 和 ERC-20 有各自的机制。因为 Bulker 非托管、平时不该持有任何代币,所以任何留在里面的都是误转,可被管理员扫出。

七、非标准 ERC-20 代币处理

Compound 稳健地兼容偏离规范的 ERC-20——特别是不返回布尔值的代币(如老版 USDT)。实现用底层 call 检查返回数据大小:

  • 返回 32 字节:标准 ERC-20 响应(读布尔判断);
  • 无返回数据或 revert:非标准行为;
  • 成功 = 代币返回 true,什么都不返回且不 revert;
  • 失败 = 代币 revert,明确返回 false。

合约用 IERC20NonStandard 接口处理不合规代币。这是与各种”奇葩代币”打交道的生产级写法(呼应 Module 5 Router 篇的 fee-on-transfer 处理思路)。

八、Gas 与 UX 的取舍

理论上 Compound 可以用 1 字节的动作标识符代替 32 字节的 ASCII 字符串来省 Gas,但现有设计优先安全和可维护性而非边际 Gas 节省。可读的动作标识让代码更易审计和维护,这是一个有意识的工程权衡。

九、与 Comet allow 权限的关系

Bulker 能代表用户操作,依赖 Comet 的二元授权(上一篇 cUSDC rebasing 篇讲的 allow):

comet.allow(bulkerAddress, true); // 授权 Bulker 作为操作者

授权后,Bulker 调用 Comet 的函数时,Comet 通过 hasPermission() 确认 Bulker 被用户授权,于是允许它代表用户 supply/withdraw/transfer。这就是非托管 + 可组合的权限基础:用户授权操作权,而非转移代币所有权

十、总结

  • Bulker = 结构化的 multicall,把多笔 Comet 操作合并成一笔 invoke;
  • 六种动作:供应/提取 ERC-20、供应/提取 ETH(自动 WETH 包装)、转移、领奖励;
  • 非托管:不持有用户代币,靠 Comet allow 授权代表用户操作,保持可组合性;
  • 独立合约是为了安全处理 msg.value 循环双花风险;
  • 兼容非标准 ERC-20(不返回布尔的代币);
  • 用可读字符串动作标识,优先安全可维护而非极致省 Gas。

十一、动手练习项目:批量操作器 MiniBulker

项目目标

为前面练习的 MiniComet 实现一个非托管 Bulker,支持一笔交易完成多个动作(供应抵押 + 借款 + 包装 ETH),亲手处理 msg.value 循环的双花防护。部署到 Sepolia。

合约要求

MiniBulker.sol

  • immutable cometimmutable weth
  • 定义动作常量:ACTION_SUPPLY_ASSETACTION_SUPPLY_NATIVEACTION_WITHDRAW_ASSETACTION_WITHDRAW_NATIVEACTION_TRANSFERACTION_CLAIM_REWARD(用 bytes32 字符串或 uint8 枚举);
  • invoke(bytes32[] calldata actions, bytes[] calldata data) external payable
    • 维护一个局部 uint256 unusedEth = msg.value
    • 遍历 actions,for 循环里 decode 每个 data,分派执行;
    • 遇到 ACTION_SUPPLY_NATIVE 时从 unusedEth 扣减对应金额unusedEth -= amount,不足则 revert)——这是双花防护的核心;
    • ETH 动作:weth.deposit{value: amount}() 后 supply,或 withdraw 后 weth.withdraw 再转 ETH 给用户;
    • 结束时把剩余 unusedEth 退还调用者;
  • sweepToken(address token) / sweepEth():onlyAdmin 救援误转资产;
  • 所有对 comet 的操作以 msg.sender(原始用户)为受益人——依赖用户事先 comet.allow(bulker, true)

测试要求(Foundry)

  1. test_BatchSupplyAndBorrow:一笔 invoke 完成”supply 抵押 + withdraw(借) base”,验证最终头寸正确;
  2. test_SupplyNativeWrapsToWeth:ACTION_SUPPLY_NATIVE 带 ETH,验证被包装成 WETH 存入 comet;
  3. test_MsgValueNoDoubleSpend(核心):构造两个 ACTION_SUPPLY_NATIVE 但 msg.value 只够一个,断言 revert(不能用 1 份 ETH 花两次);正常情况下两动作金额之和 == msg.value 才成功;
  4. test_RefundUnusedEth:msg.value 多于实际用量,多余部分退还调用者;
  5. test_NonCustodial:invoke 执行后 Bulker 自身余额为 0(不持有用户资产);
  6. test_RequiresAllow:用户未 comet.allow(bulker, true) 时 invoke 中的 comet 操作 revert;
  7. test_SweepRecoversMistransfer:误转入 Bulker 的代币能被 admin sweep 出来。

Sepolia 部署与验证步骤

  1. 部署 MiniComet、WETH、MiniBulker;
  2. 用户先 comet.allow(bulker, true) 授权;
  3. 构造 actions 数组(如 [SUPPLY_NATIVE, WITHDRAW_ASSET])和对应 data,带上 ETH 调 invoke;
  4. 在 Etherscan 上验证一笔交易完成了多个动作、Bulker 余额为 0、多余 ETH 已退还;
  5. 故意让两个 SUPPLY_NATIVE 金额之和 > msg.value,观察 revert(双花防护)。

进阶挑战(可选)

  • 做 RareSkills Riddles 的 MultiDelegateCall 或 DamnVulnerableDefi 的 Free Rider,亲手利用 msg.value 循环双花漏洞,再用本练习的扣减法修复,彻底理解防护原理;
  • 支持非标准 ERC-20(不返回布尔的代币):用底层 call + returndatasize 判断,复刻第七节逻辑;
  • 写注释回答:为什么 Bulker 设计成非托管?如果它持有用户代币会引入哪些风险,又会破坏什么可组合性?

💬 评论