Skip to main content

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 active flag 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 lastPaidAt as the funding anchor (0 until first execution).
  • remainingExecutions serves 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

NameTypeDescription
_merchantaddressThe merchant who will own this module
_paymentProcessoraddressThe 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

NameTypeDescription
_newProcessoraddressAddress 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

NameTypeDescription
priceuint256
intervaluint256
gracePerioduint256Must not exceed interval. Grace periods longer than the billing interval allow multi-window free access.
tokenaddress
metadataHashbytes32

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

NameTypeDescription
subIduint256Subscription 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

NameTypeDescription
subscriberaddressAddress 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

NameTypeDescription
planIduint256Plan subscriber is joining
remainingExecutionsuint256Number of payment executions allowed
allowanceExpiryuint256Expiry of Permit2 allowance
permitDatabytesABI-encoded Permit2.PermitSingle struct
signaturebytesUser's Permit2 signature

Returns

NameTypeDescription
subIduint256Newly 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

NameTypeDescription
planIduint256Plan identifier
subscriberaddressAddress of the subscriber (unused but part of interface)

Returns

NameTypeDescription
tokenaddressToken address that must be permitted
minAmountuint256Minimum 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

NameTypeDescription
subscriberaddressAddress of the subscriber/owner
planIduint256Plan identifier
executionBudgetuint256Number of executions allowed
allowanceExpiryuint256When the delegated allowance expires

Returns

NameTypeDescription
iduint256Module-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

NameTypeDescription
owneraddressThe owner's address (subscriber)
planIduint256The selected plan
remainingExecutionsuint256Number of executions allowed
allowanceExpiryuint256When the delegated allowance expires

Returns

NameTypeDescription
iduint256The new execution ID (subscription ID)

pauseSubscription

Pauses a subscription.

Only the subscriber can pause their subscription.

function pauseSubscription(uint256 subId) external onlySubscriber(subId);

Parameters

NameTypeDescription
subIduint256Subscription 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

NameTypeDescription
subIduint256Subscription 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

NameTypeDescription
subIduint256Subscription 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

NameTypeDescription
subIduint256Subscription ID
newExpiryuint256New 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

NameTypeDescription
subIduint256Subscription ID
newRemainingExecutionsuint256New 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:

  1. Subscription existence (permanent failure)
  2. Paused state (user-controlled)
  3. Plan active state (merchant-controlled)
  4. Execution budget (user-controlled)
  5. Allowance expiry (user-controlled)
  6. 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

NameTypeDescription
iduint256Subscription ID

Returns

NameTypeDescription
reasonIExecutableModule.ExecutionFailureReasonStandardized failure reason (None if executable)
payeraddressAddress that will pay (subscriber)
recipientaddressAddress that will receive payment (merchant)
tokenaddressToken address to transfer
amountuint256Amount to transfer (plan price)
executionTimeuint256Canonical execution timestamp (nextChargeAt)
windowIduint256Idempotency 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

NameTypeDescription
iduint256Subscription ID
executedAtuint256Timestamp 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

NameTypeDescription
subIduint256Subscription ID

Returns

NameTypeDescription
<none>boolbool 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

NameTypeDescription
subjectaddressAddress being checked (subscriber)
scopeuint256Plan ID (authorization scope)

Returns

NameTypeDescription
<none>boolbool 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;
}