本文介绍了ERC-4337中Paymaster的概念,Paymaster允许第三方为用户的交易支付gas费用。文章详细讲解了两种Paymaster的实现方式:基于签名的赞助,以及基于ERC20代币的赞助,包括如何使用Chainlink预言机动态获取代币价格,以及如何使用担保人模式为用户提供初始资金。此外,还讨论了在生产环境中实施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 相关字段的访问(例如 paymasterData ,paymasterVerificationGasLimit ) |
要实现基于签名的赞助,你首先需要部署 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,
},
});
时间窗口(validAfter
和 validUntil
)可以防止重放攻击,并允许你限制签名保持有效的时间。签名后,需要格式化 paymaster 数据,并将其附加到用户操作:
userOp.paymasterAndData = encodePacked(
["address", "uint128", "uint128", "bytes"],
[\
paymasterECDSASigner.address,\
paymasterVerificationGasLimit,\
paymasterPostOpGasLimit,\
encodePacked(\
["uint48", "uint48", "bytes"],\
[validAfter, validUntil, paymasterSignature]\
),\
]
);
paymasterVerificationGasLimit 和 paymasterPostOpGasLimit 的值应根据你的 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 执行该操作。
虽然基于签名的赞助对许多应用程序都很有用,但有时你希望用户可以使用 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 价格
}
实现价格预言机的常用方法是使用 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 的例子是:
从各自的预言机中获取当前的 ETH/USD
和 USDC/USD
价格。
使用公式计算 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 合约遵循预先收费和退款模型:
在验证期间,它会预先收取最大可能的 gas 成本
执行后,它会将任何未使用的 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 成本,并且在执行后:
如果用户偿还了担保人,则担保人可以收回他们的资金
如果用户未能偿还,则担保人承担费用
一个常见的用例是担保人支付用户申领空投的操作费用:<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 < 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 < 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\
]\
)\
]
);
当操作执行时:
在验证期间,paymaster 验证担保人的签名并从担保人的帐户预先支付资金
用户操作执行,可能会给用户 token(如在空投申领中)
在后操作期间,paymaster 首先尝试从用户那里获得还款
如果用户无法付款,则使用担保人预先支付的金额
发出一个事件,指示谁最终支付了该操作
这种方法支持了新颖的用例,即用户无需 token 即可开始使用 web3 应用程序,并且可以在通过交易收到价值后支付费用。
在生产环境中实施 paymaster 时,请记住以下注意事项:
余额管理:定期监控和补充你的 paymaster 的 ETH 余额,以确保不间断的服务。
Gas 限制:应仔细设置验证和后操作 gas 限制。太低,操作可能会失败;太高,你会浪费资源。
安全性:对于基于签名的 paymaster,请保护你的签名密钥,因为它控制谁获得补贴的操作。
价格波动:对于基于 token 的 paymaster,请考虑限制接受哪些 token,并为极端市场条件实施断路器。
支出限制:考虑实施每日或每个用户的限制,以防止滥用你的 paymaster。
对于生产部署,实施监控服务通常很有用,该服务跟踪 paymaster 使用情况、余额和其他指标,以确保平稳运行。 |
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!