订阅与收款
本指南将介绍如何构建一个订阅支付系统,其中订阅者只需授权一次,服务提供商即可通过 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";
const authorization = {
chainId: CHAIN_ID,
address: SUBSCRIPTION_MANAGER,
nonce: await provider.getTransactionCount(subscriberWallet.address),
};
const signedAuth = await subscriberWallet.signAuthorization(authorization);
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 基础 gas + 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 随机数,因此它们不能跨链或跨委托重放。
- 在生产使用前审计委托合约。
- 将每个订阅的金额保持在订阅者余额的较小比例。
- 监控
SubscriptionCreated/SubscriptionCollected事件并将其显示给订阅者。 - 提供订阅者清晰的“取消”UI,在其自己的 EOA 上调用
cancelSubscription(subscriptionId)。
重要考虑事项
- 持久委托:EIP-7702 委托将一直存在,直到订阅者明确更改或清除它。每个计费周期无需重新委托。
- 每个 EOA 的单一委托:如果订阅者以后委托给不同的合约,订阅委托逻辑将被替换,并且收款将失败。使用支持多个功能(订阅、批量支付、支出限制、会话密钥)的模块化委托合约,所有这些功能都在一个委托下。
- 计划行为:此示例在每次成功收款时将
nextChargeAt增加一个间隔。如果已经过去不止一个计费周期,则重复的collect()调用可以一次赶上一个周期。如果您的产品需要不同的策略,请扩展逻辑。 - 使用经过审计的委托合约:仅委托给经过审计的合约。
接下来推荐
- 订阅计费概念:了解拉式计费模型。
- 账户抽象:了解批量支付、支出限制和会话密钥如何在单个委托下结合使用。
- EIP-7702 概念:回顾使这成为可能的委托模型。

