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

1 Like

Hey! I can see synergy with the ERC 8183, ERC-8183: Agentic Commerce

should we collaborate, we can start developing hook that combine ERC 8191 with ERC 8183

1 Like

Hey, thanks for flagging this — we took a look at ERC-8183 and the synergy is concrete.

ERC-8191 handles pull-based recurring payments: a SubscriptionManager tracks lifecycle (active/expired/cancelled) and a KeeperRegistry lets any permissioned keeper collect payments at each interval. No oracles, no custody.

The natural integration point with ERC-8183 is your hook system. A hook implementing IACPHook could, on job creation (beforeAction on fund), automatically open an ERC-8191 subscription — so an agentic service relationship becomes a recurring billing relationship by default, without the client having to manage renewals.

The inverse is also interesting: an ERC-8191 subscription expiry could trigger an ERC-8183 job rejection via hook, keeping both lifecycle states in sync.

We’d be happy to start sketching a hook interface — either here or in a shared doc. Are you the authors of ERC-8183, or were you proposing to loop them in?

Would this ERC be intended to support subscriptions where the terms of the deal can change mid-subscription while keeping the same ID? If so, how?

We’ve previously implemented a pseudo-subscription pattern which required manual renewal at the end of each term (RedisProvisionObligation, de-redis-clients) based on the escrow architecture described in ERC-8183: Agentic Commerce - #37 by mlegls. I like the idea of this ERC in abstracting ongoing payments with a pull-based mechanism without requiring re-attestation after the first payment.

Two suggestions:

  • I’d recommend adding a generic bytes data and bytes32 schema field to SubscriptionTerms for subscription-specific data. The downside to a mapping(bytes32 => CustomTerms) per implementation is that there’s no shared interface for accessing it.
  • token and amount may be worth generalizing to any on-chain action rather than just ERC-20 and ETH transfers, to support ERC-1155, bundles of tokens, or other recurring non-token actions. e.g. bytes32 paymentSchema and bytes paymentData instead of address token and uint256 amount. The external function interfaces wouldn’t have to change.

There are very natural integrations with our general purpose conditional escrow system Alkahest, where

Happy to work on these together if you’d like.

Thanks for the detailed feedback - and for linking the RedisProvisionObligation pattern, that’s a useful prior art reference.

On mid-subscription term changes: By design, ERC-8191 does not support mutating terms on an existing subscription ID. The subscriber’s approval is bound to the terms at creation; a provider modifying them mid-cycle without re-consent would break the trust model. The intended pattern is cancel + new subscription. An optional amendment flow (provider proposes, subscriber must accept before the next payment cycle) could live at the implementation layer without touching the base standard.

On bytes data + bytes32 schema: After looking at Alkahest, I think this suggestion is right. The bytes32 schema pattern maps naturally to EAS schema UIDs, which would allow SubscriptionTerms to be attested and referenced in arbiter contracts directly. I’m inclined to add this - it doesn’t change the external function signatures and makes the standard meaningfully more composable. One open question: should the schema affect keeper behavior in collectPayment, or is it purely metadata for the attestation layer?

On paymentSchema/paymentData: The Alkahest context makes this more compelling than I initially thought, aligning with the generic escrowable action model. My hesitation is that it significantly increases implementation and audit surface for what is meant to be a minimal standard. I’d lean toward keeping address token + uint256 amount in the base spec and defining an extension profile for generic payloads, but I’m open to being convinced if you have a concrete case where ERC-1155 or other actions are load-bearing.

On the Alkahest integration: The two patterns you mention are both interesting, and they go in different directions. An arbiter that validates ERC-8191 subscription state before releasing escrow is the cleaner one, where a subscription becomes a verifiable precondition for a deal. Subscription creation as an escrowable obligation is more complex but opens up conditional onboarding flows. Happy to work through a concrete hook or arbiter spec for either of these.

mid-subscription term changes

I was mostly thinking of subscriber-initiated term changes, but implementation layer flows could work here too. Stripe’s subscription model has a pending_update field, but specifying such flows in the ERC spec would add complexity.

should the schema affect keeper behavior

This is up to implementations. SubscriptionTerms is returned from getTerms, and therefore can be used in on-chain logic, but how it’s used isn’t dictated by the spec.

extension profile for generic payloads

Do you have in mind a way of defining such an extension profile?

A counterpoint re “implementation and audit surface” is that these would be audited per-implementation anyway rather than as part of the spec. It’s one line of Solidity to decode bytes to (address token, uint256 amount), and an ERC-20 schema can be specified in the spec, so implementations are not that much more complex.

ERC-20 is obviously the most popular token standard, but I think that at the level of ERC specs, generality is more important than maximally convenient support for what’s most popular. Otherwise there is less motivation for many diverse implementations to follow the ERC, and risk of many competing and incompatible ERCs in a similar niche, which dilutes the interoperability benefits.

One example I can especially see becoming popular is pricing in portfolios of several tokens. It’s much easier for AI agents to negotiate complex deals involving multiple currencies than it was for humans, and there are many financial motivations to do so when individual tokens can represent fine-grained things like equity in a particular project or a position in a prediction market.

Alkahest integration

Feel free to make a PR to GitHub - arkhai-io/alkahest · GitHub adding these to obligations/example and arbiters/example based on your reference implementation.

Thanks for the continued pushback, all three points are clearer now.


On subscriber-initiated term changes: Agreed that it’s a legitimate use case (upgrade/downgrade mid-cycle is the most common real-world scenario). Keeping it at the implementation layer makes sense for the base spec: the Stripe pending_update analogy is apt, and mandating that flow in the ERC would add meaningful complexity without being universally necessary. A cancel + new subscription preserves the trust model cleanly even if it loses ID continuity.


On schema and keeper behavior: That’s the right framing — getTerms() exposes it, how implementations use it in on-chain logic is up to them. Closes that question cleanly.


On paymentSchema/paymentData: You’ve convinced me the idea is right, but after looking at the implementation I think the right home for it is a composable extension rather than the core spec. The reason: token is branched throughout the payment execution layer; it’s not just a struct field, it drives _collectETHPayment vs _collectERC20Payment, the subscribe() pre-flight checks, the ISubscriptionReceiver callback signature. Changing it to bytes paymentData in the core would require redesigning the entire payment execution path, which works against the “minimal base” goal.

That said, native ETH is already a first-class case in the current implementation via address(0), handled with a full deposit/escrow model. So the base spec already covers ERC-20 and ETH with typed fields. For everything beyond that (ERC-1155, bundles, arbitrary on-chain actions), I’d propose ISubscriptionGenericPayment as an optional extension: implementations declare support via ERC-165 and layer it on top without touching the core interface.

Draft below. Would love your feedback, and happy to accept a PR if you want to take a first pass at it:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title ISubscriptionGenericPayment
/// @notice Optional ERC-8191 extension for payment types beyond ERC-20 and native ETH.
///         Implementations declare support via ERC-165. The base ISubscription interface
///         is unchanged — this extension is purely additive.
interface ISubscriptionGenericPayment {

    /// @notice Create a subscription with a generic payment payload.
    /// @param merchant       Merchant address
    /// @param paymentSchema  Schema identifier (see well-known constants below)
    /// @param paymentData    ABI-encoded payment data, interpreted per paymentSchema
    /// @param interval       Seconds between payments
    /// @param trialPeriod    Free trial in seconds (0 = no trial)
    /// @param maxPayments    Max payments to collect (0 = unlimited)
    /// @return subId         Unique subscription identifier
    function subscribeGeneric(
        address merchant,
        bytes32 paymentSchema,
        bytes calldata paymentData,
        uint48 interval,
        uint48 trialPeriod,
        uint256 maxPayments
    ) external payable returns (bytes32 subId);

    /// @notice Return the generic payment terms for an existing subscription.
    /// @param subId          Subscription identifier
    /// @return paymentSchema The schema identifier
    /// @return paymentData   The encoded payment data
    function getGenericPaymentTerms(bytes32 subId)
        external view
        returns (bytes32 paymentSchema, bytes memory paymentData);
}

// ── Well-known schema identifiers ────────────────────────────────────────

/// abi.encode(address token, uint256 amount)
bytes32 constant SCHEMA_ERC20   = keccak256("ERC-20");

/// abi.encode(address token, uint256 id, uint256 amount)
bytes32 constant SCHEMA_ERC1155 = keccak256("ERC-1155");

/// abi.encode(address token, uint256[] ids, uint256[] amounts)
bytes32 constant SCHEMA_ERC1155_BATCH = keccak256("ERC-1155-BATCH");

/// abi.encode(Transfer[]) where Transfer = (address token, uint256 amount)
bytes32 constant SCHEMA_BUNDLE  = keccak256("BUNDLE");

The ERC-20 schema maps exactly to the current address token + uint256 amount fields, so existing implementations can expose ISubscriptionGenericPayment with zero behavioral change, just wrapping their existing logic. The extension stays purely additive.

Repo is at GitHub - cadence-protocol/cadence-protocol: Open ERC standard for onchain recurring payments. Native pull payments, full lifecycle state machine, cross-chain via ERC-7683. · GitHub , src/interfaces/ would be the right place for a PR.


Finally, regarding Alkahest: happy to open a PR.

I’ve looked at the arbiter interface and obligations structure. The two pieces are an ActiveSubscriptionArbiter that calls getStatus() on a given subId, and an ERC8191SubscriptionObligation that attests subscription creation. Will open a draft PR once I’ve reviewed BaseObligation to get the attestation schema right.