Abstract
This proposal defines a standard Solidity interface — ISubscription — for onchain recurring payments on Ethereum and EVM-compatible chains. It introduces a pull-payment model with full subscription lifecycle management (create, pause, resume, cancel), keeper-gated payment collection, native ETH and ERC-20 support, and an optional callback interface for merchants. A reference implementation with >95% test coverage is available.
Motivation
Recurring payments are a foundational primitive of the digital economy. Despite this, no finalized ERC standard exists that enables trustless, automated, onchain subscriptions.
Two prior attempts have failed:
-
ERC-1337 (2018): Required offchain meta-transaction relayers, introducing centralization and trust assumptions. No test suite was ever written. Stagnant.
-
ERC-5643: Scoped exclusively to NFT-based memberships. No pull payment mechanism, no state management (pause, grace period, retry). A partial solution to a larger problem.
The result is that every protocol building recurring payments today reinvents its own logic — non-composable, non-auditable by a shared standard, and incompatible with any shared tooling layer.
This proposal aims at solving that permanently.
Specification
Core types
struct SubscriptionTerms {
address token; // ERC-20 address, or address(0) for native ETH
uint256 amount; // payment amount per interval
uint48 interval; // seconds between payments
uint48 trialPeriod; // free trial in seconds before first payment; 0 = none
uint256 maxPayments; // max number of payments; 0 = unlimited
uint256 originChainId;
uint256 paymentChainId;
}
enum Status { Active, Paused, Cancelled, Expired, PastDue }
Interface
interface ISubscription {
function subscribe(address merchant, SubscriptionTerms calldata terms)
external returns (bytes32 subId);
function collectPayment(bytes32 subId) external returns (bool);
function cancelSubscription(bytes32 subId) external;
function pauseSubscription(bytes32 subId) external;
function resumeSubscription(bytes32 subId) external;
function getStatus(bytes32 subId) external view returns (Status);
function nextPaymentDue(bytes32 subId) external view returns (uint256);
function getTerms(bytes32 subId) external view returns (SubscriptionTerms memory);
function getSubscriber(bytes32 subId) external view returns (address);
function getMerchant(bytes32 subId) external view returns (address);
function getPaymentCount(bytes32 subId) external view returns (uint256);
}
Key design decisions
collectPayment() is keeper-gated. Direct subscriber pull (push model) does not enable automation. Permissionless collection introduces griefing. A two-tier KeeperRegistry (global keepers set by owner + per-merchant keepers self-managed) balances decentralization with liveness.
PastDue is computed dynamically inside getStatus(). This avoids requiring a keeper transaction purely to update status — any view call reflects the correct state.
bytes32 subscription IDs are derived as keccak256(subscriber, merchant, block.timestamp, block.chainid, nonce) — deterministic, cross-chain portable, and collision-resistant without a sequential counter.
address(0) represents native ETH — consistent with widespread convention (e.g. Uniswap, 1inch). An escrow pattern separates subscriber and merchant ETH balances to eliminate reentrancy vectors.
uint48 for interval and trialPeriod — fits in a single storage slot alongside other struct fields, supports up to ~8.9M years (effectively unbounded for any real use case).
Optional extensions
The following extension interfaces are designed to be composable and opt-in via ERC-165, following the ERC-721/ERC-721Enumerable precedent:
-
ISubscriptionTrial— runtime trial queries (isInTrial(),trialEndsAt(),extendTrial()) -
ISubscriptionTiered— tiered pricing (Tierstruct,createTier(),upgradeTier(),downgradeTier()) -
ISubscriptionDiscovery— minimal on-chain merchant registry with off-chain metadata URI -
ISubscriptionHook— dunning callbacks for failed payments (called by off-chain automation, not bySubscriptionManager)
Optional merchant callback
interface ISubscriptionReceiver {
function onPaymentCollected(bytes32 subId, uint256 amount, address token)
external returns (bytes4);
function onSubscriptionCancelled(bytes32 subId)
external returns (bytes4);
}
Merchants opt in by implementing this interface and returning the function selector. Callbacks are wrapped in try/catch inside the reference implementation — a merchant’s reverting callback can never block payment collection.
Backwards Compatibility
This ERC introduces a new interface and does not modify or conflict with any existing standard. It is compatible with ERC-20, ERC-165, and ERC-7683 (cross-chain intents). ERC-5643 implementations are not required to migrate; this standard can coexist.
Reference Implementation
A complete Foundry reference implementation is available at: github.com/cadence-protocol/cadence-protocol
-
src/interfaces/ISubscription.sol— full interface -
src/SubscriptionManager.sol— reference implementation (ERC-20 + ETH, escrow, trials, keeper-gated, receiver callbacks) -
src/KeeperRegistry.sol— two-tier keeper authorization with blacklist -
test/— 90+ unit, fuzz, and invariant tests (>95% branch coverage) -
docs/rationale.md— extended design rationale for all decisions above
Questions for the community
-
collectPayment()access model: Is aKeeperRegistrythe right abstraction, or should this be left entirely to implementors with no on-chain gating in the interface? -
Cross-chain:
originChainId/paymentChainIdinSubscriptionTermsanticipates ERC-7683 intents. Is encoding this in the struct the right approach, or should cross-chain be a separate extension? -
PastDueas a computed status: Any objection to a status value not stored in state? -
Extension interface granularity: Are four extension interfaces (Trial, Tiered, Discovery, Hook) the right split, or should some be merged?
Looking forward to hearing feedback from anyone interested.
— Cadence Protocol Team
[UPDATE] The formal ERC draft has been submitted: https://github.com/ethereum/ERCs/pull/1595