订阅与收款
本指南将指导您构建一个订阅支付系统,订阅者只需授权一次,服务提供商即可通过 EIP-7702 账户抽象自动在每个账单周期进行收款。
您将构建什么
一个完整的订阅生命周期:订阅者委托并订阅一次,提供商按计划收款(展示第二个周期以证明重复行为),然后订阅者取消。
演示
step 1. Subscriber delegates EOA to SubscriptionManager (EIP-7702)
tx: 0x7702...aaaa
step 2. Subscriber registers subscription (10 USDT0 / 30 days)
subscriptionId: 0xabc...
nextChargeAt: 2026-05-23T12:00:00Z
step 3. Provider calls collect() on day 30
collected: 10 USDT0
gas cost: ~0.000050 USDT0
nextChargeAt: 2026-06-22T12:00:00Z
step 4. Provider calls collect() on day 60
collected: 10 USDT0
gas cost: ~0.000050 USDT0
nextChargeAt: 2026-07-22T12:00:00Z
step 5. Subscriber cancels
subscription: inactive概述
订阅者:─── Subscriber ───────────────────────────────────────
// One-time setup: delegate EOA to the subscription contract
signAuthorization(delegateContract)
sendTransaction({ type: 4, authorizationList: [signedAuth] })
// Subscribe: set billing terms on own EOA
sendTransaction({ to: self, data: subscribe(subscriptionId, provider, amount, interval) })
// Cancel: revoke billing access at any time
sendTransaction({ to: self, data: cancelSubscription(subscriptionId) })─── Service Provider ────────────────────────────────
// Each billing cycle: collect payment from subscriber's EOA
// The delegate contract verifies caller, billing schedule, and amount
sendTransaction({ to: subscriberEOA, data: collect(subscriptionId) })
// Automate with a cron job matching the billing interval
// The contract reverts if called before the interval has elapsed委托合约
订阅计费通过将订阅者的 EOA 委托给强制执行计费条款的合约来实现。通过 EIP-7702,订阅者的账户暂时获得合约逻辑,允许服务提供商在每个计费周期收款,而无需订阅者每次都签名。
您可以使用现有的已部署合约或部署自己的合约。下面的示例是一个最小的 SubscriptionManager 合约,它支持三种操作:
subscribe:为subscriptionId注册计费条款。collect:提供商收取该subscriptionId的下一个预定付款。cancelSubscription:订阅者撤销特定的订阅。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title SubscriptionManager (example)
/// @notice Delegate contract for EIP-7702 subscription billing.
/// Runs on the subscriber's EOA via delegation.
contract SubscriptionManager {
struct Subscription {
address provider;
uint256 amount;
uint256 interval;
uint256 nextChargeAt;
bool active;
}
// Keyed by subscriptionId.
// Storage is already per subscriber EOA under delegation.
mapping(bytes32 => Subscription) public subscriptions;
IERC20 public immutable usdt0;
event SubscriptionCreated(
bytes32 indexed subscriptionId,
address indexed provider,
uint256 amount,
uint256 interval,
uint256 nextChargeAt
);
event SubscriptionCollected(
bytes32 indexed subscriptionId,
address indexed provider,
uint256 amount,
uint256 collectedAt
);
event SubscriptionCancelled(bytes32 indexed subscriptionId);
constructor(address _usdt0) {
usdt0 = IERC20(_usdt0);
}
/// @notice Register a subscription. Called by the subscriber on their own EOA.
function subscribe(
bytes32 subscriptionId,
address provider,
uint256 amount,
uint256 interval
) external {
require(msg.sender == address(this), "subscriber only");
require(provider != address(0), "invalid provider");
require(amount > 0, "invalid amount");
require(interval > 0, "invalid interval");
require(!subscriptions[subscriptionId].active, "already exists");
uint256 nextChargeAt = block.timestamp + interval;
subscriptions[subscriptionId] = Subscription({
provider: provider,
amount: amount,
interval: interval,
nextChargeAt: nextChargeAt,
active: true
});
emit SubscriptionCreated(subscriptionId, provider, amount, interval, nextChargeAt);
}
/// @notice Collect a payment for a specific subscription. Called by the service provider.
function collect(bytes32 subscriptionId) external {
Subscription storage sub = subscriptions[subscriptionId];
require(sub.active, "not active");
require(msg.sender == sub.provider, "not provider");
require(block.timestamp >= sub.nextChargeAt, "too early");
sub.nextChargeAt += sub.interval;
require(usdt0.transfer(sub.provider, sub.amount), "transfer failed");
emit SubscriptionCollected(subscriptionId, sub.provider, sub.amount, block.timestamp);
}
/// @notice Cancel a specific subscription. Called by the subscriber.
function cancelSubscription(bytes32 subscriptionId) external {
require(msg.sender == address(this), "subscriber only");
require(subscriptions[subscriptionId].active, "not active");
delete subscriptions[subscriptionId];
emit SubscriptionCancelled(subscriptionId);
}
}配置
// config.ts
import { ethers } from "ethers";
export const STABLE_TESTNET_RPC = "https://rpc.testnet.stable.xyz";
export const CHAIN_ID = 2201;
export const USDT0_ADDRESS = "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9";
export const SUBSCRIPTION_MANAGER = "0xYourDeployedSubscriptionManager";
export const provider = new ethers.JsonRpcProvider(STABLE_TESTNET_RPC);
export const subscriberWallet = new ethers.Wallet(process.env.SUBSCRIBER_KEY!, provider);步骤 1:委托订阅者的 EOA (EIP-7702)
订阅者签署 EIP-7702 授权,将其 EOA 委托给 SubscriptionManager。此后,订阅者的 EOA 将执行委托合约的逻辑。
// delegate.ts
import { subscriberWallet, provider, CHAIN_ID, SUBSCRIPTION_MANAGER } from "./config";
// The subscriber sends this transaction itself, so the authorization must use
// the next nonce: the account nonce increments before authorizations are checked.
const txNonce = await provider.getTransactionCount(subscriberWallet.address);
const signedAuth = await subscriberWallet.authorize({
chainId: CHAIN_ID,
address: SUBSCRIPTION_MANAGER,
nonce: txNonce + 1,
});
const tx = await subscriberWallet.sendTransaction({
type: 4,
to: subscriberWallet.address,
authorizationList: [signedAuth],
maxPriorityFeePerGas: 0n,
});
const receipt = await tx.wait(1);
console.log("Delegation tx:", receipt.hash);npx tsx delegate.tsDelegation tx: 0x7702...aaaa步骤 2:注册订阅(订阅者)
订阅者在其自己的 EOA 上调用 subscribe()。由于 EOA 已委托,这将执行 SubscriptionManager.subscribe。
// subscribe.ts
import { ethers } from "ethers";
import { subscriberWallet } from "./config";
const subscriptionManager = new ethers.Interface([
"function subscribe(bytes32 subscriptionId, address provider, uint256 amount, uint256 interval)",
]);
const serviceProvider = "0xServiceProviderAddress";
const monthlyAmount = ethers.parseUnits("10", 6); // 10 USDT0
const interval = 30 * 24 * 60 * 60; // 30 days in seconds
// Derive a unique subscriptionId from provider + plan name + local nonce
const subscriptionId = ethers.solidityPackedKeccak256(
["address", "string", "uint256"],
[serviceProvider, "pro-monthly", 1]
);
const tx = await subscriberWallet.sendTransaction({
to: subscriberWallet.address, // call self (delegate code executes)
data: subscriptionManager.encodeFunctionData("subscribe", [
subscriptionId,
serviceProvider,
monthlyAmount,
interval,
]),
maxPriorityFeePerGas: 0n,
});
const receipt = await tx.wait(1);
console.log("Subscription registered, tx:", receipt.hash);
console.log("Subscription ID:", subscriptionId);npx tsx subscribe.tsSubscription registered, tx: 0xabcd...1234
Subscription ID: 0xfedc...9876步骤 3:收取款项(服务提供商)
每个账单周期,服务提供商在订阅者的 EOA 上调用 collect(subscriptionId)。委托逻辑在转账 USDT0 之前验证调用者、计费计划和金额。
// collect.ts
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://rpc.testnet.stable.xyz");
const providerWallet = new ethers.Wallet(process.env.PROVIDER_KEY!, provider);
const subscriptionManager = new ethers.Interface([
"function collect(bytes32 subscriptionId)",
]);
const subscriberEOA = "0xSubscriberEOAAddress";
const subscriptionId = "0xYourSubscriptionId";
const tx = await providerWallet.sendTransaction({
to: subscriberEOA, // subscriber's EOA (runs delegate code)
data: subscriptionManager.encodeFunctionData("collect", [subscriptionId]),
maxPriorityFeePerGas: 0n,
});
const receipt = await tx.wait(1);
console.log("Payment collected, tx:", receipt.hash);
console.log("Gas used:", receipt.gasUsed.toString());
// In production, run this on a cron schedule matching the billing interval.
// The delegate contract will revert if called before the interval has elapsed.npx tsx collect.tsPayment collected, tx: 0x8f3a...2d41
Gas used: 52000collect() 调用在 Stable 上大约需要 50k-55k gas(21k 基本费用 + 7702 委托开销 + ERC-20 transfer)。以 1 gwei 的基本费用计算,提供商每个账单周期支付大约 0.000050 USDT0。
步骤 4:取消订阅(订阅者)
订阅者在其自己的 EOA 上调用 cancelSubscription(subscriptionId),以撤销该特定订阅的计费访问权限。
// cancel.ts
import { ethers } from "ethers";
import { subscriberWallet } from "./config";
const subscriptionManager = new ethers.Interface([
"function cancelSubscription(bytes32 subscriptionId)",
]);
const subscriptionId = "0xYourSubscriptionId";
const tx = await subscriberWallet.sendTransaction({
to: subscriberWallet.address,
data: subscriptionManager.encodeFunctionData("cancelSubscription", [subscriptionId]),
maxPriorityFeePerGas: 0n,
});
const receipt = await tx.wait(1);
console.log("Subscription cancelled, tx:", receipt.hash);npx tsx cancel.tsSubscription cancelled, tx: 0xdef0...5678安全模型
订阅者授权委托合约从其 EOA 中提取资金。请务必了解该授权涵盖的范围以及如何限制风险。
订阅者授权的内容。 通过委托给 SubscriptionManager,订阅者授予合约逻辑对其 EOA 的完全执行权限。委托合约只能在以下条件下转账资金:调用者是注册的提供商,间隔已过,金额与存储的订阅相符。它不能转账到其他地址或绕过间隔检查,因为合约代码不允许这些操作。
- 恶意委托升级:如果
SubscriptionManager是一个代理,其实施可以由管理员更改,则授权实际上信任该管理员。只委托给不可变合约或具有透明、时间锁定升级的代理。 - 提供商泄露:如果提供商的密钥泄露,攻击者可以提前收取款项,最高可达每个周期的金额。订阅者应为每个订阅设置
spendingLimit,并监控未经授权的SubscriptionCollected事件。 - 委托替换:再次使用不同的委托进行订阅会清除订阅状态。使用支持在一次委托下实现多个功能(订阅、批量支付、支出限制)的模块化委托,而不是每个功能一个委托。
- 可重放签名:所有签名都使用绑定到订阅者 EOA 的 EIP-7702 Nonce,因此它们不能跨链或跨委托重放。
- 在投入生产使用前审计委托合约。
- 保持每个订阅的金额相对于订阅者的余额较小。
- 监控
SubscriptionCreated/SubscriptionCollected事件并将其显示给订阅者。 - 为订阅者提供清晰的“取消”UI,该 UI 在其自己的 EOA 上调用
cancelSubscription(subscriptionId)。
重要注意事项
- 持久委托:EIP-7702 委托将持续存在,直到订阅者明确更改或清除它。每个账单周期无需重新委托。
- 每个 EOA 单次委托:如果订阅者以后委托给不同的合约,则订阅委托逻辑将被替换,并且收款将失败。使用支持在一次委托下实现多个功能(订阅、批量支付、支出限制、会话密钥)的模块化委托合约。
- 计划行为:此示例在每次成功收款时将
nextChargeAt推进一个间隔。如果超过一个计费周期已过,重复调用collect()可以一次性追赶一个周期。如果您的产品需要不同的策略,请扩展逻辑。 - 使用经审计的委托:仅委托给已审计的合约。
接下来推荐
- 订阅计费概念:了解拉式计费模型。
- 账户抽象:将多个调用批量处理到一个委托 EOA 的交易中。
- 会话密钥:在经过审计的智能账户上授予范围受限、有时效的密钥。
- EIP-7702 概念:回顾使这成为可能的委托模型。

