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;
}