Subscribe and collect
This guide walks through building a subscription payment system where the subscriber authorizes once and the service provider collects each billing cycle automatically via EIP-7702 account abstraction.
What you'll build
A full subscription lifecycle: the subscriber delegates and subscribes once, the provider collects on schedule (second cycle shown to prove repeat behavior), and the subscriber cancels.
Demo
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: inactiveOverview
Subscriber:─── 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 elapsedDelegate contract
Subscription billing works by delegating the subscriber's EOA to a contract that enforces billing terms. Through EIP-7702, the subscriber's account temporarily gains contract logic, allowing a service provider to collect payments at each billing cycle without requiring the subscriber to sign every time.
You can use an existing deployed contract or deploy your own. The example below is a minimal SubscriptionManager contract that supports three operations:
subscribe: register billing terms for asubscriptionId.collect: provider pulls the next scheduled payment for thatsubscriptionId.cancelSubscription: subscriber revokes a specific subscription.
// 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);
}
}Configuration
// 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);Step 1: Delegate the subscriber's EOA (EIP-7702)
The subscriber signs an EIP-7702 authorization to delegate their EOA to the SubscriptionManager. After this, the subscriber's EOA executes the delegate contract's logic.
// 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...aaaaStep 2: Register a subscription (subscriber)
The subscriber calls subscribe() on their own EOA. Since the EOA is delegated, this executes 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...9876Step 3: Collect a payment (service provider)
Each billing cycle, the service provider calls collect(subscriptionId) on the subscriber's EOA. The delegate logic verifies the caller, billing schedule, and amount before transferring 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: 52000A collect() call costs roughly 50k-55k gas on Stable (21k base + 7702 delegation overhead + ERC-20 transfer). At a 1 gwei base fee, that's approximately 0.000050 USDT0 per billing cycle paid by the provider.
Step 4: Cancel a subscription (subscriber)
The subscriber calls cancelSubscription(subscriptionId) on their own EOA to revoke billing access for that specific subscription.
// 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...5678Security model
The subscriber is authorizing the delegate contract to pull funds from their EOA. Understand exactly what that authorization covers and how to limit exposure.
What the subscriber is authorizing. By delegating to SubscriptionManager, the subscriber grants the contract's logic full execution authority over their EOA. The delegate can only transfer funds under the conditions coded into it: caller is the registered provider, the interval has elapsed, the amount matches the stored subscription. It cannot transfer to other addresses or bypass the interval check, because the contract code doesn't allow those actions.
- Malicious delegate upgrade: if the
SubscriptionManageris a proxy whose implementation can be changed by an admin, the authorization effectively trusts that admin. Delegate only to immutable contracts or proxies with transparent, time-locked upgrades. - Provider compromise: if the provider's key leaks, an attacker can collect early payments up to the per-cycle amount. Subscribers should set a
spendingLimitper subscription and monitor for unauthorizedSubscriptionCollectedevents. - Delegation replacement: subscribing again with a different delegate wipes the subscription state. Use a modular delegate that supports multiple functions (subscription, batch payments, spending limits) under a single delegation, rather than one delegate per feature.
- Replayable signatures: all signatures use EIP-7702 nonces tied to the subscriber's EOA, so they can't replay across chains or across delegations.
- Audit the delegate contract before production use.
- Keep per-subscription amounts small relative to the subscriber's balance.
- Monitor
SubscriptionCreated/SubscriptionCollectedevents and surface them to the subscriber. - Offer the subscriber a clear "cancel" UI that calls
cancelSubscription(subscriptionId)on their own EOA.
Important considerations
- Persistent delegation: the EIP-7702 delegation persists until the subscriber explicitly changes or clears it. No re-delegation needed each billing cycle.
- Single delegation per EOA: if the subscriber later delegates to a different contract, the subscription delegate logic is replaced and collection fails. Use a modular delegate contract that supports multiple functions (subscriptions, batch payments, spending limits, session keys) under a single delegation.
- Schedule behavior: this example advances
nextChargeAtby one interval on each successful collection. If more than one billing period has elapsed, repeatedcollect()calls can catch up one period at a time. Extend the logic if your product requires a different policy. - Use audited delegates: only delegate to contracts that have been audited.
Next recommended
- Subscription billing concept — Understand the pull-based billing model.
- Account abstraction — See how batch payments, spending limits, and session keys combine under one delegation.
- EIP-7702 concept — Review the delegation model that makes this possible.

