订阅与收款
本指南将引导你构建一个订阅支付系统,订阅者只需授权一次,服务提供方便可在每个计费周期通过 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: 52000在 Stable 上,一次 collect() 调用大约消耗 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事件并向订阅者展示。 - 为订阅者提供清晰的"取消"界面,在其自己的 EOA 上调用
cancelSubscription(subscriptionId)。
重要注意事项
- 持久委托:EIP-7702 委托会持续存在,直到订阅者明确更改或清除它。无需每个计费周期重新委托。
- 每个 EOA 单一委托:如果订阅者之后委托给不同的合约,订阅委托逻辑将被替换,收款将失败。请使用模块化委托合约,在单一委托下支持多种功能(订阅、批量支付、支出限额、会话密钥)。
- 计划行为:此示例在每次成功收款时将
nextChargeAt推进一个间隔。如果已经过去了多个计费周期,重复的collect()调用可以逐个周期追赶。如果你的产品需要不同的策略,请扩展该逻辑。 - 使用经过审计的委托方:仅委托给已经过审计的合约。
下一步推荐
- 订阅计费概念 — 了解基于拉取的计费模型。
- 账户抽象 — 了解批量支付、支出限额和会话密钥如何在单一委托下组合。
- EIP-7702 概念 — 回顾使这一切成为可能的委托模型。

