SubscriptionModule
Inherits: ReentrancyGuard, IExecutableModule, IRegistrableModule, IAuthorizationModule, IInitializableModule, IMigratableModule
Title: SubscriptionModule
Author: amser
Merchant-owned contract that stores subscription plans and user subscriptions.
This contract contains NO Permit2 logic and NO payment logic. All delegated spending and signature validation is handled by the global PaymentProcessor contract, ensuring clean modular separation. Users interact with THIS contract when subscribing. However, the module forwards the request to PaymentProcessor.registerWithPermit(...) which:
- gets registration context from module (token, minAmount)
- validates Permit2 binding (generic)
- consumes the Permit2 signature
- registers execution via registerExecution(...) Key Design Principles:
- Plans are immutable after creation (only the
activeflag may be toggled). - SubscriptionModule is a pure state machine: only stores data.
- Only PaymentProcessor can create subscriptions or update billing state.
- Users may pause/resume or modify their subscription expiry.
- All state changes emit events for off-chain indexing (indexer, dashboards).
- Subscriptions use
lastPaidAtas the funding anchor (0 until first execution). remainingExecutionsserves as the execution guardrail.- Access is derived off-chain:
lastPaidAt + interval + gracePeriod >= now. Security Invariants: - merchant and paymentProcessor are immutable after initialization.
- Only merchant can create/toggle plans.
- Only subscriber can pause/resume or update their subscription expiry.
- Only PaymentProcessor can create subscriptions and mutate payment state. Architectural Guarantees:
- No token custody, no Permit2 dependency, no transfer logic.
- This contract is safe to clone (minimal proxy support via factory).
- Matches RAI architecture where SubscriptionModule = “on-chain truth.”
State Variables
MIN_INTERVAL
Minimum plan interval (1 hour) to prevent spam billing.
uint256 public constant MIN_INTERVAL = 3600
_initialized
bool private _initialized
modulePaused
When true, all keeper execution is paused for this module.
Does not affect subscriber-level pause state. Subscriptions resume from their existing nextChargeAt schedule on unpause.
bool public modulePaused
merchant
Merchant who owns this module instance
address public merchant
paymentProcessor
The global PaymentProcessor contract
address public paymentProcessor
_nextPlanId
uint256 private _nextPlanId
_nextSubId
uint256 private _nextSubId
_plans
mapping(uint256 => PlanPacked) internal _plans
_subscriptions
mapping(uint256 => SubscriptionPacked) internal _subscriptions
subscriptionOf
Mapping from subscriber address + planId to subscription ID.
Used for authorization checks via IAuthorizationModule. IMPORTANT: This mapping is permanent and never cleared. Once a subscription is created, the subscriber cannot create a fresh one for the same plan; they must reuse the existing subscription via updateRemainingExecutions() and updateAllowanceExpiry().
mapping(address => mapping(uint256 => uint256)) public subscriptionOf
blockedSubscribers
Module-level subscriber block list.
Blocked subscribers have immediate access revoked and cannot resubscribe to any plan on this module. Managed by merchant only. Permanent until explicitly lifted via unblockSubscriber().
mapping(address => bool) public blockedSubscribers
MAX_BATCH_QUERY
Maximum number of plan IDs accepted by batch view helpers (OOG guard).
uint256 internal constant MAX_BATCH_QUERY = 256
Functions
constructor
Note: oz-upgrades-unsafe-allow: constructor
constructor() ;
initialize
Initializes the module for a merchant.
Called by RAIModuleFactory immediately after deployment. Cannot be called twice (proxy-safe initialization).
function initialize(address _merchant, address _paymentProcessor) external;
Parameters
| Name | Type | Description |
|---|---|---|
_merchant | address | The merchant who will own this module |
_paymentProcessor | address | The global PaymentProcessor |
setPaymentProcessor
Updates the PaymentProcessor address (migration path).
Only the current PaymentProcessor can call this, via migrateModule(). This enables protocol-level upgrades without breaking existing modules.
function setPaymentProcessor(address _newProcessor) external onlyPaymentProcessor;
Parameters
| Name | Type | Description |
|---|---|---|
_newProcessor | address | Address of the new PaymentProcessor |
onlyMerchant
modifier onlyMerchant() ;
onlyPaymentProcessor
modifier onlyPaymentProcessor() ;
onlySubscriber
modifier onlySubscriber(uint256 subId) ;
getSubscriptionPacked
Low-gas read function for PaymentProcessor.
Returns packed subscription data without ABI decoding overhead.
function getSubscriptionPacked(uint256 subId)
external
view
returns (SubscriptionPacked memory);
getPlanPacked
Low-gas read function for PaymentProcessor.
Returns packed plan data without ABI decoding overhead.
function getPlanPacked(uint256 planId) external view returns (PlanPacked memory);
createPlan
Merchant creates a new plan.
function createPlan(
uint256 price,
uint256 interval,
uint256 gracePeriod,
address token,
bytes32 metadataHash
)
external
onlyMerchant
returns (uint256 planId);
Parameters
| Name | Type | Description |
|---|---|---|
price | uint256 | |
interval | uint256 | |
gracePeriod | uint256 | Must not exceed interval. Grace periods longer than the billing interval allow multi-window free access. |
token | address | |
metadataHash | bytes32 |
togglePlanActive
Merchant toggles plan active/inactive.
function togglePlanActive(uint256 planId) external onlyMerchant;
pauseModule
Pauses all keeper execution for this module.
Only affects quoteExecution() — individual subscription state (lastPaidAt, nextChargeAt, remainingExecutions) is untouched. Subscriber-level pause flags are also unaffected. Use during platform incidents, migrations, or emergencies where halting all billing immediately is required.
function pauseModule() external onlyMerchant;
unpauseModule
Resumes keeper execution for this module.
Subscriptions resume from their existing nextChargeAt schedule. Any subscriptions whose payment window was missed during the pause will require recoverSubscription() to be called before the keeper can charge them — this is existing behaviour and is unchanged.
function unpauseModule() external onlyMerchant;
cancelAndBlockSubscriber
Cancels a subscription and permanently blocks the subscriber from resubscribing to any plan on this module.
Intended for T&C violations. Effects are immediate:
- remainingExecutions set to 0 (stops keeper billing)
- isActive() returns false immediately (access revoked)
- subscriber cannot call subscribeWithPermit() on this module Use unblockSubscriber() to lift the block if dispute is resolved. After unblocking, the subscriber must top up via updateRemainingExecutions() — they cannot create a fresh subscription because subscriptionOf mapping is permanent.
function cancelAndBlockSubscriber(uint256 subId) external onlyMerchant;
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID to cancel and block |
unblockSubscriber
Lifts a subscriber block, allowing resubscription.
Does NOT restore remaining executions. Subscriber must top up via updateRemainingExecutions() on their existing subscription.
function unblockSubscriber(address subscriber) external onlyMerchant;
Parameters
| Name | Type | Description |
|---|---|---|
subscriber | address | Address to unblock |
subscribeWithPermit
Subscribe to a plan using a Permit2 signature in a single transaction.
This contract DOES NOT:
- validate the permit
- consume the signature
- enforce any spending rules Instead, it forwards everything to: PaymentProcessor.registerWithPermit(...) Which:
- consumes Permit2 signature
- validates plan + expiry
- registers execution via _registerExecution(...)
function subscribeWithPermit(
uint256 planId,
uint256 remainingExecutions,
uint256 allowanceExpiry,
bytes calldata permitData,
bytes calldata signature
)
external
nonReentrant
returns (uint256 subId);
Parameters
| Name | Type | Description |
|---|---|---|
planId | uint256 | Plan subscriber is joining |
remainingExecutions | uint256 | Number of payment executions allowed |
allowanceExpiry | uint256 | Expiry of Permit2 allowance |
permitData | bytes | ABI-encoded Permit2.PermitSingle struct |
signature | bytes | User's Permit2 signature |
Returns
| Name | Type | Description |
|---|---|---|
subId | uint256 | Newly created subscription ID |
registrationContext
Returns the expected permit parameters for registration.
Used by PaymentProcessor to validate Permit2 binding. Returns the token and minimum amount (plan price) required.
function registrationContext(
uint256 planId,
address subscriber
)
external
view
override
returns (address token, uint256 minAmount);
Parameters
| Name | Type | Description |
|---|---|---|
planId | uint256 | Plan identifier |
subscriber | address | Address of the subscriber (unused but part of interface) |
Returns
| Name | Type | Description |
|---|---|---|
token | address | Token address that must be permitted |
minAmount | uint256 | Minimum amount that must be permitted (plan price) |
registerExecution
Registers a new executable entity in the module.
Called after Permit2 signature is consumed and validated. Wrapper around _registerExecution that enforces access control.
function registerExecution(
address subscriber,
uint256 planId,
uint256 executionBudget,
uint256 allowanceExpiry
)
external
override
onlyPaymentProcessor
returns (uint256 id);
Parameters
| Name | Type | Description |
|---|---|---|
subscriber | address | Address of the subscriber/owner |
planId | uint256 | Plan identifier |
executionBudget | uint256 | Number of executions allowed |
allowanceExpiry | uint256 | When the delegated allowance expires |
Returns
| Name | Type | Description |
|---|---|---|
id | uint256 | Module-specific execution identifier (subscription ID) |
_registerExecution
Registers a new executable entity after funding authorization has been validated.
ONLY PaymentProcessor may call this function. This is a protocol-level registration hook called after permit validation, before execution begins. It initializes the execution state but does not perform any payment or grant access yet.
function _registerExecution(
address owner,
uint256 planId,
uint256 remainingExecutions,
uint256 allowanceExpiry
)
internal
returns (uint256 id);
Parameters
| Name | Type | Description |
|---|---|---|
owner | address | The owner's address (subscriber) |
planId | uint256 | The selected plan |
remainingExecutions | uint256 | Number of executions allowed |
allowanceExpiry | uint256 | When the delegated allowance expires |
Returns
| Name | Type | Description |
|---|---|---|
id | uint256 | The new execution ID (subscription ID) |
pauseSubscription
Pauses a subscription.
Only the subscriber can pause their subscription.
function pauseSubscription(uint256 subId) external onlySubscriber(subId);
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
resumeSubscription
Resumes a paused subscription.
Only the subscriber can resume their subscription. If the subscription has fallen outside the payment window (beyond the billing interval from nextChargeAt), nextChargeAt will be advanced to the current time to reset the payment window. This prevents PaymentWindowViolation errors and allows the subscription to be executed again.
function resumeSubscription(uint256 subId) external onlySubscriber(subId);
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
recoverSubscription
Recovers a subscription stuck in PaymentWindowViolation.
When a keeper misses the payment window (block.timestamp > nextChargeAt + interval), quoteExecution returns PaymentWindowViolation and the subscription becomes permanently un-executable because nextChargeAt is never updated. This function fast-forwards nextChargeAt to the current timestamp so the next keeper cycle can charge it. Callable by the subscriber or the merchant — NOT permissionless, to prevent griefing where a third party resets a subscriber's billing cadence. Subscription must NOT be paused (use resumeSubscription for paused subs).
function recoverSubscription(uint256 subId) external;
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
updateAllowanceExpiry
Update the allowance expiry for a subscription.
Only the subscriber can update their subscription's allowance expiry. This allows subscribers to extend their subscription's validity period.
function updateAllowanceExpiry(
uint256 subId,
uint256 newExpiry
)
external
onlySubscriber(subId);
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
newExpiry | uint256 | New allowance expiry timestamp |
updateRemainingExecutions
Update the remaining executions for a subscription.
Only the subscriber can update their subscription's remaining executions. This allows subscribers to add or remove execution capacity. Setting to 0 effectively disables the subscription until updated again. When re-adding executions to an expired subscription (was 0), if the payment window has been missed (block.timestamp > nextChargeAt + interval), nextChargeAt is fast-forwarded to block.timestamp to prevent permanent PaymentWindowViolation lockout. This mirrors the resumeSubscription pattern.
function updateRemainingExecutions(
uint256 subId,
uint256 newRemainingExecutions
)
external
onlySubscriber(subId);
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
newRemainingExecutions | uint256 | New remaining executions count (must fit in uint32) |
quoteExecution
Quotes whether execution is allowed and returns execution parameters.
Single entry point replacing canExecute, getExecutionFailureReason, and executionContext. Maps subscription-specific failure states to standardized ExecutionFailureReason enum. Failure reasons are checked in priority order:
- Subscription existence (permanent failure)
- Paused state (user-controlled)
- Plan active state (merchant-controlled)
- Execution budget (user-controlled)
- Allowance expiry (user-controlled)
- Timing: not yet due vs window violation When reason == None, the remaining return values contain valid execution context. When reason != None, context values are zeroed out.
function quoteExecution(uint256 id)
external
view
override
returns (
IExecutableModule.ExecutionFailureReason reason,
address payer,
address recipient,
address token,
uint256 amount,
uint256 executionTime,
uint256 windowId
);
Parameters
| Name | Type | Description |
|---|---|---|
id | uint256 | Subscription ID |
Returns
| Name | Type | Description |
|---|---|---|
reason | IExecutableModule.ExecutionFailureReason | Standardized failure reason (None if executable) |
payer | address | Address that will pay (subscriber) |
recipient | address | Address that will receive payment (merchant) |
token | address | Token address to transfer |
amount | uint256 | Amount to transfer (plan price) |
executionTime | uint256 | Canonical execution timestamp (nextChargeAt) |
windowId | uint256 | Idempotency window identifier |
onExecute
Called by PaymentProcessor after a successful execution.
Mutates payment-critical fields: sets/updates lastPaidAt, updates nextChargeAt, decrements remainingExecutions. Reverts if remainingExecutions is 0.
function onExecute(uint256 id, uint256 executedAt) external override onlyPaymentProcessor;
Parameters
| Name | Type | Description |
|---|---|---|
id | uint256 | Subscription ID |
executedAt | uint256 | Timestamp of execution |
getPlan
function getPlan(uint256 planId) external view returns (Plan memory);
getSubscription
function getSubscription(uint256 subId) external view returns (Subscription memory);
hasEverPaid
Checks if a subscription has ever executed a payment.
function hasEverPaid(uint256 subId) external view returns (bool);
Parameters
| Name | Type | Description |
|---|---|---|
subId | uint256 | Subscription ID |
Returns
| Name | Type | Description |
|---|---|---|
<none> | bool | bool True if lastPaidAt > 0 |
getSubscriber
function getSubscriber(uint256 subId) external view returns (address);
planCount
function planCount() external view returns (uint256);
subCount
function subCount() external view returns (uint256);
nextPlanId
Returns the next plan ID (same as planCount for backwards-compatibility).
Integrations may already rely on planCount returning the next ID.
function nextPlanId() external view returns (uint256);
nextSubId
Returns the next subscription ID (same as subCount for backwards-compatibility).
Integrations may already rely on subCount returning the next ID.
function nextSubId() external view returns (uint256);
planTotal
Returns the total number of created plans.
Uses nextPlanId - 1 since plan IDs start at 1.
function planTotal() external view returns (uint256);
subTotal
Returns the total number of created subscriptions.
Uses nextSubId - 1 since subscription IDs start at 1.
function subTotal() external view returns (uint256);
isActive
Returns whether a subject is currently authorized for a plan.
This function answers: "Is this subscriber currently authorized?" Authorization is separate from execution. A subscription can be authorized (user has access) even if execution is not yet due or paused. Authorization logic:
- Subscription must exist
- Subscription must be paid at least once
- If paid before: must be within access window from lastPaidAt
function isActive(address subject, uint256 scope) external view override returns (bool);
Parameters
| Name | Type | Description |
|---|---|---|
subject | address | Address being checked (subscriber) |
scope | uint256 | Plan ID (authorization scope) |
Returns
| Name | Type | Description |
|---|---|---|
<none> | bool | bool True if authorized |
isActiveAny
Returns whether a subject is currently authorized for any plan in the list.
Returns false when the planIds array is empty. Array length is bounded by MAX_BATCH_QUERY to prevent OOG griefing. Non-existent plan IDs are safely skipped (_isActive returns false).
function isActiveAny(
address subject,
uint32[] calldata planIds
)
external
view
override
returns (bool);
authView
Returns an aggregate authorization view for a subject and plan.
Never reverts for missing plan/subscription; returns best-effort zeros.
function authView(
address subject,
uint256 planId
)
external
view
returns (AuthView memory viewData);
_isActive
function _isActive(address subject, uint256 scope) internal view returns (bool);
Events
PaymentProcessorUpdated
event PaymentProcessorUpdated(address indexed oldProcessor, address indexed newProcessor);
PlanCreated
event PlanCreated(
uint256 indexed planId,
address indexed merchant,
address token,
uint256 price,
uint256 interval,
bytes32 metadataHash,
uint256 gracePeriod
);
SubscriptionCreated
event SubscriptionCreated(
uint256 indexed subId,
address indexed subscriber,
uint256 planId,
uint256 allowanceExpiry,
uint256 remainingExecutions
);
SubscriptionPaused
event SubscriptionPaused(uint256 indexed subId);
SubscriptionUnpaused
event SubscriptionUnpaused(uint256 indexed subId);
AllowanceExpiryUpdated
event AllowanceExpiryUpdated(uint256 indexed subId, uint256 newExpiry);
RemainingExecutionsUpdated
event RemainingExecutionsUpdated(uint256 indexed subId, uint256 newRemainingExecutions);
SubscriptionNextChargeAtUpdated
event SubscriptionNextChargeAtUpdated(uint256 indexed subId, uint256 newNextChargeAt);
PlanActiveToggled
event PlanActiveToggled(uint256 indexed planId, bool active);
SubscriptionExecuted
event SubscriptionExecuted(
uint256 indexed subId, uint256 executedAt, uint256 remainingExecutions
);
SubscriptionRecovered
event SubscriptionRecovered(
uint256 indexed subId, uint256 oldNextChargeAt, uint256 newNextChargeAt
);
SubscriberBlockedByMerchant
event SubscriberBlockedByMerchant(
address indexed subscriber, uint256 indexed subId, address indexed merchant
);
SubscriberUnblockedByMerchant
event SubscriberUnblockedByMerchant(address indexed subscriber, address indexed merchant);
ModulePaused
event ModulePaused(address indexed merchant);
ModuleUnpaused
event ModuleUnpaused(address indexed merchant);
Errors
AlreadyInitialized
error AlreadyInitialized();
InvalidMerchant
error InvalidMerchant();
InvalidProcessor
error InvalidProcessor();
InvalidPrice
error InvalidPrice();
InvalidInterval
error InvalidInterval();
InvalidToken
error InvalidToken();
InvalidAllowanceExpiry
error InvalidAllowanceExpiry();
PlanDoesNotExist
error PlanDoesNotExist();
PlanNotActive
error PlanNotActive();
SubscriptionDoesNotExist
error SubscriptionDoesNotExist();
SubscriptionAlreadyExistsForPlan
error SubscriptionAlreadyExistsForPlan();
NoRemainingExecutions
error NoRemainingExecutions();
OnlyMerchant
error OnlyMerchant();
OnlyProcessor
error OnlyProcessor();
OnlySubscriber
error OnlySubscriber();
SubscriptionAlreadyPaused
error SubscriptionAlreadyPaused();
SubscriptionNotPaused
error SubscriptionNotPaused();
SubscriptionNotExpired
error SubscriptionNotExpired();
InvalidGracePeriod
error InvalidGracePeriod();
ArrayTooLong
error ArrayTooLong();
SubscriberBlocked
error SubscriberBlocked();
SubscriberNotBlocked
error SubscriberNotBlocked();
ModuleAlreadyPaused
error ModuleAlreadyPaused();
ModuleNotPaused
error ModuleNotPaused();
Structs
PlanPacked
Packed plan storage for gas optimization.
Slot 0: price (160 bits) | interval (32 bits) | gracePeriod (32 bits) | active (1 bit) Slot 1: token address Slot 2: metadataHash
struct PlanPacked {
uint160 price; // slot 0 (bits 0-159)
uint32 interval; // slot 0 (bits 160-191)
uint32 gracePeriod; // slot 0 (bits 192-223)
bool active; // slot 0 (bit 224)
address token; // slot 1
bytes32 metadataHash; // slot 2
}
SubscriptionPacked
Packed subscription storage for gas optimization.
Slot 0: lastPaidAt (64 bits) | allowanceExpiry (48 bits) | paused (1 bit) | planId (32 bits) | remainingExecutions (32 bits) Slot 1: nextChargeAt (64 bits) Slot 2: subscriber address
struct SubscriptionPacked {
uint64 lastPaidAt; // slot 0 (bits 0-63) - 0 until first execution
uint48 allowanceExpiry; // slot 0 (bits 64-111)
bool paused; // slot 0 (bit 112)
uint32 planId; // slot 0 (bits 113-144)
uint32 remainingExecutions; // slot 0 (bits 145-176) - execution guardrail
uint64 nextChargeAt; // slot 1 - when keeper should charge next
address subscriber; // slot 2
}
Plan
Merchant-created subscription plan (external API).
Immutable after creation except for the active flag. This struct is used for external API compatibility.
struct Plan {
uint256 price;
uint256 interval;
uint256 gracePeriod;
address token;
bytes32 metadataHash;
bool active;
}
Subscription
A subscriber's commitment to a plan (external API).
Only PaymentProcessor can mutate billing state fields. This struct is used for external API compatibility.
struct Subscription {
uint256 planId;
uint256 lastPaidAt;
uint256 nextChargeAt;
uint256 remainingExecutions;
uint256 allowanceExpiry;
bool paused;
}
AuthView
Aggregate authorization view for a subscriber and plan.
Best-effort view that never reverts for missing plan/subscription.
struct AuthView {
uint256 subId;
bool exists;
bool hasEverPaid;
bool authorized;
bool paused;
uint256 planId;
uint256 lastPaidAt;
uint256 nextChargeAt;
uint256 accessEnd;
uint256 allowanceExpiry;
uint256 remainingExecutions;
bool planActive;
address token;
uint256 price;
uint256 interval;
uint256 gracePeriod;
}