Abstract
This proposal defines an interface for managing subscriptions to onchain AI agent services. It enables users (humans or agents) to subscribe to tiered service plans offered by agents registered via ERC-8004, with payments in ERC-20 tokens. The protocol uses a cycle-based prepaid model where subscribers pay upfront for one or more billing cycles and tokens are transferred directly to the agent owner. The registry contract never holds funds. Subscription state lives entirely onchain, requiring zero offchain billing infrastructure from agent developers. An HTTP 402 access flow using EIP-712 signatures allows subscribers to prove access without revealing their identity in the initial request.
Motivation
The agentic economy is converging on two complementary primitives: identity (ERC-8004) and payments. Per-request payment protocols like x402 solve the transactional case well, but many agent interactions are better served by a subscription model. An AI coding assistant used daily, a monitoring agent that checks a portfolio every hour, or a team of agents collaborating on a task all benefit from predictable pricing and persistent access rather than per-call negotiation. Users can iterate on multi-turn conversations without tracking per-message costs, and agents can serve requests without settling a payment on every call.
No existing standard covers recurring, tiered access to agent services. This ERC fills the gap with a protocol that:
- Requires zero offchain infrastructure from agent developers. Subscribe, renew, and access verification logic is handled entirely onchain.
- Integrates with ERC-8004 for agent identity and discovery.
- Supports multiple plan tiers per agent, enabling differentiated service levels.
- Gives users full control with cycle-based prepaid billing, where subscriptions simply expire at the end of the paid period.
Agents can support both x402 and this protocol simultaneously, using per-request billing for occasional usage and subscriptions for regular access.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Overview
The protocol consists of a singleton SubscriptionRegistry contract deployed per chain. At deployment, the registry is configured with the address of the local ERC-8004 Identity Registry on the same chain. This address is immutable and used for all agent ownership verification: when an agent owner calls createPlan(), the registry calls identityRegistry.ownerOf(agentId) to confirm the caller owns the agent NFT.
Agents MUST be registered on the same chain as the SubscriptionRegistry they use. An agent registered on Base that wants subscriptions on Base uses the Base SubscriptionRegistry. If the same agent also wants subscriptions on Arbitrum, it registers on Arbitrum's ERC-8004 Identity Registry and uses Arbitrum's SubscriptionRegistry.
The registry interacts with:
- An ERC-8004 Identity Registry (same chain) for agent ownership verification.
- ERC-20 tokens (e.g., USDC) for payment.
+--------------------------------------------------------------+
| Offchain (Agent Card) |
| |
| ERC-8004 agentURI JSON: |
| +-- subscriptions: |
| +-- registryChain: "eip155:8453" |
| +-- registryAddress: "0x..." |
| +-- plans: |
| +-- { planId: 1, name: "Starter", ... } |
| +-- { planId: 2, name: "Pro", ... } |
| +-- { planId: 3, name: "Enterprise", ... } |
| |
+-------------------------------+-------------------------------+
|
v
+--------------------------------------------------------------+
| Onchain (SubscriptionRegistry, singleton) |
| |
| Configured with: identityRegistry address (immutable) |
| |
| Plans: createPlan / updatePlan / deactivatePlan |
| Subscriptions: subscribe / renew |
| Access: verifyAccess / isActive |
| Payments: direct transfer to agent owner |
| |
+-------------------------------+-------------------------------+
|
v
+--------------------------------------------------------------+
| Payment Layer (ERC-20) |
| |
| Token transfers via: |
| +-- EIP-2612 permit + transferFrom (e.g., USDC) |
| +-- Standard ERC-20 approve + transferFrom |
| |
+--------------------------------------------------------------+Pricing Model
Plans use a cycle-based price. Each plan defines a price for a fixed cycleDuration, and subscribers pay for one or more cycles upfront.
Total cost = price * cycles. Total duration = cycleDuration * cycles.
Example: A plan priced at 5 USDC/month (USDC has 6 decimals):
price = 5000000(5 USDC in base units)cycleDuration = 2592000(30 days in seconds)- 1-month subscription:
5000000 * 1 = 5000000(5 USDC) - 1 year (12-month) subscription:
5000000 * 12 = 60000000(60 USDC)
Direct Payment Model
Subscription payments are transferred directly to the agent owner (via identityRegistry.ownerOf(agentId)) at the time of subscribe or renew. The registry contract never holds funds.
Subscribe / Renew: The subscriber pays price * cycles and the tokens are transferred immediately to the agent owner. The subscription is active from the start time until the end time.
Subscriptions are prepaid and simply expire at endTime. Once paid, the service runs until the end of the billing period. This matches user expectations: paying for a month of service means getting a month of service.
Data Structures
Plan
struct Plan {
address asset; // payment token address (address(0) = plan does not exist)
uint256 price; // price per cycle in token base units
uint32 cycleDuration; // duration of one billing cycle in seconds
bool active; // whether new subscriptions are accepted
}- Plans are stored by a key derived from
(agentId, planId). Plan(agentId=5, planId=1)and(agentId=9, planId=1)are distinct. - A plan exists if
asset != address(0). No separateexistsflag is needed. priceusesuint256to support any token denomination without overflow concerns.cycleDurationdefines the length of one billing cycle in seconds (e.g., 2592000 for 30 days).
Subscription
struct Subscription {
uint256 agentId; // ERC-8004 agent reference
uint32 planId; // active plan tier
address subscriber; // the subscribing address
uint48 startTime; // subscription start timestamp
uint48 endTime; // subscription end timestamp
}- Subscriptions are stored by
subscriptionId = keccak256(subscriber, agentId, planId, nonce). Hash-based IDs prevent enumeration of subscriber counts while remaining deterministic and verifiable.
Events
The registry MUST emit the following events:
PlanCreated(uint256 indexed agentId, uint32 indexed planId, address asset, uint256 price, uint32 cycleDuration)when a plan is created.PlanUpdated(uint256 indexed agentId, uint32 indexed planId, uint256 newPrice, uint32 newCycleDuration)when a plan's pricing is updated.PlanDeactivated(uint256 indexed agentId, uint32 indexed planId)when a plan is deactivated.Subscribed(bytes32 indexed subscriptionId, uint256 indexed agentId, uint32 planId, address indexed subscriber, uint48 startTime, uint48 endTime, uint256 amount)when a new subscription is created.Renewed(bytes32 indexed subscriptionId, uint48 newEndTime)when a subscription is renewed.
These events are the primary mechanism for agents to maintain a local cache of subscription state (see Caching Recommendation).
Configuration
The registry MUST expose the identity registry address it was deployed with:
function identityRegistry() external view returns (address);This value is set at deployment and MUST be immutable. It is used for all agent ownership verification via identityRegistry.ownerOf(agentId).
Plan Management
Agent owners manage plans through three functions. The caller MUST be the owner of the ERC-8004 agent NFT for the given agentId.
To create a plan, the agent owner calls:
function createPlan(
uint256 agentId, uint32 planId, address asset, uint256 price, uint32 cycleDuration
) external;The planId MUST be unique within the scope of agentId. Both price and cycleDuration MUST be greater than zero. The asset MUST be a standard ERC-20 token; fee-on-transfer and rebasing tokens are NOT supported. A PlanCreated event MUST be emitted.
To update pricing on an existing plan:
function updatePlan(
uint256 agentId, uint32 planId, uint256 newPrice, uint32 newCycleDuration
) external;Only price and cycleDuration are mutable. To change the payment asset, the agent owner creates a new plan. Changes apply to new subscriptions and future renewals; active subscriptions are unaffected until they renew. A PlanUpdated event MUST be emitted.
To stop accepting new subscriptions on a plan:
function deactivatePlan(uint256 agentId, uint32 planId) external;Existing subscriptions MUST remain active until expiry. Active subscriptions MAY still be renewed (extended). Expired subscriptions MUST NOT be renewed on a deactivated plan, since an expired renewal is effectively a new subscription. A PlanDeactivated event MUST be emitted.
Plan details MUST be queryable:
function getPlan(uint256 agentId, uint32 planId)
external view returns (address asset, uint256 price, uint32 cycleDuration, bool active);Subscription Lifecycle
Subscribe
A user subscribes by calling:
function subscribe(uint256 agentId, uint32 planId, uint32 cycles)
external returns (bytes32 subscriptionId);The plan MUST be active and cycles MUST be >= 1. The caller MUST have approved the registry to transfer price * cycles tokens. Tokens are transferred directly to the agent owner (via identityRegistry.ownerOf(agentId)) using safeTransferFrom. If the subscriber already has an active subscription to the same agent and plan, the call MUST revert. A Subscribed event MUST be emitted.
A permit variant combines EIP-2612 approval and subscription in a single transaction:
function subscribeWithPermit(
uint256 agentId, uint32 planId, uint32 cycles,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external returns (bytes32 subscriptionId);User SubscriptionRegistry ERC-20 Token
| | |
|-- subscribe(agentId, planId, cyc) ->| |
| |-- safeTransferFrom(user, owner, amt)->|
| |<-- success ---------------------------|
| | [create subscription] |
|<-- subscriptionId ------------------| |The agent owner is not a participant in this call. The Registry instructs the ERC-20 contract to debit the subscriber and credit the agent owner; the owner's balance increases in the token's internal state, but no contract call is made to the owner's address.
Renew
An existing subscription is extended by calling:
function renew(bytes32 subscriptionId, uint32 cycles) external;The subscription MUST exist. cycles MUST be >= 1. Payment is charged at the plan's current price (indicated in the agent card), so renewals reflect any price changes made by the agent owner. If the subscription is still active, the renewal begins from the current endTime. If the subscription is expired, it resets. In other words, startTime is set to now, endTime is set to now + (cycleDuration * cycles). In the expired case, the plan MUST be active and the call MUST revert if another active subscription already exists for the same (subscriber, agentId, planId). Tokens are transferred directly to the agent owner. A Renewed event MUST be emitted.
A permit variant is also provided:
function renewWithPermit(
bytes32 subscriptionId, uint32 cycles,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external;User SubscriptionRegistry ERC-20 Token
| | |
|-- renew(subscriptionId, cycles) --->| |
| | [if active: extend from endTime] |
| | [if expired: reset startTime] |
| |-- safeTransferFrom(user, owner, amt)->|
| |<-- success ---------------------------|
|<-- success -------------------------| |Access Verification
The registry provides two view functions for checking subscription status.
To check whether a specific subscription is currently active:
function isActive(bytes32 subscriptionId) external view returns (bool);A subscription is active if it exists and block.timestamp is between startTime and endTime (inclusive).
To check whether a subscriber has access to a given agent (optionally restricted to a specific plan):
function verifyAccess(address subscriber, uint256 agentId, uint32 planId)
external view returns (bool);If planId is 0, the function returns true if the subscriber has any active subscription to the agent, regardless of tier. This is the primary function used by agents during the HTTP 402 verification flow.
Full subscription details are available via:
function getSubscription(bytes32 subscriptionId)
external view returns (
uint256 agentId, uint32 planId, address subscriber,
uint48 startTime, uint48 endTime
);Same-Chain Identity Binding
Each SubscriptionRegistry is deployed with an immutable reference to the ERC-8004 Identity Registry on the same chain. An agent can register on multiple chains, each with its own independent identity registry, subscription registry, and subscription pool.
Cross-chain identity verification would require oracles, bridges, or attestations, each adding a trust assumption and failure mode. Same-chain binding is simple and verifiable: the registry calls identityRegistry.ownerOf(agentId) directly.
When a client proves its subscription (in response to a 402), it includes the registryChain and registryAddress in the signed proof. The agent validates that the specified registry is one it recognizes, then verifies the subscription on that chain.
Hash-Based Subscription IDs
Subscription IDs are derived from keccak256(subscriber, agentId, planId, salt), not sequential integers.
Sequential IDs leak information. An observer can trivially determine subscriber count, growth rate, and ordering. Hash-based IDs provide the same uniqueness and determinism without the information leakage. The trade-off (inability to enumerate subscriptions by ID range) is acceptable because the contract provides query functions for access verification.
Immutable Subscription Terms
When a subscription is created, the price paid covers the full subscription period. The subscription struct stores startTime and endTime. The subscriber pays price * cycles upfront and receives access for that window.
- Current cycle: the subscriber paid for it. The price is locked for that period regardless of any plan changes.
- Renewals: always use the plan's current price at renewal time. If the agent updates pricing between cycles, the new price applies to the next renewal.
- No mid-period surprises: an agent cannot retroactively change the terms of an active subscription. The subscriber's access runs until
endTimeno matter what.
Agent Card Extension
Agents that support subscriptions SHOULD advertise their plans in the ERC-8004 agent registration file (resolved via agentURI). The following extension to the agent card schema is RECOMMENDED:
{
"type": "agent",
"name": "Example Agent",
"services": [],
"subscriptions": [
{
"chain": "eip155:8453",
"registryAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"agentId": 42,
"plans": [
{
"planId": 1,
"name": "Starter",
"description": "Basic access: 100 requests/day",
"price": "5000000",
"cycleDuration": 2592000,
"asset": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"features": ["100 requests/day", "Standard response time"]
},
{
"planId": 2,
"name": "Pro",
"description": "Full access: unlimited requests",
"price": "20000000",
"cycleDuration": 2592000,
"asset": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"features": ["Unlimited requests", "Priority response time"]
}
]
}
]
}This allows clients, agent directories, and frontend applications to discover available plans without querying the chain, while the onchain registry remains the source of truth for pricing and access verification.
HTTP 402 Access Flow
Subscription onboarding begins with discovery: the client fetches the agent's ERC-8004 agent card, which advertises available plans, registries, and pricing. Using this information, the client subscribes onchain before making any authenticated requests. The 402 flow described below handles access control, gating requests from clients that have already subscribed.
Similar to x402, this protocol uses two custom HTTP headers: SUBSCRIPTION-REQUIRED (agent to client) and SUBSCRIPTION-SIGNATURE (client to agent).
Flow Overview
Client Agent Service SubscriptionRegistry
| | |
|-- GET /api/resource ----------------->| |
| | [no subscription proof] |
|<-- 402 + SUBSCRIPTION-REQUIRED -------| |
| | |
| [client signs EIP-712 proof] | |
| | |
|-- GET /api/resource ----------------->| |
| + SUBSCRIPTION-SIGNATURE header | |
| |-- ecrecover(signature) ---+ |
| | [recover subscriber] | |
| |<--------------------------+ |
| |-- verifyAccess(addr,agent,plan)->|
| |<-- true -------------------------|
|<-- 200 OK (resource data) ------------| |Step 1: Initial Request
The client sends a standard HTTP request to the agent's service endpoint. No wallet address, subscription ID, or authentication is included.
GET /api/chat HTTP/1.1
Host: agent.example.com
Content-Type: application/jsonStep 2: Agent Responds with 402 and SUBSCRIPTION-REQUIRED
If the request lacks a SUBSCRIPTION-SIGNATURE header, the agent responds with HTTP 402 and a SUBSCRIPTION-REQUIRED header containing a base64-encoded JSON object:
HTTP/1.1 402 Payment Required
SUBSCRIPTION-REQUIRED: eyJ0eXBlIjoic3Vic2Ny...Decoded SUBSCRIPTION-REQUIRED payload:
{
"type": "subscription",
"registries": [
{
"chain": "eip155:8453",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"agentId": 42
}
],
"challenge": "0x1a2b3c4d..."
}| Field | Required | Description |
|---|---|---|
type | MUST | Always "subscription". |
registries | MUST | Array of { chain, address, agentId } objects identifying the SubscriptionRegistry contracts and agent identities the agent accepts. Each chain is a CAIP-2 chain identifier, address is the registry contract address on that chain, and agentId is the ERC-8004 agent identifier on that chain's identity registry. The agentId MAY differ across chains. |
challenge | MAY | Opaque bytes provided by the agent that the client MUST include in the signed data if present. Agents can use this for replay protection (nonce), freshness (timestamp), session binding, or any other agent-specific validation logic. If absent, the client signs with an empty challenge (0x). |
Step 3: Client Signs EIP-712 Proof
The client constructs and signs an EIP-712 typed data message proving it controls a subscriber address, without revealing that address in advance.
EIP-712 Domain:
The domain's chainId and verifyingContract correspond to the registry the client subscribed on. This binds the signature to a specific chain and registry, preventing cross-chain replay.
{
"name": "ERC-8162: Agent Subscription Protocol",
"version": "1",
"chainId": 8453,
"verifyingContract": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18"
}EIP-712 Type:
struct SubscriptionProof {
uint256 agentId; // the agent being accessed
bytes challenge; // opaque challenge from the agent (empty if none)
}agentIdbinds the proof to a specific agent, preventing a signature meant for agent A from being replayed on agent B.challengeis the opaque value from theSUBSCRIPTION-REQUIREDresponse. If the agent did not include achallenge, the client signs with empty bytes (0x).- The EIP-712 domain's
chainIdandverifyingContractimplicitly bind the proof to the specific registry the client subscribed on.
The client signs this with their wallet private key. Neither the subscriber address nor the subscription ID are included; the agent recovers the address via ecrecover and looks up the subscription onchain.
The challenge mechanism gives agents full control over their security model:
| Use case | Challenge value | Agent validation |
|---|---|---|
| No replay protection needed | Omitted (client signs 0x) | None |
| Nonce-based replay protection | Random nonce | Agent tracks used nonces |
| Time-bound access | Timestamp or expiry | Agent checks freshness |
| Session binding | Session ID | Agent validates session |
| Request-specific binding | keccak256(method + path + body) | Agent recomputes and compares |
Step 4: Client Retries with SUBSCRIPTION-SIGNATURE
The client re-sends the original request with a SUBSCRIPTION-SIGNATURE header containing a base64-encoded JSON object:
GET /api/chat HTTP/1.1
Host: agent.example.com
Content-Type: application/json
SUBSCRIPTION-SIGNATURE: eyJzdWJzY3JpcHRpb25JZ...Decoded SUBSCRIPTION-SIGNATURE payload:
{
"authorization": {
"agentId": 42,
"registryChain": "eip155:8453",
"registryAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"challenge": "0x1a2b3c4d..."
},
"signature": "0x2d6a8f..."
}| Field | Required | Description |
|---|---|---|
authorization | MUST | The subscription authorization object, containing the data that was signed. |
authorization.agentId | MUST | The ERC-8004 agent identifier on the chosen chain. MUST match the agentId from the corresponding registries entry in the SUBSCRIPTION-REQUIRED response. |
authorization.registryChain | MUST | CAIP-2 chain identifier where the client's subscription exists. MUST match the chain from the corresponding registries entry. |
authorization.registryAddress | MUST | Address of the SubscriptionRegistry on that chain. MUST match the address from the corresponding registries entry. |
authorization.challenge | MUST | The challenge from SUBSCRIPTION-REQUIRED, or "0x" if none was provided. |
signature | MUST | EIP-712 signature over the SubscriptionProof struct (derived from the authorization fields). |
The subscriber address is NOT included; it is recovered from the signature via ecrecover. The subscription ID is NOT included; the agent looks it up via verifyAccess(recoveredAddress, agentId, planId). The client specifies which chain and registry to verify against, allowing agents registered on multiple chains to route verification correctly.
Step 5: Agent Verifies and Serves
The agent performs the following verification:
- Validate registry: Verify that the
registryChain,registryAddress, andagentIdfrom theSUBSCRIPTION-SIGNATUREmatch one of the agent's known registry entries. Reject unknown combinations immediately. - Recover signer: Use
ecrecoveron the EIP-712 signature (using the domain corresponding to the specified chain and registry) to obtain the subscriber address. - Validate challenge (if one was issued): The agent validates the challenge according to its own logic (e.g., check nonce has not been used, verify timestamp is fresh, confirm session ID). If no challenge was issued, this step is skipped.
- Verify access: Call
verifyAccess(recoveredAddress, agentId, planId)on the SubscriptionRegistry at the specified chain and address, whereplanIdis0to accept any plan or a specific plan ID for tier-based access control. The agent MAY also check a local cache synced from onchain events. - Serve resource: If all checks pass, return the requested resource with HTTP 200.
If verification fails, the agent SHOULD respond with HTTP 403 (Forbidden) and an appropriate error message indicating whether the subscription is expired or the signature is invalid.
Caching Recommendation
To avoid an RPC call per request, agents SHOULD maintain a local cache of subscription state by indexing Subscribed and Renewed events. The agent re-syncs with the chain on a cadence appropriate for its use case (e.g., every block, every minute). Agents with low request volume MAY use onchain verification directly.
x402 Interoperability
This ERC and x402 are independent protocols that an agent can support simultaneously. The 402 access flow described here is solely for subscription-based access. An agent that also supports x402 pay-per-request handles x402 through its own 402 response and header exchange, separate from the SUBSCRIPTION-REQUIRED / SUBSCRIPTION-SIGNATURE flow. The two protocols do not share headers or payload formats. The agent decides which to use based on endpoint configuration or the headers present on incoming requests.
| Aspect | x402 | ERC-8162 |
|---|---|---|
| 402 means | "Pay me" | "Identify yourself" |
| Client authorizes | A token transfer | Subscription identity lookup |
| Authorization type | Payment authorization (EIP-3009 / Permit2) | Subscription authorization (EIP-712) |
| Onchain effect per request | Yes, token transfer settles | No, read-only verification |
| Cost per request | Token amount specified by agent | Zero (pre-paid via subscription) |
| Payment timing | At request time | Before request time (subscription) |
| Third party needed | Facilitator (verify + settle) | None (agent verifies directly) |
| Replay protection | Built-in (onchain nonce consumed) | Agent's choice via challenge |
| Best for | Occasional or unpredictable usage | Regular, high-frequency usage |
Privacy Considerations
Subscription state is stored onchain, but the HTTP 402 access flow ensures that subscriber identity is never exposed to the network layer unless a valid subscription exists.
| Data | Visibility |
|---|---|
| Subscriber address | Onchain, public (in subscription record) |
| Agent subscribed to | Onchain, public |
| Plan tier | Onchain, public |
| Amount paid | Onchain, public |
| Duration | Onchain, public |
| Subscriber identity at request time | Recoverable from the SUBSCRIPTION-SIGNATURE header by any observer via ecrecover |
| Service usage / API calls | Offchain, private |
| Request/response content | Offchain, private |
The initial request (Step 1) carries no identifying information. The subscriber address is only exposed in the retry (Step 4), after the agent has responded with a 402 challenge. However, any party that can observe the SUBSCRIPTION-SIGNATURE header can recover the subscriber address from the EIP-712 signature using the public domain parameters. The header does not provide confidentiality.
Onchain, subscription data is public. This is comparable to what is visible in any ERC-20 transfer and is acceptable for most use cases. As always, use of TLS is RECOMMENDED to protect headers from network observers.
Rationale
Why direct payment?
Tokens are transferred directly to the agent owner on every subscribe and renew call. The registry contract never holds funds.
- Simplicity: The contract only needs to track subscription state (start time, end time) and facilitate token transfers. The state footprint is minimal.
- Immediate revenue: Agent owners receive payment at the moment of subscription or renewal.
- Minimal trust surface: The registry facilitates a direct transfer between subscriber and agent owner. It never custodies funds, reducing the contract's attack surface.
Subscriptions are prepaid: the subscriber pays upfront and receives access for the full period. Without auto-renewal, a cancel would only prevent the subscriber from voluntarily extending their own subscription. This provides no benefit and would be a strict downgrade. Subscriptions simply expire at endTime, matching user expectations from prepaid services.
Why cycle-based pricing?
Real-world subscriptions are cycle-based: Netflix charges per month, Spotify charges per month, GitHub charges per month or per year. Users understand and expect this model.
- Intuitive: "5 USDC per month" is immediately clear. Users see the price and the period, with nothing to calculate.
- Simple math: Total cost =
price * cycles. - Predictable: Both parties know the exact amount charged and the exact duration of service.
- Natural alignment: Billing cycles naturally align with service delivery periods, making plan management straightforward.
Why onchain plans?
Plans could be defined purely offchain (as ERC-8004 does with agent metadata), with only a hash stored onchain. However, onchain plans enable:
- Trustless price verification: users know exactly what they are paying.
- Smart contract composability: other contracts can read plan details.
- Transparent pricing: anyone can verify the plan terms.
Why hash-based subscription IDs?
Sequential IDs leak information: subscriber count, growth rate, and ordering. Hash-based IDs (keccak256(subscriber, agentId, planId, nonce)) prevent casual enumeration while remaining deterministic and verifiable.
Backwards Compatibility
This ERC introduces no backwards compatibility issues. It is a new contract that references but does not modify existing standards (ERC-8004, ERC-20, EIP-2612).
Test Cases
Plan Creation
| Test | Input | Expected |
|---|---|---|
| Create valid plan | agentId=42, planId=1, asset=USDC, price=5000000, cycleDuration=2592000 | PlanCreated event emitted; getPlan returns correct values |
| Non-owner creates plan | Caller is not ownerOf(agentId) | Reverts |
| Duplicate planId | Same (agentId, planId) pair already exists | Reverts |
| Zero price | price=0 | Reverts |
| Zero cycle duration | cycleDuration=0 | Reverts |
Subscribe
| Test | Input | Expected |
|---|---|---|
| Subscribe 1 cycle | agentId=42, planId=1, cycles=1 | Subscribed event; endTime = startTime + cycleDuration; 5 USDC transferred to agent owner |
| Subscribe 3 cycles | agentId=42, planId=1, cycles=3 | endTime = startTime + 3 * cycleDuration; 15 USDC transferred |
| Subscribe to inactive plan | Plan has active=false | Reverts |
| Duplicate active subscription | Already has active sub for same (agentId, planId) | Reverts |
| Insufficient token approval | Approval < price * cycles | Reverts |
Renew
| Test | Input | Expected |
|---|---|---|
| Renew active subscription | 1 cycle renewal while active | endTime extended by cycleDuration; tokens transferred at current plan price |
| Renew expired subscription | Plan is active, subscription expired | startTime resets to now; endTime = now + cycleDuration |
| Renew expired, plan inactive | Plan active=false, subscription expired | Reverts |
Access Verification
| Test | Input | Expected |
|---|---|---|
| Active subscription | block.timestamp between startTime and endTime | isActive returns true; verifyAccess returns true |
| Expired subscription | block.timestamp > endTime | isActive returns false; verifyAccess returns false |
| Any-plan check | planId=0, subscriber has active sub on planId=2 | verifyAccess returns true |
Reference Implementation
See ISubscriptionRegistry.sol and SubscriptionRegistry.sol in the reference repository.
Security Considerations
Reentrancy
The registry MUST use reentrancy guards on all state-mutating functions that interact with external contracts (token transfers). The subscribe, renew, and any permit-based variants call safeTransferFrom on an external ERC-20 token, which could trigger a callback if the token is malicious.
Front-running
Subscription creation is not sensitive to front-running because subscriptionId is derived from the subscriber's address. An attacker cannot subscribe on behalf of another user without their token approval.
Price Changes and Renewals
Plan prices are NOT snapshotted into the subscription. Renewals always use the plan's current price. Agent owners can update plan pricing at any time, and the new price takes effect on the next renewal. Subscribers should be aware that renewing an expired subscription charges the current plan price. Active renewals (extending from endTime) also use the current price. This is the standard behavior for real-world subscription services.
Token Compatibility
The registry SHOULD validate that the payment token implements ERC-20. Rebasing tokens and fee-on-transfer tokens are NOT supported and MAY cause accounting errors.
Integer Overflow
price uses uint256 and cycles uses uint32, so price * cycles can overflow if price is extremely large. Implementations MUST use safe arithmetic or Solidity 0.8+ built-in overflow checks.
Signature Replay (HTTP 402 Flow)
The minimal SubscriptionProof (just agentId + empty challenge) is intentionally replayable. It proves wallet ownership, not request uniqueness. Agents that require replay protection MUST issue a challenge (e.g., a random nonce) in the SUBSCRIPTION-REQUIRED response and validate it on receipt. The EIP-712 domain separator binds all signatures to a specific chain and registry address, preventing cross-chain replay.
Challenge-less Mode Trade-offs
When no challenge is issued, an intercepted SUBSCRIPTION-SIGNATURE can be reused by an attacker to access the same agent until the subscription expires. This is acceptable for low-sensitivity endpoints (similar to a bearer token). Agents serving sensitive data SHOULD always issue a challenge.
Copyright
Copyright and related rights waived via CC0.
