支付方
如果你想为你的用户赞助用户操作,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 的支付方相关字段(例如 paymasterData 、paymasterVerificationGasLimit )
|
要实现基于签名的赞助,你首先需要部署支付方合约。该合约将持有用于支付用户操作的 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,
},
});
时间窗口(validAfter
和 validUntil
)可以防止重放攻击,并允许你限制签名保持有效的时间。签名后,需要格式化支付方数据并将其附加到用户操作:
userOp.paymasterAndData = encodePacked(
["address", "uint128", "uint128", "bytes"],
[
paymasterECDSASigner.address,
paymasterVerificationGasLimit,
paymasterPostOpGasLimit,
encodePacked(
["uint48", "uint48", "bytes"],
[validAfter, validUntil, paymasterSignature]
),
]
);
paymasterVerificationGasLimit 和 paymasterPostOpGasLimit 值应根据支付方的复杂性进行调整。较高的值会增加 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 价格反馈
实现价格预言机的一种流行方法是使用 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 的例子是:
-
从各自的预言机获取当前的
ETH/USD
和USDC/USD
价格。 -
使用公式计算
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 合约遵循预先收费和退款模型:
-
在验证期间,它会预先收取最大可能的 Gas 成本
-
执行后,它会将任何未使用的 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 成本,并在执行后:
-
如果用户偿还担保人,担保人将获得他们的资金
-
如果用户未能偿还,担保人将承担费用
一个常见的用例是担保人支付用户领取空投操作的 Gas 费用:
|
要实现担保人功能,你的支付方需要扩展 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
]
)
]
);
当操作执行时:
-
在验证期间,支付方验证担保人的签名并从担保人的帐户预先支付
-
用户操作执行,可能会为用户提供 Token(如在空投申领中)
-
在后操作期间,支付方首先尝试从用户那里获得偿还
-
如果用户无法支付,则使用担保人的预先支付金额
-
发出事件,指示最终谁为该操作付款
这种方法支持新颖的用例,即用户不需要 Token 即可开始使用 web3 应用程序,并且可以在通过交易收到价值后支付费用。