支付方

如果你想为你的用户赞助用户操作,ERC-4337 定义了一种称为 支付方(paymaster) 的特殊类型的合约,其目的是支付用户操作消耗的 Gas 费用。

在账户抽象的上下文中,赞助用户操作允许第三方代表用户支付交易 Gas 费用。这可以通过消除用户持有原生加密货币(如 ETH)来支付交易的需求,从而改善用户体验。

为了启用赞助,用户签署他们的用户操作,其中包括一个名为 paymasterAndData 的特殊字段,该字段是由他们打算使用的支付方地址和将要传递到 validatePaymasterUserOp 中的相关 calldata 串联而成。EntryPoint 将使用此字段来确定它是否愿意为用户操作付费。

签名赞助

PaymasterSigner 通过授权签名实现基于签名的赞助,允许指定的支付方签名者授权和赞助特定的用户操作,而无需用户持有原生 ETH。

了解更多关于 签名者 的信息,以探索通过签名进行用户操作赞助的不同方法。
Unresolved include directive in modules/ROOT/pages/paymasters.adoc - include::api:example$account/paymaster/PaymasterECDSASigner.sol[]
使用 ERC4337Utils 以方便访问 userOp 的支付方相关字段(例如 paymasterDatapaymasterVerificationGasLimit

要实现基于签名的赞助,你首先需要部署支付方合约。该合约将持有用于支付用户操作的 ETH,并验证来自你授权签名者的签名。部署后,你必须使用 ETH 为支付方提供资金,以支付其将赞助的操作的 Gas 成本:

// 使用 ETH 为支付方提供资金
await eoaClient.sendTransaction({
  to: paymasterECDSASigner.address,
  value: parseEther("0.01"),
  data: encodeFunctionData({
    abi: paymasterECDSASigner.abi,
    functionName: "deposit",
    args: [],
  }),
});
支付方需要有足够的 ETH 余额来支付 Gas 成本。如果支付方耗尽资金,其旨在赞助的所有操作都将失败。考虑在生产环境中实施监控和自动补充支付方余额的功能。

当用户发起需要赞助的操作时,你的后端服务(或其他授权实体)需要使用 EIP-712 对操作进行签名。此签名向支付方证明它应该支付此特定用户操作的 Gas 成本:

// 设置验证窗口
const now = Math.floor(Date.now() / 1000);
const validAfter = now - 60; // 从 1 分钟前开始有效
const validUntil = now + 3600; // 有效期为 1 小时
const paymasterVerificationGasLimit = 100_000n;
const paymasterPostOpGasLimit = 300_000n;

// 使用 EIP-712 类型化数据进行签名
const paymasterSignature = await signer.signTypedData({
  domain: {
    chainId: await signerClient.getChainId(),
    name: "MyPaymasterECDSASigner",
    verifyingContract: paymasterECDSASigner.address,
    version: "1",
  },
  types: {
    UserOperationRequest: [
      { name: "sender", type: "address" },
      { name: "nonce", type: "uint256" },
      { name: "initCode", type: "bytes" },
      { name: "callData", type: "bytes" },
      { name: "accountGasLimits", type: "bytes32" },
      { name: "preVerificationGas", type: "uint256" },
      { name: "gasFees", type: "bytes32" },
      { name: "paymasterVerificationGasLimit", type: "uint256" },
      { name: "paymasterPostOpGasLimit", type: "uint256" },
      { name: "validAfter", type: "uint48" },
      { name: "validUntil", type: "uint48" },
    ],
  },
  primaryType: "UserOperationRequest",
  message: {
    sender: userOp.sender,
    nonce: userOp.nonce,
    initCode: userOp.initCode,
    callData: userOp.callData,
    accountGasLimits: userOp.accountGasLimits,
    preVerificationGas: userOp.preVerificationGas,
    gasFees: userOp.gasFees,
    paymasterVerificationGasLimit,
    paymasterPostOpGasLimit,
    validAfter,
    validUntil,
  },
});

时间窗口(validAftervalidUntil)可以防止重放攻击,并允许你限制签名保持有效的时间。签名后,需要格式化支付方数据并将其附加到用户操作:

userOp.paymasterAndData = encodePacked(
  ["address", "uint128", "uint128", "bytes"],
  [
    paymasterECDSASigner.address,
    paymasterVerificationGasLimit,
    paymasterPostOpGasLimit,
    encodePacked(
      ["uint48", "uint48", "bytes"],
      [validAfter, validUntil, paymasterSignature]
    ),
  ]
);
paymasterVerificationGasLimitpaymasterPostOpGasLimit 值应根据支付方的复杂性进行调整。较高的值会增加 Gas 成本,但会提供更多的执行空间,从而降低验证或后操作处理期间 Gas 不足错误的风险。

附加支付方数据后,用户操作现在可以由帐户签名者签名并提交到 EntryPoint 合约:

// 使用帐户所有者签署用户操作
const signedUserOp = await signUserOp(entrypoint, userOp);

// 提交到 EntryPoint 合约
const userOpReceipt = await eoaClient.writeContract({
  abi: EntrypointV08Abi,
  address: entrypoint.address,
  functionName: "handleOps",
  args: [[signedUserOp], beneficiary.address],
});

在幕后,EntryPoint 将调用支付方的 validatePaymasterUserOp 函数,该函数会验证签名和时间窗口。如果有效,支付方将承诺支付操作的 Gas 成本,并且 EntryPoint 将执行该操作。

基于 ERC20 的赞助

虽然基于签名的赞助对许多应用程序都很有用,但有时你希望用户使用 Token 而不是 ETH 来支付他们自己的交易。PaymasterERC20 允许用户使用 ERC-20 Token 支付 Gas 费用。开发人员必须实现 _fetchDetails 以从他们首选的预言机获取 Token 价格信息。

function _fetchDetails(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash
) internal view override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) {
    // 实现从 userOp 获取 Token 和 Token 价格的逻辑
}

使用预言机

实现价格预言机的一种流行方法是使用 Chainlink 的价格反馈。通过使用他们的 AggregatorV3Interface,开发人员可以动态地确定其支付方的 Token 到 ETH 的汇率。即使市场汇率波动,这也能确保公平定价。

考虑以下合约:

// WARNING: 未经审计的代码。
// 考虑在投入生产之前执行安全审查。
contract PaymasterUSDCChainlink is PaymasterERC20, Ownable {
    // Sepolia 的值
    // 请参见 https://docs.chain.link/data-feeds/price-feeds/addresses
    AggregatorV3Interface public constant USDC_USD_ORACLE =
        AggregatorV3Interface(0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E);
    AggregatorV3Interface public constant ETH_USD_ORACLE =
        AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306);

    // 请参见 https://sepolia.etherscan.io/token/0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
    IERC20 private constant USDC =
        IERC20(0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238);

    constructor(address initialOwner) Ownable(initialOwner) {}

    function _authorizeWithdraw() internal virtual override onlyOwner {}

    function liveness() public view virtual returns (uint256) {
        return 15 minutes; // 容忍过期数据
    }

    function _fetchDetails(
        PackedUserOperation calldata userOp,
        bytes32 /* userOpHash */
    ) internal view virtual override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) {
        (uint256 validationData_, uint256 price) = _fetchOracleDetails(userOp);
        return (
            validationData_,
            USDC,
            price
        );
    }

    function _fetchOracleDetails(
        PackedUserOperation calldata /* userOp */
    )
        internal
        view
        virtual
        returns (uint256 validationData, uint256 tokenPrice)
    {
      // ...
    }
}
PaymasterUSDCChainlink 合约在 Sepolia 上使用特定的 Chainlink 价格反馈(ETH/USD 和 USDC/USD)。对于生产用途或其他网络,你需要修改合约以使用适当的价格反馈地址。

正如你所看到的,指定了一个 _fetchOracleDetails 函数来获取 Token 价格,该价格将用作计算最终 ERC-20 支付的参考。可以从 Chainlink 预言机获取和处理价格数据,以确定具体 ERC-20 和 ETH 之间的汇率。一个关于 USDC 的例子是:

  1. 从各自的预言机获取当前的 ETH/USDUSDC/USD 价格。

  2. 使用公式计算 USDC/ETH 汇率:USDC/ETH = (USDC/USD) / (ETH/USD)。这告诉我们需要多少 USDC Token 才能购买 1 ETH。

ERC-20 的价格必须由 _tokenPriceDenominator 缩放。

以下是使用此方法实现 _fetchOracleDetails 的方式:

使用 ERC4337Utils.combineValidationData 来合并两个 validationData 值。
// WARNING: 未经审计的代码。
// 考虑在投入生产之前执行安全审查。

using SafeCast for *;
using ERC4337Utils for *;

function _fetchOracleDetails(
    PackedUserOperation calldata /* userOp */
)
    internal
    view
    virtual
    returns (uint256 validationData, uint256 tokenPrice)
{
    (uint256 ETHUSDValidationData, int256 ETHUSD) = _fetchPrice(
        ETH_USD_ORACLE
    );
    (uint256 USDCUSDValidationData, int256 USDCUSD) = _fetchPrice(
        USDC_USD_ORACLE
    );

    if (ETHUSD <= 0 || USDCUSD <= 0) {
        // 没有负价格
        return (ERC4337Utils.SIG_VALIDATION_FAILED, 0);
    }

    // eth / usdc = (usdc / usd) / (eth / usd) = usdc * usd / eth * usd = usdc / eth
    int256 scale = _tokenPriceDenominator().toInt256();
    int256 scaledUSDCUSD = USDCUSD * scale * (10 ** ETH_USD_ORACLE.decimals()).toInt256();
    int256 scaledUSDCETH = scaledUSDCUSD / (ETHUSD * (10 ** USDC_USD_ORACLE.decimals()).toInt256());

    return (
        ETHUSDValidationData.combineValidationData(USDCUSDValidationData),
        uint256(scaledUSDCETH) // 安全向上转型
    );
}

function _fetchPrice(
    AggregatorV3Interface oracle
) internal view virtual returns (uint256 validationData, int256 price) {
    (
        uint80 roundId,
        int256 price_,
        ,
        uint256 timestamp,
        uint80 answeredInRound
    ) = oracle.latestRoundData();
    if (
        price_ == 0 || // 没有数据
        answeredInRound < roundId || // 未在 round 中回答
        timestamp == 0 || // 不完整的 round
        block.timestamp - timestamp > liveness() // 过期数据
    ) {
        return (ERC4337Utils.SIG_VALIDATION_FAILED, 0);
    }
    return (ERC4337Utils.SIG_VALIDATION_SUCCESS, price_);
}
基于 Token 的赞助的一个重要区别是,用户的智能账户必须首先批准支付方花费他们的 Token。你可能希望将此批准作为帐户初始化过程的一部分,或者在执行操作之前检查是否需要批准。

PaymasterERC20 合约遵循预先收费和退款模型:

  1. 在验证期间,它会预先收取最大可能的 Gas 成本

  2. 执行后,它会将任何未使用的 Gas 退还给用户

此模型可确保支付方始终可以支付 Gas 成本,同时仅向用户收取实际使用的 Gas 费用。

const paymasterVerificationGasLimit = 150_000n;
const paymasterPostOpGasLimit = 300_000n;

userOp.paymasterAndData = encodePacked(
  ["address", "uint128", "uint128", "bytes"],
  [
    paymasterUSDCChainlink.address,
    paymasterVerificationGasLimit,
    paymasterPostOpGasLimit,
    "0x" // 不需要其他数据
  ]
);

对于其余部分,一旦设置了 paymasterAndData 字段,你就可以像往常一样签署用户操作。

// 使用帐户所有者签署用户操作
const signedUserOp = await signUserOp(entrypoint, userOp);

// 提交到 EntryPoint 合约
const userOpReceipt = await eoaClient.writeContract({
  abi: EntrypointV08Abi,
  address: entrypoint.address,
  functionName: "handleOps",
  args: [[signedUserOp], beneficiary.address],
});
基于预言机的定价依赖于价格反馈的准确性和新鲜度。PaymasterUSDCChainlink 包含针对过期数据的安全检查,但你仍应监控可能影响用户的极端市场波动。

使用担保人

在交易发生之前,用户可能没有足够的 Token 来支付交易的 Gas 费用,这有多种有效的情况。例如,如果用户正在领取空投,他们可能需要赞助他们的第一笔交易。对于这些情况,PaymasterERC20Guarantor 合约扩展了标准 PaymasterERC20,以允许第三方(担保人)支持用户操作。

担保人预先支付最大可能的 Gas 成本,并在执行后:

  1. 如果用户偿还担保人,担保人将获得他们的资金

  2. 如果用户未能偿还,担保人将承担费用

一个常见的用例是担保人支付用户领取空投操作的 Gas 费用:

  • 担保人预先支付 Gas 费用

  • 用户领取他们的空投 Token

  • 用户从领取的 Token 中偿还担保人

  • 如果用户未能偿还,担保人将承担费用

要实现担保人功能,你的支付方需要扩展 PaymasterERC20Guarantor 类并实现 _fetchGuarantor 函数:

function _fetchGuarantor(
    PackedUserOperation calldata userOp
) internal view override returns (address guarantor) {
    // 实现从 userOp 获取和验证担保人的逻辑
}

让我们通过扩展之前的示例来创建一个启用担保人的支付方:

// WARNING: 未经审计的代码。
// 考虑在投入生产之前执行安全审查。
contract PaymasterUSDCGuaranteed is EIP712, PaymasterERC20Guarantor, Ownable {

    // 保持与之前相同的预言机代码...

    bytes32 private constant GUARANTEED_USER_OPERATION_TYPEHASH =
        keccak256(
            "GuaranteedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterData)"
        );

    constructor(
        address initialOwner
    ) EIP712("PaymasterUSDCGuaranteed", "1") Ownable(initialOwner) {}

    // 来自 PaymasterUSDCChainlink 的其他函数...

    function _fetchGuarantor(
        PackedUserOperation calldata userOp
    ) internal view override returns (address guarantor) {
        bytes calldata paymasterData = userOp.paymasterData();

        // 检查担保人数据(至少应为 22 字节:20 个用于地址 + 2 个用于签名长度)
        // 如果未指定担保人,则提前返回
        if (paymasterData.length < 22 || guarantor == address(0)) {
            return address(0);
        }

        guarantor = address(bytes20(paymasterData[:20]));
        uint16 guarantorSigLength = uint16(bytes2(paymasterData[20:22]));

        // 确保签名适合数据
        if (paymasterData.length < 22 + guarantorSigLength) {
            return address(0);
        }

        bytes calldata guarantorSignature = paymasterData[22:22 + guarantorSigLength];

        // 验证担保人的签名
        bytes32 structHash = _getGuaranteedOperationStructHash(userOp);
        bytes32 hash = _hashTypedDataV4(structHash);

        return SignatureChecker.isValidSignatureNow(
            guarantor,
            hash,
            guarantorSignature
        ) ? guarantor : address(0);
    }

    function _getGuaranteedOperationStructHash(
        PackedUserOperation calldata userOp
    ) internal pure returns (bytes32) {
        return keccak256(
            abi.encode(
                GUARANTEED_USER_OPERATION_TYPEHASH,
                userOp.sender,
                userOp.nonce,
                keccak256(userOp.initCode),
                keccak256(userOp.callData),
                userOp.accountGasLimits,
                userOp.preVerificationGas,
                userOp.gasFees,
                keccak256(bytes(userOp.paymasterData()[:20])) // 仅担保人地址部分
            )
        );
    }
}

通过此实现,担保人将签署用户操作以授权支持它:

// 使用担保人签署用户操作
const guarantorSignature = await guarantor.signTypedData({
  domain: {
    chainId: await guarantorClient.getChainId(),
    name: "PaymasterUSDCGuaranteed",
    verifyingContract: paymasterUSDC.address,
    version: "1",
  },
  types: {
    GuaranteedUserOperation: [
      { name: "sender", type: "address" },
      { name: "nonce", type: "uint256" },
      { name: "initCode", type: "bytes" },
      { name: "callData", type: "bytes" },
      { name: "accountGasLimits", type: "bytes32" },
      { name: "preVerificationGas", type: "uint256" },
      { name: "gasFees", type: "bytes32" },
      { name: "paymasterData", type: "bytes" }
    ]
  },
  primaryType: "GuaranteedUserOperation",
  message: {
    sender: userOp.sender,
    nonce: userOp.nonce,
    initCode: userOp.initCode,
    callData: userOp.callData,
    accountGasLimits: userOp.accountGasLimits,
    preVerificationGas: userOp.preVerificationGas,
    gasFees: userOp.gasFees,
    paymasterData: guarantorAddress // 仅担保人地址
  },
});

然后,我们将担保人的地址及其签名包含在支付方数据中:

const paymasterVerificationGasLimit = 150_000n;
const paymasterPostOpGasLimit = 300_000n;

userOp.paymasterAndData = encodePacked(
  ["address", "uint128", "uint128", "bytes"],
  [
    paymasterUSDC.address,
    paymasterVerificationGasLimit,
    paymasterPostOpGasLimit,
    encodePacked(
      ["address", "bytes2", "bytes"],
      [
        guarantorAddress,
        toHex(guarantorSignature.replace("0x", "").length / 2, { size: 2 }),
        guarantorSignature
      ]
    )
  ]
);

当操作执行时:

  1. 在验证期间,支付方验证担保人的签名并从担保人的帐户预先支付

  2. 用户操作执行,可能会为用户提供 Token(如在空投申领中)

  3. 在后操作期间,支付方首先尝试从用户那里获得偿还

  4. 如果用户无法支付,则使用担保人的预先支付金额

  5. 发出事件,指示最终谁为该操作付款

这种方法支持新颖的用例,即用户不需要 Token 即可开始使用 web3 应用程序,并且可以在通过交易收到价值后支付费用。

实际考虑

在生产环境中实施支付方时,请记住以下注意事项:

  1. 余额管理:定期监控和补充支付方的 ETH 余额,以确保不间断的服务。

  2. Gas 限制:应仔细设置验证和后操作 Gas 限制。太低,操作可能会失败;太高,你会浪费资源。

  3. 安全性:对于基于签名的支付方,请保护你的签名密钥,因为它控制着谁获得补贴操作。

  4. 价格波动:对于基于 Token 的支付方,请考虑限制接受哪些 Token,并为极端的市场条件实施断路器。

  5. 消费限制:考虑实施每日或每用户限制,以防止滥用你的支付方。

对于生产部署,实施一个监控服务通常很有用,该服务跟踪支付方的使用情况、余额和其他指标,以确保平稳运行。