Paymaster - OpenZeppelin 文档

本文介绍了ERC-4337中Paymaster的概念,Paymaster允许第三方为用户的交易支付gas费用。文章详细讲解了两种Paymaster的实现方式:基于签名的赞助,以及基于ERC20代币的赞助,包括如何使用Chainlink预言机动态获取代币价格,以及如何使用担保人模式为用户提供初始资金。此外,还讨论了在生产环境中实施Paymaster时需要考虑的实际问题。

Paymaster

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

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

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

签名赞助

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

了解更多关于 签名者 的信息,以探索通过签名进行用户操作赞助的不同方法。
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
import {PaymasterSigner, EIP712} from "@openzeppelin/community-contracts/account/paymaster/PaymasterSigner.sol";
import {SignerECDSA} from "@openzeppelin/community-contracts/utils/cryptography/signers/SignerECDSA.sol";

contract PaymasterECDSASigner is PaymasterSigner, SignerECDSA, Ownable {
    constructor(address signerAddr) EIP712("MyPaymasterECDSASigner", "1") Ownable(signerAddr) {
        _setSigner(signerAddr);
    }

    function _authorizeWithdraw() internal virtual override onlyOwner {}
}
使用 ERC4337Utils 来简化对 userOp 的 paymaster 相关字段的访问(例如 paymasterDatapaymasterVerificationGasLimit

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

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

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

// Set validation window
// 设置验证窗口
const now = Math.floor(Date.now() / 1000);
const validAfter = now - 60; // Valid from 1 minute ago
// 从 1 分钟前生效
const validUntil = now + 3600; // Valid for 1 hour
// 有效期为 1 小时
const paymasterVerificationGasLimit = 100_000n;
const paymasterPostOpGasLimit = 300_000n;

// Sign using EIP-712 typed data
// 使用 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)可以防止重放攻击,并允许你限制签名保持有效的时间。签名后,需要格式化 paymaster 数据,并将其附加到用户操作:

userOp.paymasterAndData = encodePacked(
  ["address", "uint128", "uint128", "bytes"],
  [\
    paymasterECDSASigner.address,\
    paymasterVerificationGasLimit,\
    paymasterPostOpGasLimit,\
    encodePacked(\
      ["uint48", "uint48", "bytes"],\
      [validAfter, validUntil, paymasterSignature]\
    ),\
  ]
);
paymasterVerificationGasLimitpaymasterPostOpGasLimit 的值应根据你的 paymaster 的复杂性进行调整。较高的值会增加 gas 成本,但会提供更多的执行空间,从而降低验证或后操作处理期间发生 out-of-gas 错误的可能性。

通过附加的 paymaster 数据,用户操作现在可以由帐户签名者签名并提交到 EntryPoint 合约:

// Sign the user operation with the account owner
// 使用帐户所有者签署用户操作
const signedUserOp = await signUserOp(entrypoint, userOp);

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

在后台,EntryPoint 将调用 paymaster 的 validatePaymasterUserOp 函数,该函数验证签名和时间窗口。如果有效,paymaster 会承诺支付操作的 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) {
    // Implement logic to fetch the token and token price from the userOp
    // 实施逻辑以从 userOp 获取 token 和 token 价格
}

使用 Oracles

Chainlink 价格信息

实现价格预言机的常用方法是使用 Chainlink 的价格信息。通过使用他们的 AggregatorV3Interface,开发人员可以动态地确定其 paymaster 的 token 到 ETH 的汇率。这确保了公平的定价,即使市场利率波动。

考虑以下合约:

// WARNING: Unaudited code.
// 警告:未经审计的代码。
// Consider performing a security review before going to production.
// 考虑在投入生产之前进行安全审查。
contract PaymasterUSDCChainlink is PaymasterERC20, Ownable {
    // Values for sepolia
    // Sepolia 的值
    // See https://docs.chain.link/data-feeds/price-feeds/addresses
    // 请参阅 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);

    // See https://sepolia.etherscan.io/token/0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
    // 请参阅 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; // Tolerate stale data
        // 容忍陈旧数据
    }

    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)。这给了我们购买 1 ETH 需要多少 USDC token。

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

以下是使用这种方法实现的 _fetchOracleDetails 的外观:

使用 ERC4337Utils.combineValidationData 合并两个 validationData 值。
// WARNING: Unaudited code.
// 警告:未经审计的代码。
// Consider performing a security review before going to production.
// 考虑在投入生产之前进行安全审查。

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) {
        // No negative prices
        // 没有负价格
        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) // Safe upcast
        // 安全向上转型
    );
}

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 || // No data
        // 没有数据
        answeredInRound < roundId || // Not answered in round
        // 未在本轮中回答
        timestamp == 0 || // Incomplete round
        // 不完整的轮
        block.timestamp - timestamp > liveness() // Stale data
        // 陈旧数据
    ) {
        return (ERC4337Utils.SIG_VALIDATION_FAILED, 0);
    }
    return (ERC4337Utils.SIG_VALIDATION_SUCCESS, price_);
}
基于 token 的赞助的一个重要区别是,用户的智能帐户必须首先批准 paymaster 支出其 token。你可能希望将此批准纳入你的帐户初始化过程的一部分,或者在执行操作之前检查是否需要批准。

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

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

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

该模型确保 paymaster 始终可以支付 gas 成本,同时仅向用户收取实际使用的 gas。

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

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

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

// Sign the user operation with the account owner
// 使用帐户所有者签署用户操作
const signedUserOp = await signUserOp(entrypoint, userOp);

// Submit to the EntryPoint contract
// 提交到 EntryPoint 合约
const userOpReceipt = await eoaClient.writeContract({
  abi: EntrypointV08Abi,
  address: entrypoint.address,
  functionName: "handleOps",
  args: [[signedUserOp], beneficiary.address],
});
基于 Oracle 的定价依赖于价格信息的准确性和新鲜度。PaymasterUSDCChainlink 包括对陈旧数据的安全检查,但你仍然应该监控可能影响你的用户的极端市场波动。

使用担保人

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

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

  1. 如果用户偿还了担保人,则担保人可以收回他们的资金

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

一个常见的用例是担保人支付用户申领空投的操作费用:<br>- 担保人预先支付 gas 费用<br> <br>- 用户申领他们的空投 token<br> <br>- 用户从申领的 token 中偿还担保人<br> <br>- 如果用户未能偿还,则担保人承担费用

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

function _fetchGuarantor(
    PackedUserOperation calldata userOp
) internal view override returns (address guarantor) {
    // Implement logic to fetch and validate the guarantor from userOp
    // 实施逻辑以从 userOp 获取和验证担保人
}

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

// WARNING: Unaudited code.
// 警告:未经审计的代码。
// Consider performing a security review before going to production.
// 考虑在投入生产之前进行安全审查。
contract PaymasterUSDCGuaranteed is EIP712, PaymasterERC20Guarantor, Ownable {

    // Keep the same oracle code as before...
    // 保持与以前相同的预言机代码...

    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) {}

    // Other functions from PaymasterUSDCChainlink...
    // PaymasterUSDCChainlink 中的其他函数...

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

        // Check guarantor data (should be at least 22 bytes: 20 for address + 2 for sig length)
        // 检查担保人数据(至少应为 22 个字节:20 个字节用于地址 + 2 个字节用于签名长度)
        // If no guarantor specified, return early
        // 如果未指定担保人,则提前返回
        if (paymasterData.length &lt; 22 || guarantor == address(0)) {
            return address(0);
        }

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

        // Ensure the signature fits in the data
        // 确保签名适合数据
        if (paymasterData.length &lt; 22 + guarantorSigLength) {
            return address(0);
        }

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

        // Validate the guarantor's signature
        // 验证担保人的签名
        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])) // Just the guarantor address part
                // 仅是担保人地址部分
            )
        );
    }
}

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

// Sign the user operation with the guarantor
// 使用担保人签署用户操作
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 // Just the guarantor address
    // 仅是担保人地址
  },
});

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

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. 在验证期间,paymaster 验证担保人的签名并从担保人的帐户预先支付资金

  2. 用户操作执行,可能会给用户 token(如在空投申领中)

  3. 在后操作期间,paymaster 首先尝试从用户那里获得还款

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

  5. 发出一个事件,指示谁最终支付了该操作

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

实际考虑

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

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

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

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

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

  5. 支出限制:考虑实施每日或每个用户的限制,以防止滥用你的 paymaster。

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

← 模块

跨链 →

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

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。