ERC-4337 Paymaster:更好的用户体验,隐藏的风险

  • osecio
  • 发布于 12小时前
  • 阅读 22

本文深入探讨了ERC-4337 Paymaster的强大功能及其带来的用户体验提升,同时也详细分析了Paymaster实现中常见的陷阱和潜在风险,包括未充分计算的Gas成本以及不正确的ERC-20代币处理方式,并为开发者提供了构建安全可靠Paymaster的实践建议。

ERC-4337 支付大师:更好的用户体验,隐藏的风险

ERC-4337 支付大师通过 Gas 成本的抽象化,释放了强大的用户体验,但它们也增加了复杂性和潜在的 Bug。探索实际实现中的一些常见陷阱,并学习如何设计可用于生产的支付大师。

ERC-4337 支付大师:更好的用户体验,隐藏的风险的标题图片

介绍

ERC-4337 (账户抽象) 已经为 Ethereum 解锁了新一波的用户体验改进。通过将用户从 EOA (外部拥有账户) 解耦,它实现了智能合约钱包、Gas 赞助和灵活的身份验证机制。

ERC-4337 引入的最强大的功能之一是支付大师 (paymaster),这是一种可以赞助用户 Gas 费用的合约。这允许 dApp 提供无缝的 “无 Gas” 体验,用户无需持有 ETH 即可进行交易。

然而,构建一个正确的支付大师并非易事。我们已经看到许多开发人员在标准的细微之处绊倒,这可能导致意外行为或不必要的复杂性。

在本文中,我们将从高层次分解 ERC-4337 的工作原理,深入研究支付大师的角色,并介绍我们在实现支付大师时观察到的一些最常见的陷阱。到最后,你将清楚地了解如何设计符合最佳实践并可用于生产的支付大师。

ERC4337 概述

传统 EOA 与智能合约钱包

在 Ethereum 的早期设计中,用户账户是外部拥有账户 (EOA),由私钥控制。当你发送交易 (例如,代币转账或合约调用) 时,你的私钥会对交易进行签名,并且你必须以 ETH 支付 Gas 费用。如果密钥丢失或被盗,你将永远失去对所有内容的访问权限。这种设置很简单,但也僵化且有风险。

相比之下,智能合约账户 (或 “智能钱包”) 是可编程的。它们可以强制执行诸如多重签名、支出限制、社交恢复、批量处理等逻辑,自动执行安全性和可用性的许多方面。

为什么引入 ERC-4337

智能钱包提供了强大的功能,但 Ethereum 的协议限制交易只能从 EOA 发起。之前的提案 (例如,EIP-2938、EIP-3074) 试图更改协议本身,需要硬分叉。ERC-4337 完全在链下实现账户抽象,使用更高层的基础设施,而无需对 Ethereum 的共识层进行任何更改。这释放了关键的用户体验改进:

  • 丢失密钥的用户恢复 (例如,社交恢复)
  • 在一个流程中批量或原子地执行多步骤操作
  • 使用 ERC-20 代币或通过赞助商支付 Gas 费用 (无 Gas 用户体验)
  • 使用自定义签名方案或多重签名逻辑
  • 创建和使用智能合约钱包,而无需预先提供 ETH 或助记词

ERC-4337 的工作原理

在深入研究每个组件之前,让我们先从高层次了解一下 ERC-4337 的工作原理:

图片

上图显示了 ERC-4337 的关键流程。以下是对上面显示的每个组件的简短说明。

UserOperation

UserOperation 是一个伪交易对象,代表用户的意图。它包括以下数据:

  • 目标合约调用
  • 签名或验证元数据
  • Gas 限制和费用支付详细信息 (钱包地址、支付大师、打包者) UserOperations 被提交到一个单独的内存池 (通常称为 alt­mempool),而不是常规的 Ethereum 交易池。

智能合约账户

通常称为发送者或智能账户,这是一个用户控制的合约,通过 validateUserOp()executeUserOp() 实现逻辑。它指定自定义规则:签名检查、随机数逻辑、允许的调用或支出限制。

打包者

打包者是一种监视 alt­mempool 的链下服务或节点。它收集多个 UserOperations,将它们打包,并在一个交易中将它们提交到 EntryPoint 合约。打包者必须使用 EOA 预先支付 Gas 费用,并且稍后会得到补偿。

EntryPoint

EntryPoint 合约充当 ERC-4337 的中心链上网关。对于 打包者 提交的每批 UserOperationsEntryPoint 都会验证每个操作,并将其路由回相应的智能合约钱包以供执行。

一旦所有操作都已处理完毕,EntryPoint 就会计算消耗的总 Gas 量,并补偿 打包者。此付款可以直接来自发送者在 EntryPoint 中的智能账户存款,也可以来自已同意赞助交易的 paymaster

Paymaster

paymaster 是一种可选的智能合约,可实现灵活的 Gas 支付选项。它可以直接赞助 Gas 费用,也可以允许用户使用 ERC-20 代币而不是 ETH 支付 Gas 费用。它运行两个关键功能:

  • validatePaymasterUserOp() 用于验证操作。这可以检查赞助资格,或者验证用户是否具有足够的 ERC-20 代币余额和授权来支付 Gas 费用。该功能的确切实现取决于协议如何实现它。
  • postOp(),它处理执行后的会计处理。对于赞助的交易,这可能会更新内部会计记录,而对于代币支付,它通常会完成与 ERC-20 代币支付相关的任何会计处理。

通过支持赞助和基于代币的 Gas 支付,paymaster 消除了用户持有 ETH 的需求,从而可以通过任一模型实现真正的无 Gas 交易。

了解 EntryPoint 的流程

当打包者通过 handleOps()UserOperations 提交到 EntryPoint 合约时,处理过程分两个主要阶段进行:验证执行

验证阶段

在此阶段中,EntryPoint 首先验证提交的 UserOps 数组中的所有操作,然后再执行任何操作。这确保只有有效的操作才能继续执行。对于每个 UserOpEntryPoint

  1. 计算 通过将所有指定的 Gas 限制 (验证、执行和支付大师 (如果使用)) 乘以用户的指定 maxFeePerGas,得出所需预付金额
  2. 调用 发送者的智能账户合约上的 validateUserOp(),以验证操作的有效性 (例如,检查签名)
  3. 如果未指定支付大师,则尝试从 EntryPoint 中发送者的 ETH 存款中 扣除 预付金额 (如果实际执行成本较低,则可以稍后部分退还)
  4. 验证 随机数以防止重放攻击
  5. 如果指定了支付大师,它将从支付大师的已存入 ETH 中 扣除 所需的预付金额,然后 调用 支付大师合约上的 validatePaymasterUserOp(),以验证它将支付 Gas 费用

只有在所有这些验证检查通过后,EntryPoint 才会继续实际执行 UserOperation。这种严格的验证流程可确保:

  • 该操作是合法的,并已获得用户的授权
  • 有足够的资金可用于支付 Gas 费用 (来自用户或支付大师)
  • 该操作无法重放
  • 所有涉及的合约 (发送者和支付大师) 都已批准执行

这种多层验证方法对于在处理可能涉及复杂的智能账户逻辑和第三方 Gas 赞助的操作时,维护安全性至关重要。

执行阶段

在所有操作均已通过验证后,EntryPoint 开始 执行 阶段,分别处理每个 UserOperation。对于每个操作,流程如下:

  1. EntryPoint 调用 innerHandleOp(),这将:

    • 转发 操作到发送者的智能账户合约
    • 在智能账户中执行预期的交易
    • 处理 执行后 的任务和清理
  2. 如果使用了支付大师,Entrypoint调用 paymaster.postOp() 以:

    • 允许支付大师最终确定其会计处理
    • 处理任何退款或额外费用
    • 完成任何支付大师特定的逻辑
  3. 最后,在处理完所有操作后,EntryPoint 补偿 打包者,用于:

    • 执行所有操作的 Gas 费用
    • 提交批量交易的开销
    • 任何未使用的 Gas,这将退还

此执行流程可确保安全和原子的操作执行、Gas 成本的准确跟踪和结算、对自定义支付大师支付逻辑的支持,以及对提供交易提交服务的打包者的适当补偿。

现在我们从高层次了解了 EntryPoint 的工作原理,让我们研究一下某些协议如何未能正确地实现与 EntryPoint 的执行模型相符的 paymaster,从而导致潜在的漏洞。

支付大师实现中的常见陷阱

虽然支付大师提供了强大的灵活性,但它们也引入了新的复杂性,并随之带来了微妙的 Bug 空间。支付大师设计中的失误不仅会破坏 Gas 赞助流程,还会使他们在 EntryPoint 中存入的 ETH 遭受利用或恶意破坏。

在本节中,我们将介绍我们在实际支付大师实现中观察到的 两个最常见的陷阱

低估 Gas 成本

要理解这个问题,我们首先来研究一下 EntryPoint 中的 Gas 惩罚是如何运作的。当 UserOperation 指定的执行 Gas 限制高于执行期间实际使用的 Gas 时,EntryPoint 会收取未使用 Gas 的 10% 的惩罚。此惩罚将支付给打包者,并从用户存款 (对于常规交易) 或支付大师存款 (当使用支付大师时) 中扣除。

现在,让我们来研究一个实际的例子,说明这种惩罚机制如何影响支付大师。SEND 协议的支付大师实现提供了一个有益的案例研究:

contract TokenPaymaster is BasePaymaster, UniswapHelper, OracleHelper {
[...]
    function _validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32, uint256 requiredPreFund)
        internal
        override
        returns (bytes memory context, uint256 validationResult)
    {
        unchecked {
            uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
            uint256 baseFee = tokenPaymasterConfig.baseFee;
            uint256 dataLength = userOp.paymasterAndData.length - PAYMASTER_DATA_OFFSET;
            require(dataLength == 0 || dataLength == 32, "TPM: invalid data length");
            uint256 maxFeePerGas = userOp.unpackMaxFeePerGas();
            uint256 refundPostopCost = tokenPaymasterConfig.refundPostopCost;
            require(refundPostopCost < userOp.unpackPostOpGasLimit(), "TPM: postOpGasLimit too low");
            uint256 preChargeNative = requiredPreFund + (refundPostopCost * maxFeePerGas);
            // note: price is in native-asset-per-token increasing it means dividing it by markup
            uint256 cachedPriceWithMarkup = cachedPrice * DENOM / priceMarkup;
            if (dataLength == 32) {
                uint256 clientSuppliedPrice =
                    uint256(bytes32(userOp.paymasterAndData[PAYMASTER_DATA_OFFSET:PAYMASTER_DATA_OFFSET + 32]));
                if (clientSuppliedPrice < cachedPriceWithMarkup) {
                    // note: smaller number means 'more native asset per token'
                    cachedPriceWithMarkup = clientSuppliedPrice;
                }
            }
            uint256 tokenAmount = weiToToken(preChargeNative, cachedPriceWithMarkup);
            tokenAmount += baseFee;
            SafeERC20.safeTransferFrom(token, userOp.sender, address(this), tokenAmount);
            context = abi.encode(tokenAmount, userOp.sender);
            validationResult =
                _packValidationData(false, uint48(cachedPriceTimestamp + tokenPaymasterConfig.priceMaxAge), 0);
        }
    }
[...]
    function _postOp(PostOpMode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas)
        internal
        override
    {
        unchecked {
            uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
            uint256 baseFee = tokenPaymasterConfig.baseFee;
            (uint256 preCharge, address userOpSender) = abi.decode(context, (uint256, address));
            preCharge -= baseFee; // don't refund the base fee
            uint256 _cachedPrice = updateCachedPrice(false);
            // note: price is in native-asset-per-token increasing it means dividing it by markup
            uint256 cachedPriceWithMarkup = _cachedPrice * DENOM / priceMarkup;
            // Refund tokens based on actual gas cost
            uint256 actualChargeNative = actualGasCost + tokenPaymasterConfig.refundPostopCost * actualUserOpFeePerGas;
            uint256 actualTokenNeeded = weiToToken(actualChargeNative, cachedPriceWithMarkup);
            if (preCharge > actualTokenNeeded) {
                // If initially provided token amount is greater than the actual amount needed, refund the difference
                SafeERC20.safeTransfer(token, userOpSender, preCharge - actualTokenNeeded);
            } else if (preCharge < actualTokenNeeded) {
                // Attempt to cover Paymaster's gas expenses by withdrawing the 'overdraft' from the client
                // If the transfer reverts also revert the 'postOp' to remove the incentive to cheat
                SafeERC20.safeTransferFrom(token, userOpSender, address(this), actualTokenNeeded - preCharge);
            }

            if (baseFee > 0) {
                SafeERC20.safeTransfer(token, tokenPaymasterConfig.rewardsPool, baseFee);
            }

            emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPriceWithMarkup, baseFee);
            refillEntryPointDeposit(_cachedPrice);
        }
    }
}

查看上面的代码,在 validatePaymasterUserOp 期间,支付大师会尝试首先收取最大的预付金额。此预付款是通过获取在 UserOp 中指定的 Gas 限制,并将溢价应用于将原生 ETH 成本转换为等效的 ERC20 代币价值来计算的。稍后在 postOp 中,支付大师会计算实际的费用,并退还预付款中的任何多余部分。

但是,存在一个关键的疏忽:该代码未考虑 Gas 惩罚。向支付大师收取的实际 Gas 费用不仅包括使用的 Gas,还包括因执行 Gas 限制与实际执行 Gas 之间的差异而产生的任何 惩罚

恶意用户可以通过设置人为地高的 Gas 限制来触发惩罚来利用此漏洞。当应用惩罚时,支付大师将被收取比预期多得多的费用,从而可能会耗尽其资金,因为未将这些额外成本纳入计算中。

实际上,打包者将是收到支付大师支付的惩罚的人。这意味着打包者可以提交自己的 UserOperation 以供自己执行,并且如果他们可以从支付大师中提取的惩罚超过了他们为该支付大师支付的 Gas 成本,则可以获利。在 SEND 的案例中,幸运的是,由于他们运营自己的打包者,因此产生的任何惩罚都会流回他们控制的打包者,从而创建一个封闭的经济循环,从而减轻了这种特殊的攻击媒介。

不正确的 ERC-20 处理

为了改善用户体验,一些协议引入了 ERC-20 支付大师,允许用户使用 ERC-20 代币而不是原生 ETH 支付交易 Gas 费用 (就像 SEND 在上面的代码中所做的那样)。核心概念非常简单,支付大师向打包者预付 ETH Gas 费用,然后根据当前市场汇率向用户收取等值的 ERC-20 代币。但是,安全地实施这种代币到 ETH 的转换和支付流程需要仔细考虑。

查看上面的 EntryPoint 流程,我们可以看到支付大师在 UserOperation 的生命周期中有两个关键的交互点:

  1. 在通过 validatePaymasterUserOp() 进行验证期间
  2. 在通过 postOp() 进行执行后

这种双重交互模型导致了在支付大师实现中处理 ERC-20 支付的两种主要模式:

1. 预付款退款模式

在此模型中,支付大师要求用户在 validatePaymasterUserOp() 期间预先支付 ERC-20 代币中可能的最大 Gas 成本。执行完成后,postOp() 会根据实际消耗的 Gas 退还任何多余的代币。这类似于常规 ETH Gas 支付的工作方式。像 SENDCircle 这样的几个协议已经实现了这种方法。但是,这种模式有一个关键缺点:用户必须先批准支付大师花费其 ERC20 代币,然后才能提交任何 UserOperations。需要此额外的设置步骤才能确保支付大师可以成功扣除代币 执行之前 (特别是在 validatePaymasterUserOp 期间)。

2. 执行后收费模式

这种替代方法会将代币收集推迟到执行之后。实际的Token支付不是在 validatePaymasterUserOp() 期间收取预付款,而是在 postOp() 中根据消耗的确切 Gas 计算和收集的。乍一看,这似乎是最用户友好的模式,因为用户可以将Token批准交易捆绑在同一 UserOperation 中,从而无需在提交 UserOp 之前进行单独的预批准交易。这意味着用户可以在没有任何先前设置的情况下与支付大师进行交互。

此方法用于在 EntryPoint 版本 v0.6 中有效,但该模式在 v0.7 中不再有效。实际上,使用此模式可能会导致支付大师的资金损失。让我们仔细看看 v0.7 如何处理执行阶段:

    function _executeUserOp(
        uint256 opIndex,
        PackedUserOperation calldata userOp,
        UserOpInfo memory opInfo
    )
    internal virtual
    returns (uint256 collected) {
    [...]
        bool success;
        {
    [...]
            if (methodSig == IAccountExecute.executeUserOp.selector) {
                bytes memory executeUserOp = abi.encodeCall(IAccountExecute.executeUserOp, (userOp, opInfo.userOpHash));
                innerCall = abi.encodeCall(this.innerHandleOp, (executeUserOp, opInfo, context));
            } else
            {
                innerCall = abi.encodeCall(this.innerHandleOp, (callData, opInfo, context));
            }
            assembly ("memory-safe") {
                success := call(gas(), address(), 0, add(innerCall, 0x20), mload(innerCall), 0, 32)
                collected := mload(0)
            }
            _restoreFreePtr(saveFreePtr);
        }
        if (!success) {
    [...]
            if (innerRevertCode == INNER_OUT_OF_GAS) {
                // handleOps was called with gas limit too low. abort entire bundle.
                // can only be caused by bundler (leaving not enough gas for inner call)
                revert FailedOp(opIndex, "AA95 out of gas");
            } else if (innerRevertCode == INNER_REVERT_LOW_PREFUND) {
                // innerCall reverted on prefund too low. treat entire prefund as "gas cost"
                uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
                uint256 actualGasCost = opInfo.prefund;
                _emitPrefundTooLow(opInfo);
                _emitUserOperationEvent(opInfo, false, actualGasCost, actualGas);
                collected = actualGasCost;
            } else {
    [...]
                collected = _postExecution(
                    IPaymaster.PostOpMode.postOpReverted,
                    opInfo,
                    context,
                    actualGas
                );
            }
        }
    }

在执行期间,EntryPoint 合约通过底层 call() 调用 它自己的 innerHandleOp 函数。这样做是为了为执行用户操作创建一个新的调用上下文。

如果此调用失败 (当 successfalse 时),代码将进入检查 innerRevertCode错误处理 流程。有三种可能的路径:

  1. 如果 innerRevertCodeINNER_OUT_OF_GAS,则意味着打包者没有为执行提供足够的 Gas。这会导致整个捆绑包失败,并显示 "AA95 out of gas"
  2. 如果 innerRevertCodeINNER_REVERT_LOW_PREFUND,则意味着用户没有预付足够的 Gas。在这种情况下,它会将整个预付金额作为 Gas 成本收取。
  3. 对于任何其他还原原因,代码仍将使用 PostOpMode.postOpReverted 调用 _postExecution()。这可确保即使在失败时也能进行适当的清理。

我们对第三个错误路径特别感兴趣,其中 innerRevertCode 既不是 INNER_OUT_OF_GAS 也不是 INNER_REVERT_LOW_PREFUND。为了更好地理解这种情况,让我们研究一下 innerHandleOp 的工作方式。

    function innerHandleOp(
        bytes memory callData,
        UserOpInfo memory opInfo,
        bytes calldata context
    ) external returns (uint256 actualGasCost) {
    [...]
        IPaymaster.PostOpMode mode = IPaymaster.PostOpMode.opSucceeded;
        if (callData.length > 0) {
            bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);
            if (!success) {
                uint256 freePtr = _getFreePtr();
                bytes memory result = Exec.getReturnData(REVERT_REASON_MAX_LEN);
                if (result.length > 0) {
                    emit UserOperationRevertReason(
                        opInfo.userOpHash,
                        mUserOp.sender,
                        mUserOp.nonce,
                        result
                    );
                }
                _restoreFreePtr(freePtr);
                mode = IPaymaster.PostOpMode.opReverted;
            }
        }

        unchecked {
            uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
            return _postExecution(mode, opInfo, context, actualGas);
        }
    }

我们观察到,在正常情况下,innerHandleOp 不仅应执行实际的 UserOperation 调用,还应调用 _postExecution。这意味着第三个失败处理路径 (该路径传递 postOpReverted 作为其模式) 是在 innerHandleOp 中的 _postExecution 调用出现问题时发生的。

让我们检查 _postExecution 代码以了解还原可能在哪里发生。

    function _postExecution(
        IPaymaster.PostOpMode mode,
        UserOpInfo memory opInfo,
        bytes memory context,
        uint256 actualGas
    ) internal virtual returns (uint256 actualGasCost) {
    [...]
            if (paymaster == address(0)) {
                refundAddress = mUserOp.sender;
            } else {
                refundAddress = paymaster;
                if (context.length > 0) {
                    actualGasCost = actualGas * gasPrice;
                    uint256 postOpPreGas = gasleft();
                    if (mode != IPaymaster.PostOpMode.postOpReverted) {
                        try IPaymaster(paymaster).postOp{
                                gas: mUserOp.paymasterPostOpGasLimit
                            }(mode, context, actualGasCost, gasPrice)
                        // solhint-disable-next-line no-empty-blocks
                        {} catch {
                            bytes memory reason = Exec.getReturnData(REVERT_REASON_MAX_LEN);
                            revert PostOpReverted(reason);
                        }
                    }
                    // Calculating a penalty for unused postOp gas
                    // note that if postOp is reverted, the maximum penalty (10% of postOpGasLimit) is charged.
                    uint256 postOpGasUsed = postOpPreGas - gasleft();
                    postOpUnusedGasPenalty = _getUnusedGasPenalty(postOpGasUsed, mUserOp.paymasterPostOpGasLimit);
                }
            }
    [...]
    }

事实证明,如果 postOp() 调用失败,它将还原并显示 PostOpReverted。但是,正如我们在 _executeUserOp 的先前代码中看到的那样,即使 innerHandleOp 失败,执行也不会还原。相反,它将继续进行另一次对 _postExecution 的调用,并显示 postOpReverted 模式,并且它不会尝试再次调用 postOp()。这意味着 bundler 仍然会因提交失败的 UserOperation 而获得报酬。

现在我们了解了 postOp() 允许失败,而 bundler 仍然可以获得报酬的这种行为,让我们检查一个来自当前使用最广泛的支付大师的真实示例,该支付大师是由 Pimlico 实现的支付大师。

    function _postOp(
        PostOpMode, /* mode */
        bytes calldata _context,
        uint256 _actualGasCost,
        uint256 _actualUserOpFeePerGas
    )
        internal
    {
        ERC20PostOpContext memory ctx = _parsePostOpContext(_context);

        uint256 expectedPenaltyGasCost = _expectedPenaltyGasCost(
            _actualGasCost, _actualUserOpFeePerGas, ctx.postOpGas, ctx.preOpGasApproximation, ctx.executionGasLimit
        );

        uint256 actualGasCost = _actualGasCost + expectedPenaltyGasCost;

        uint256 costInToken =
            getCostInToken(actualGasCost, ctx.postOpGas, _actualUserOpFeePerGas, ctx.exchangeRate) + ctx.constantFee;

        uint256 absoluteCostInToken =
            costInToken > ctx.preFundCharged ? costInToken - ctx.preFundCharged : ctx.preFundCharged - costInToken;

        SafeTransferLib.safeTransferFrom(
            ctx.token,
            costInToken > ctx.preFundCharged ? ctx.sender : ctx.treasury,
            costInToken > ctx.preFundCharged ? ctx.treasury : ctx.sender,
            absoluteCostInToken
        );

        uint256 preFundInToken = (ctx.preFund * ctx.exchangeRate) / 1e18;

        if (ctx.recipient != address(0) && preFundInToken > costInToken) {
            SafeTransferLib.safeTransferFrom(ctx.token, ctx.sender, ctx.recipient, preFundInToken - costInToken);
        }

        emit UserOperationSponsored(ctx.userOpHash, ctx.sender, ERC20_MODE, ctx.token, costInToken, ctx.exchangeRate);
    }

如上所示,支付大师计算使用的实际 Gas 并尝试通过调用 safeTransferFrom 向用户收费。请注意,preFundCharged 可以为零,因为用户可以在验证阶段选择退出任何 preFund。如果用户没有给予 Pimlico 的支付大师足够的转账授权,则 innerHandleOp 中的 postOp 调用将还原,并且支付大师将无法从用户那里收取付款。

但是,即使 postOp 失败,EntryPoint 仍将完成执行并支付提交它的打包者。重要的是,此付款来自支付大师的存款,因为在验证期间,requiredPrefund 是从支付大师的 存款 中获取的。

这为实施执行后收费模式的支付大师创建了一个关键漏洞。即使 postOp 调用失败 (意味着支付大师无法从用户那里收取付款),支付大师仍然必须从他们的已存入资金中支付打包者的 Gas 成本。恶意打包者可以通过以下方式利用此漏洞:

  1. 打包者创建一个具有故意高的 gasPriceUserOperation
  2. 打包者通过在 postOp 执行之前撤销支付大师的代币授权来确保 postOp 调用将失败
  3. postOp 失败时,打包者仍然可以从支付大师那里获得他们的高 Gas 成本的报酬
  4. 支付大师会亏损,因为他们向打包者付款了,但无法从用户那里收取费用
  5. 只要他们的实际 Gas 成本低于他们收取的费用,打包者就可以获利

这实际上允许打包者通过提交设计为在 postOp 期间失败的 UserOperations,同时最大程度地提高他们可以向支付大师收取的 Gas 成本来耗尽支付大师存款。

一些支付大师尝试通过在签署和允许提交 UserOperation 之前模拟 UserOperation 执行来防止这种情况。但是,这种保护很容易被绕过,因为攻击者只需在模拟期间批准所需的代币授权即可通过验证,然后在通过 handleOps 提交 UserOperation 之前撤销授权。这意味着 postOp 将通过模拟,但在实际执行期间会失败,从而允许打包者从 EntryPoint 中耗尽支付大师的存款。

为了防止此漏洞,支付大师应实施执行前收费模式,而不是执行后收费模式。这意味着要求用户在操作执行之前,在验证阶段预先支付全额预计 Gas 成本。通过提前收取付款,支付大师可以防止恶意打包者利用的失败的执行后转账。

如果出于用户体验原因绝对需要执行后收费,则支付大师可以使用几种缓解策略。一种方法是将使用限制为受信任的打包者的白名单,但这会引入中心化问题。或者,Pimlico 尝试通过收紧 API 限制并限制其用户的 ERC-20 使用来解决此问题。

最安全的方法是要求预先预付款,即使它可能会暂时锁定更多用户资金。这种小的用户体验权衡值得它提供的针对支付大师利用的强大安全保证。

结论

ERC-4337 支付大师通过从最终用户那里抽象出 Gas 成本,从而实现了强大的新 UX 模式。但是,安全地实施它们需要仔细考虑标准的执行流程和潜在的攻击媒介。关键的经验教训是:

  1. 始终在验证期间 (而不是在执行之后) 收取全额款项
  2. 谨慎地进行 Gas 估算,并包括安全边际
  3. 仔细验证所有用户输入和代币转账
  4. 广泛测试,包括模拟恶意行为
  5. 始终查看新的 EntryPoint 版本中的更改,因为它们可能会影响你的支付大师的设计和安全假设最后一点尤其重要,因为 ERC-4337 标准在不断发展。EntryPoint合约行为的更改可能会破坏现有的paymaster 实现或引入新的安全考虑因素。开发人员在升级到新的 EntryPoint 版本时,应彻底审查发行说明和差异。

通过遵循这些最佳实践,开发人员可以构建强大的 paymaster,在增强用户体验的同时防止被利用。随着 ERC-4337 生态系统的成熟,安全的 paymaster 实现对于推动账户抽象的主流采用至关重要。

如果你正在构建一个 paymaster 并希望确保它免受这些和其他漏洞的攻击,请考虑从我们这里获得审计。我们的团队在审计 ERC-4337 实现方面拥有丰富的经验,可以帮助你在潜在的安全问题影响生产之前识别它们。

  • 原文链接: osec.io/blog/2025-12-02-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.