ERC-8191: Onchain Recurring Payments

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 (Tier struct, 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 by SubscriptionManager)

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

  1. collectPayment() access model: Is a KeeperRegistry the right abstraction, or should this be left entirely to implementors with no on-chain gating in the interface?

  2. Cross-chain: originChainId / paymentChainId in SubscriptionTerms anticipates ERC-7683 intents. Is encoding this in the struct the right approach, or should cross-chain be a separate extension?

  3. PastDue as a computed status: Any objection to a status value not stored in state?

  4. 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