OpenZeppelin Contracts 的 ERC-4337 担保式 Paymaster 修复:有效预存款(Effective Prefund)如何在扩展组合中正确传播
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
导读
本篇文章围绕 OpenZeppelin Contracts 仓库中一条 release changeset(.changeset/paymaster-guarantor-effective-prefund.md)展开,剖析其对PaymasterERC20Guarantor(ERC-20 代币付费 + 第三方担保的 ERC-4337 Paymaster 扩展)的一次关键修复:_prefund改为返回由super._prefund传播回来的有效预存款金额,而非调用方传入的输入金额。读完本文,你将理解该修复解决的实际问题——下游扩展实际拉取的代币少于请求量时,postOp 上下文中序列化的金额必须与真实扣款一致,否则会导致退款路径超付或事件记账失真——并掌握从 changeset 回溯到源码实现与测试用例的完整证据链。
一、这条 changeset 是什么:一次 patch 级别的行为修复
在 OpenZeppelin Contracts 仓库中,.changeset/目录存放由 Changesets 工具管理的发布说明文件(.changeset/config.json中changelog使用@changesets/changelog-github,access为public,baseBranch为master)。每条文件在发布时会被合并进 CHANGELOG,并驱动语义化版本号计算。
本文件的 front matter 声明了版本影响:
--- 'openzeppelin-solidity': patch ---即这是一次patch(补丁)级变更,代表向后兼容的缺陷修复,不引入破坏性 API 变化。正文如下:
PaymasterERC20Guarantor: Return the effectiveprefundAmountpropagated bysuper._prefundinstead of the input amount, so extensions composed below the guarantor that pull less than requested serialize the actual pulled amount into the postOp context.
翻译过来:PaymasterERC20Guarantor._prefund现在返回由super._prefund传播回来的有效prefundAmount,而不再返回输入金额;这样,位于担保人之下、实际拉取金额少于请求量的下游扩展,就能把真实拉取量序列化进 postOp 上下文。
这条变更关联的模块位于 contracts/account/paymaster/extensions/PaymasterERC20Guarantor.sol(v5.7.0)。要理解它,需要先弄清 Paymaster 的"预扣–退款"资金模型。
二、背景:PaymasterERC20 的预扣–退款模型与担保人扩展
2.1 基础资金流:先预扣最大成本,执行后按实际成本退款
PaymasterERC20(contracts/account/paymaster/extensions/PaymasterERC20.sol)让用户用 ERC-20 代币支付 gas,遵循两阶段模型:
- 验证阶段(
_validatePaymasterUserOp):按maxCost与 postOp 估算开销预扣最大可能成本(_prefund执行transferFrom); - 执行后阶段(
_postOp→_refund):按 EntryPoint 报告的actualGasCost结算实际成本,把prefundAmount - actualAmount退回。
基础_prefund的实现(PaymasterERC20.sol L161-L170)恰好拉取请求的金额:
function _prefund( PackedUserOperation calldata /* userOp */, bytes32 /* userOpHash */, IERC20 token, uint256 /* tokenPerNative */, address prefunder_, uint256 prefundAmount_ ) internal virtual returns (bool success, address prefunder, uint256 prefundAmount, bytes memory prefundContext) { return (token.trySafeTransferFrom(prefunder_, address(this), prefundAmount_), prefunder_, prefundAmount_, ""); }注意它的返回约定(注释明确说明):prefundAmount是"effective prefund actually pulled"(实际拉取的有效金额)。基础实现中请求量与拉取量一致,但在扩展组合中二者可能分叉——这正是本次修复的切入点。
2.2 担保人(Guarantor)扩展:第三方代付 gas
PaymasterERC20Guarantor是PaymasterERC20的扩展,允许第三方(担保人)替用户预付 gas,典型场景是空投领取:
- 担保人预先支付最大可能 gas 成本;
- 用户在操作执行中领到空投代币;
- 用户从空投所得中偿还担保人;
- 若用户无法偿还,担保人吸收成本。
担保人通过_fetchGuarantor(userOp)识别(返回address(0)表示不使用担保功能)。当存在担保人时,_prefund会:
- 把预扣金额膨胀
_guaranteedPostOpCost()(默认 15_000 gas,按maxFeePerGas折算成代币)对应的成本,覆盖担保人在_refund中的额外 postOp 工作(向用户trySafeTransferFrom+ 向担保人trySafeTransfer); - 将担保人设为 prefunder;
- 在
prefundContext尾部追加userOp.sender,供退款阶段识别发起者。
修复后的_prefund(PaymasterERC20Guarantor.sol L47-L84):
function _prefund( PackedUserOperation calldata userOp, bytes32 userOpHash, IERC20 token, uint256 tokenPrice, address prefunder_, uint256 prefundAmount_ ) internal virtual override returns (bool success, address prefunder, uint256 prefundAmount, bytes memory prefundContext) { address guarantor = _fetchGuarantor(userOp); bool isGuaranteed = guarantor != address(0); if (isGuaranteed) { // _erc20Cost 可能返回 type(uint256).max 作为溢出哨兵;saturatingAdd 保留它, // 让坏值到达 trySafeTransferFrom 时失败,而不是在此处 revert。 uint256 guaranteedPostOpCost = _erc20Cost(_guaranteedPostOpCost() * userOp.maxFeePerGas(), tokenPrice); prefundAmount_ = prefundAmount_.saturatingAdd(guaranteedPostOpCost); prefunder_ = guarantor; } (success, prefunder, prefundAmount, prefundContext) = super._prefund( userOp, userOpHash, token, tokenPrice, prefunder_, prefundAmount_ ); if (prefunder == guarantor) { emit UserOperationGuaranteed(userOpHash, prefunder, prefundAmount); } return (success, prefunder, prefundAmount, abi.encodePacked(prefundContext, userOp.sender)); }三、修复的核心:返回"有效金额"而非"输入金额"
3.1 问题所在:扩展组合中拉取量可能少于请求量
PaymasterERC20家族是典型的可组合扩展:开发者可以在PaymasterERC20Guarantor之上再叠加其他扩展(继承顺序如Mock → PaymasterERC20Guarantor → PaymasterERC20ReducingMock → PaymasterERC20)。这些位于担保人之下(super方向)的扩展,可能在_prefund中实际拉取的金额少于传入的请求量。
仓库中的PaymasterERC20ReducingMock(contracts/mocks/account/paymaster/PaymasterERC20Mock.sol L194-L234)就是这样一个模拟:它在调用super._prefund时把金额减 1,模拟一个"固定减免(fixed-credit)"策略——合法地以低于请求量的价格结算。
修复前,PaymasterERC20Guarantor._prefund的返回值把prefundAmount原样设为输入值prefundAmount_。一旦下游扩展实际只拉取了prefundAmount_ - 1,担保人返回给上层调用者的却是完整的请求量。这一失真的金额随后被:
_validatePaymasterUserOp(PaymasterERC20.sol L122-L145)序列化进 context:abi.encodePacked(userOpHash, token, tokenPerNative, prefundAmount, prefunder, penaltyGas, prefundContext);- 一路传递到
_postOp(PaymasterERC20.sol L181-L217),解码出prefundAmount作为退款基数。
结果就是:postOp 上下文记录的预存款比 Paymaster 账户里真实进入的代币多,退款路径可能按虚高的基数执行(例如向担保人退回超过实际持有的金额),或者UserOperationSponsored事件记录的tokenAmount与真实结算不符。
3.2 修复内容:传播super._prefund的返回值
修复只需一行关键改动:将返回语句中的金额改为super._prefund传播回来的值——即上面代码中的(success, prefunder, prefundAmount, prefundContext) = super._prefund(...)之后,直接返回这个prefundAmount(它已经是下游扩展层层修正后的有效值),而不是构造返回值时重新使用输入变量prefundAmount_。
由于PaymasterERC20._prefund的契约本身就是"返回实际拉取的有效金额",这个传播保证了整个组合链上的金额始终是真实进入 Paymaster 的代币量,与prefundContext一起被序列化进 postOp context,供_refund精确结算。
从源码结构看,这一修复同时保持了与PaymasterERC20._prefund文档契约的一致性(PaymasterERC20.sol L149-L160:"Extensions may inflate the amount ... and must return the effective value"),修复让担保人扩展真正履行了"返回有效值"的约定。
四、对称修复:_refund同样传播有效结算金额
值得注意,本次 changeset 只提到_prefund,但仓库中_refund已经实现了对称的传播逻辑(PaymasterERC20Guarantor.sol L102-L146),二者共同保证整条资金链路金额不失真:
- 非担保分支(
prefunder == userOp.sender):调用super._refund后,返回returnedEffectiveAmount(下游扩展实际结算的金额),保证UserOperationSponsored.tokenAmount与真实结算一致; - 担保分支(
prefunder != userOp.sender):把actualAmount膨胀上_guaranteedPostOpCost() * actualUserOpFeePerGas的代币成本,先尝试从用户(从prefundContext尾部读出的userOp.sender)trySafeTransferFrom拉取;成功则把actualAmount归零,让super把全部prefundAmount退给担保人;失败则保留该金额,由担保人吸收(注意:担保人吸收的是膨胀后的担保成本,而非基础成本)。
Math.ternary(prefunder != userOpSender, effectiveAmount, returnedEffectiveAmount)确保了返回值语义精确:只有非担保分支才传播下游扩展的真实收费,担保分支返回的是事件里记录的担保人膨胀金额。
五、测试验证:证据链完整闭环
仓库测试 test/account/paymaster/PaymasterERC20Guarantor.test.js 为本次修复提供了直接验证,其中propagates effective amounts from downstream extensions测试组(L432-L516)专门构造了组合:
Mock → PaymasterERC20Guarantor → PaymasterERC20ReducingMock → PaymasterERC205.1_prefund必须返回 super 实际拉取的金额
测试'_prefund returns the amount pulled by super, not the input'(L454-L481):
- 请求
requested = 100n,reducing 扩展实际拉取effective = 99n; - 断言
$_prefund的返回值是(true, this.other.address, 99n, ...),即有效值; - 并核对代币余额:
other归零、Paymaster 恰好持有99n。
测试注释明确指出:没有该修复时返回值会是requested(100),比真实进入 Paymaster 的多一枚代币,这个虚高值会被序列化进 postOp context——与 changeset 描述的问题完全对应。
5.2_refund对非担保操作传播实际收费
测试'_refund returns the amount charged by super for non-guaranteed operations'(L483-L515)验证对称路径:输入actualAmount = 40n,reducing 扩展实际结算39n,断言返回值是(true, 39n),且 Paymaster 余额恰好等于有效结算额。否则UserOperationSponsored.tokenAmount会与真实结算不一致。
5.3 真实场景测试
同一文件还覆盖了端到端流程:
- 用户成功偿还担保人(L165-L235):操作中先给用户铸币再授权 Paymaster,用户偿还后担保人余额不变(factor 0),并断言
UserOperationGuaranteed/UserOperationSponsored事件与代币、ETH 余额变动一致; - 用户未能偿还(L237-L299):用户拿不到资产,担保人余额减少(factor -1),Paymaster 吸收担保成本;
- 冷存储担保人(L301-L354):余额与授权全部是"冷"状态,验证
trySafeTransferFrom路径; - 无效担保人签名(L357-L371):EntryPoint 以
AA34 signature error拒绝; - 担保人余额/授权不足(L399-L429):同样以
AA34 signature error拒绝,印证_prefund中"不 revert、返回失败"的设计(失败以SIG_VALIDATION_FAILED形式体现,避免在验证阶段 revert 损害 Paymaster 信誉——见 PaymasterERC20.sol L157-L159 的 NOTE)。
六、理解这条修复对使用者的意义
6.1 对 Paymaster 开发者的影响
- 升级成本:patch 级变更,无需改动接口;但若你已基于
PaymasterERC20Guarantor组合了"拉取量少于请求量"的下游扩展(如折扣、减免策略),此修复会让链上记账与真实资金流严格对齐,属于正确性修复; - 组合顺序:担保人必须位于"会削减金额的扩展"之上(
super方向),有效金额才能被正确传播回 context; - 担保成本覆盖:
_guaranteedPostOpCost()默认 15_000 gas,与PaymasterERC20._postOpCost()(默认 30_000)类似,gas 更重的代币应覆盖为更高值,否则会持续低估并损耗 Paymaster 存款。
6.2 资金流全局回顾
修复后完整链路为:
_validatePaymasterUserOp计算maxTokenCost(含 postOp 成本与_postOpGasPenalty);PaymasterERC20Guarantor._prefund膨胀金额并调用super._prefund;- 下游扩展削减实际拉取量,
super链逐层传播有效金额; - 担保人把有效金额与
prefundContext(尾部附userOp.sender)一并返回; _validatePaymasterUserOp将其序列化进 context;_postOp解码并调用_refund,按有效prefundAmount精确退款、按有效actualAmount发出UserOperationSponsored。
正是 changeset 中的这一行改动,堵住了第 3~4 步之间金额失真的漏洞,让整个组合链上的预存款始终"如实入账"。
七、结论
这条 patch changeset 是 OpenZeppelin Contracts 中"小改动、深影响"的典型:表面只改了一处返回值,实质修复了可组合扩展架构下资金记账失真的正确性问题。透过它可以看到PaymasterERC20家族设计的两个关键契约——_prefund返回有效拉取量、_refund返回有效结算量——以及它们在测试中的严格验证。相关实现与验证可继续参阅:
- 变更说明:.changeset/paymaster-guarantor-effective-prefund.md
- 担保人扩展实现:contracts/account/paymaster/extensions/PaymasterERC20Guarantor.sol
- 基础 ERC-20 Paymaster:contracts/account/paymaster/extensions/PaymasterERC20.sol
- 基础 Paymaster(存款/质押/EntryPoint 约束):contracts/account/paymaster/Paymaster.sol
- 组合扩展测试桩(Reducing/GurantorReducing Mock):contracts/mocks/account/paymaster/PaymasterERC20Mock.sol
- 端到端与单元测试:test/account/paymaster/PaymasterERC20Guarantor.test.js
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考