Skip to main content

PaymentProcessor

Inherits: Ownable2Step, ReentrancyGuard, IPaymentProcessor

Title: PaymentProcessor

Author: amser

Global execution engine for the amser protocol.

This contract is responsible for:

  • consuming Permit2 signatures,
  • validating subscription parameters,
  • creating subscriptions,
  • executing billing,
  • enforcing idempotency,
  • routing protocol + keeper fees,
  • updating subscription state inside merchant modules. Architecture (RAI Core Model):
  • SubscriptionModule = pure data (plans, subscriptions, lastPaidAt, remainingAttempts, expiry)
  • PaymentProcessor = enforcement + execution (Permit2 + transfers)
  • FeeSchedule = tier-based fee resolution (independently upgradeable)
  • Permit2 = Uniswap Permit2 for delegated spending
  • Keeper Network = calls execute() deterministically / openly Security Invariants:
  • Only PaymentProcessor may mutate subscription payment state.
  • Only keepers (or open execution mode) may call execute().
  • Permit2 signatures are consumed safely and only once.
  • All billing is idempotent using bitmap window tracking.
  • No user funds are ever held in this contract (non-custodial).
  • This contract is immutable (non-upgradeable). Fee logic and token policy are externalized to FeeSchedule and TokenRegistry respectively.
  • Modules can be migrated to a new processor via migrateModule(). High-Level Flow:
  1. User calls module.subscribeWithPermit(...) (or equivalent user-facing function)
  2. Module forwards to PaymentProcessor.registerWithPermit(...)
  3. PaymentProcessor:
  • gets registration context from module
  • validates Permit2 binding (generic)
  • consumes Permit2 signature
  • calls module.registerExecution(...)
  1. Future executions are handled by keepers via execute()
  2. PaymentProcessor calls module.onExecute(...) after successful transfer NOTE: This design ensures users never call PaymentProcessor directly for UX, while keeping Permit2 logic centralized and secure inside this contract. The processor is fully module-agnostic and works with any module type.

State Variables

permit2

Permit2 contract address (immutable, set at deployment)

address public immutable permit2

MAX_BATCH_SIZE

Maximum number of IDs in a single batchExecute call

uint256 public constant MAX_BATCH_SIZE = 50

MAX_PROTOCOL_FEE_BPS

Defensive local bound for protocol fee basis points.

Mirrors FeeSchedule's max to avoid bricking if feeSchedule is misconfigured.

uint16 public constant MAX_PROTOCOL_FEE_BPS = 3000

treasury

Global treasury receiving protocol fees

address public treasury

feeSchedule

Fee schedule contract for tier-based fee resolution

FeeSchedule is an independently managed contract that resolves per-module fee tiers. Can be swapped via setFeeSchedule().

address public feeSchedule

_receipts

Stores idempotency bitmaps for module → subscription → receipt

mapping(address => mapping(uint256 => PaymentReceipt)) private _receipts

isKeeper

Permissioned keeper operators

mapping(address => bool) public isKeeper

factory

Factory contract that deploys modules (set once after deployment)

address public factory

isModule

Registry of modules deployed by the factory

mapping(address => bool) public isModule

_deregistered

One-way permanent deregistration flag.

Once a module is deregistered via deregisterModule(), this flag is set permanently. Deregistration is irreversible — the module cannot be re-registered via any path (factory, migration source, or owner), even if the underlying issue is resolved. Deploy a new module instead.

mapping(address => bool) private _deregistered

authorizedMigrationSources

Processors authorized to register migrated modules (e.g., old processor during migration)

mapping(address => bool) public authorizedMigrationSources

tokenRegistry

Registry of supported tokens and per-token constraints

address public tokenRegistry

paused

Global execution pause

bool public paused

Functions

onlyKeeper

modifier onlyKeeper() ;

constructor

Deploys the PaymentProcessor (immutable, non-upgradeable).

constructor(
address _treasury,
address feeSchedule_,
address _initialKeeper,
address permit2_,
address tokenRegistry_
)
Ownable(msg.sender);

Parameters

NameTypeDescription
_treasuryaddressWhere protocol fees are sent
feeSchedule_addressFeeSchedule contract for tier-based fee resolution
_initialKeeperaddressFirst authorized keeper (optional, pass address(0) to skip)
permit2_addressPermit2 contract address (use canonical address 0x000000000022D473030F116dDEE9F6B43aC78BA3 for mainnet)
tokenRegistry_addressTokenRegistry contract for per-token constraints

registerWithPermit

Registers an executable entity using a Permit2 signature in one transaction.

Called ONLY by module.subscribeWithPermit() (or equivalent user-facing function). Users NEVER interact with PaymentProcessor directly. Generic registration flow:

  1. Decode Permit2 data
  2. Get registration context from module
  3. Validate Permit2 binding (generic)
  4. Consume Permit2 signature
  5. Register execution in module The processor is module-agnostic and only enforces:
  • Permit2 signature validation
  • Permit binding to module requirements
  • Signature consumption Modules define all business semantics.
function registerWithPermit(
address subscriber,
address module,
uint256 planId,
uint256 executionBudget,
uint256 allowanceExpiry,
bytes calldata permitData,
bytes calldata signature
)
external
nonReentrant
returns (uint256 id);

Parameters

NameTypeDescription
subscriberaddressUser creating the registration
moduleaddressAddress of the registrable module
planIduint256Module-specific plan identifier
executionBudgetuint256Number of executions allowed
allowanceExpiryuint256Permit2 allowance expiry timestamp
permitDatabytesABI-encoded Permit2.PermitSingle struct
signaturebytesPermit2 signature

Returns

NameTypeDescription
iduint256Module-specific execution identifier

execute

Executes a payment for any executable module (keeper entrypoint).

Validates module + paused state, resolves fees once, then delegates to _executeOne() for the per-ID work.

function execute(address module, uint256 id) external onlyKeeper nonReentrant;

Parameters

NameTypeDescription
moduleaddressAddress of the executable module
iduint256Module-specific identifier (e.g., subscription ID)

batchExecute

Executes payments for multiple IDs in a single module.

Gas optimization for keepers: validates module, resolves fees, and checks paused state once for the entire batch. Each ID is then processed independently via _executeOne() with soft-fail semantics (failures emit events, never revert the batch). Bounded by MAX_BATCH_SIZE to prevent OOG griefing. NOTE: Duplicate IDs in the array are not rejected — the first occurrence will succeed and subsequent duplicates will soft-fail with PaymentAlreadyProcessed (idempotency). Keepers should deduplicate IDs off-chain to avoid wasting gas.

Duplicate IDs in the ids array are handled gracefully: the first occurrence executes successfully, subsequent duplicates soft-fail with PaymentAlreadyProcessed. However, each duplicate still consumes gas for the quoteExecution() check and emits an ExecutionAttempt event. Keepers SHOULD deduplicate IDs before submitting a batch. The off-chain keeper implementation should maintain a short-term cache (TTL: one poll interval) of recently processed IDs to avoid accidental duplicates.

function batchExecute(address module, uint256[] calldata ids) external onlyKeeper nonReentrant;

Parameters

NameTypeDescription
moduleaddressAddress of the executable module (same for all IDs)
idsuint256[]Array of module-specific identifiers

_executeOne

Internal per-ID execution logic shared by execute() and batchExecute().

Soft-fail: returns false and emits ExecutionFailed on ANY failure, including Permit2 transfer failures (wrapped in try/catch). IMPORTANT: Caller MUST emit ExecutionAttempt before calling this function. Flow:

  1. Check paused
  2. Quote execution from module
  3. Token registry validation
  4. Amount fits uint160
  5. Idempotency check
  6. Permit2 allowance check
  7. Calculate fee splits
  8. Execute Permit2 transfers (try/catch)
  9. Mark window processed (on success only)
  10. Finalize module state (on success only)
function _executeOne(
address module,
uint256 id,
uint16 protocolBps,
uint16 keeperBps
)
internal
returns (bool success);

Parameters

NameTypeDescription
moduleaddressValidated module address
iduint256Module-specific identifier
protocolBpsuint16Cached protocol fee in BPS
keeperBpsuint16Cached keeper share of protocol fee in BPS

Returns

NameTypeDescription
successboolTrue if execution completed, false if soft-failed

_fail

Emits ExecutionFailed event for soft-fail execution paths.

Gas-optimized helper to reduce code duplication and ABI encoding overhead.

function _fail(address module, uint256 id, FailCode code) internal;

_isWindowProcessed

function _isWindowProcessed(
address module,
uint256 subId,
uint64 windowId
)
internal
view
returns (bool);

_markWindowProcessed

Single SSTORE — bitmap only. Stats are derived off-chain from events.

function _markWindowProcessed(address module, uint256 subId, uint64 windowId) internal;

_validateResolvedFees

Validates fee configuration returned by FeeSchedule.

Prevents processor bricking from malicious/misconfigured FeeSchedule return values.

function _validateResolvedFees(uint16 protocolBps, uint16 keeperBps) internal pure;

setTreasury

function setTreasury(address _treasury) external onlyOwner;

setFeeSchedule

Updates the FeeSchedule contract address.

Allows upgrading fee logic without upgrading the processor. The FeeSchedule is an independently managed contract that resolves per-module fee tiers. Ownership can be transferred to a DAO.

function setFeeSchedule(address _feeSchedule) external onlyOwner;

Parameters

NameTypeDescription
_feeScheduleaddressAddress of the new FeeSchedule contract

setKeeper

function setKeeper(address keeper, bool authorized) external onlyOwner;

setFactory

Sets the factory address (only callable once, before any modules are registered).

This resolves the circular dependency between PaymentProcessor and RAIModuleFactory. Factory can only be set once and must be set before any modules are registered. RAIModuleFactory supports plug-and-play architecture for adding new module types.

function setFactory(address _factory) external onlyOwner;

Parameters

NameTypeDescription
_factoryaddressAddress of the RAIModuleFactory

pause

function pause() external onlyOwner;

unpause

function unpause() external onlyOwner;

setTokenRegistry

Updates the TokenRegistry contract address.

Allows swapping the token policy registry if the current one is compromised or needs upgrading. Only callable by the owner.

function setTokenRegistry(address _tokenRegistry) external onlyOwner;

Parameters

NameTypeDescription
_tokenRegistryaddressAddress of the new TokenRegistry contract

deregisterModule

Deregisters a module, preventing it from being processed.

Use this to remove compromised or deprecated modules without pausing the entire processor. Deregistered modules can no longer have payments executed or new subscriptions registered through this processor.

function deregisterModule(address module) external onlyOwner;

Parameters

NameTypeDescription
moduleaddressAddress of the module to deregister

migrateModule

Migrates a module to a new PaymentProcessor.

Called by the protocol owner when deploying a V2 processor. The module validates this call came from its current processor (via onlyPaymentProcessor modifier), preventing unauthorized migration. Prerequisite: The new processor must have THIS processor added as an authorized migration source via addMigrationSource(address(this)). After migration:

  • The module is registered in newProcessor's isModule registry
  • The module's paymentProcessor storage points to newProcessor
  • This processor deregisters the module (no longer accepts execute/register)
  • New subscriptions/executions go through newProcessor
  • Existing Permit2 allowances are still bound to THIS processor's address, so subscribers will need to re-approve the new processor
  • Idempotency bitmaps for this module remain in THIS processor
function migrateModule(address module, address newProcessor) external onlyOwner;

Parameters

NameTypeDescription
moduleaddressAddress of the module to migrate
newProcessoraddressAddress of the new PaymentProcessor

addMigrationSource

Adds a processor as an authorized migration source.

When migrating modules to this processor, the old processor must be added here first. Only the old processor can then call registerModule during migrateModule().

function addMigrationSource(address processor) external onlyOwner;

Parameters

NameTypeDescription
processoraddressAddress of the old PaymentProcessor that may migrate modules here

removeMigrationSource

Removes a processor from authorized migration sources.

function removeMigrationSource(address processor) external onlyOwner;

Parameters

NameTypeDescription
processoraddressAddress of the PaymentProcessor to remove

rescueTokens

Rescues ERC20 tokens accidentally sent to this contract.

The PaymentProcessor should never hold token balances; all payments are routed directly from payer → recipients via Permit2. This function allows the owner to recover any tokens that end up here by mistake.

function rescueTokens(address token, address to, uint256 amount) external onlyOwner;

Parameters

NameTypeDescription
tokenaddressERC20 token address to rescue
toaddressRecipient of the rescued tokens
amountuint256Amount to rescue (must be > 0)

calculateFees

Calculates fees for a specific module based on its tier.

Resolves the module's fee tier via FeeSchedule, then computes splits. Use this to preview the exact fee breakdown for a given module and amount.

function calculateFees(
address module,
uint256 amount
)
external
view
returns (
uint256 protocolFee,
uint256 keeperFee,
uint256 treasuryFee,
uint256 merchantAmount
);

Parameters

NameTypeDescription
moduleaddressAddress of the module (used for tier resolution)
amountuint256Total payment amount

Returns

NameTypeDescription
protocolFeeuint256Total protocol fee
keeperFeeuint256Keeper's share of the protocol fee
treasuryFeeuint256Treasury's share of the protocol fee
merchantAmountuint256Amount the merchant receives

isWindowProcessed

function isWindowProcessed(
address module,
uint256 subId,
uint64 windowId
)
external
view
returns (bool);

isDeregistered

Returns whether a module has been permanently deregistered.

Once true, the module can never be re-registered via any path.

function isDeregistered(address module) external view returns (bool);

Parameters

NameTypeDescription
moduleaddressAddress of the module to check

Returns

NameTypeDescription
<none>boolbool True if the module has been permanently deregistered

registerModule

Registers a module as valid.

Callable by:

  • Factory: during module deployment
  • Authorized migration sources: during migrateModule() when migrating from another processor Ensures only factory-deployed or migrated modules can be processed, preventing malicious contracts from masquerading as valid modules.
function registerModule(address module) external;

Parameters

NameTypeDescription
moduleaddressAddress of the module to register (SubscriptionModule, CreditModule, etc.)

_isValidModule

Validates that a module is legitimate by checking factory provenance.

Only modules registered via registerModule() (called by factory) are valid. This prevents malicious contracts from implementing the same ABI and tricking keepers into processing payments for fake modules.

function _isValidModule(address module) internal view returns (bool);

Parameters

NameTypeDescription
moduleaddressAddress of the module to validate

Returns

NameTypeDescription
<none>boolbool True if module was deployed by the factory and registered

_validateFeeSchedule

Validates that the given address is a deployed contract implementing IFeeSchedule.

Checks that the address has code deployed (not an EOA) and probes the resolveFees interface with a dummy call to ensure it responds correctly. Reverts with InvalidFeeSchedule if the probe fails.

function _validateFeeSchedule(address _feeSchedule) internal view;

Parameters

NameTypeDescription
_feeScheduleaddressAddress to validate

_validateTokenRegistry

Validates that the given address is a deployed contract implementing ITokenRegistry.

Checks that the address has code deployed (not an EOA) and probes the isEnabled interface with a dummy call to ensure it responds correctly. Reverts with InvalidTokenRegistry if the probe fails.

function _validateTokenRegistry(address _tokenRegistry) internal view;

Parameters

NameTypeDescription
_tokenRegistryaddressAddress to validate

Events

ExecutionAttempt

event ExecutionAttempt(
address indexed module, uint256 indexed id, address indexed keeper, uint256 timestamp
);

ExecutionFailed

event ExecutionFailed(
address indexed module,
uint256 indexed id,
address indexed keeper,
FailCode code,
uint256 timestamp
);

ExecutionExecuted

event ExecutionExecuted(
address indexed module,
uint256 indexed id,
address indexed payer,
address recipient,
uint256 amount,
uint256 protocolFee,
uint256 keeperFee,
address keeper,
uint256 timestamp
);

TreasuryUpdated

event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);

FeeScheduleUpdated

event FeeScheduleUpdated(address indexed oldFeeSchedule, address indexed newFeeSchedule);

TokenRegistryUpdated

event TokenRegistryUpdated(address indexed oldTokenRegistry, address indexed newTokenRegistry);

KeeperUpdated

event KeeperUpdated(address indexed keeper, bool authorized);

ModuleRegistered

event ModuleRegistered(address indexed module);

ModuleDeregistered

event ModuleDeregistered(address indexed module);

FactorySet

event FactorySet(address indexed factory);

ModuleMigrated

event ModuleMigrated(address indexed module, address indexed newProcessor);

MigrationSourceAdded

event MigrationSourceAdded(address indexed processor);

MigrationSourceRemoved

event MigrationSourceRemoved(address indexed processor);

Paused

event Paused(address indexed account);

Unpaused

event Unpaused(address indexed account);

TokensRescued

event TokensRescued(address indexed token, address indexed to, uint256 amount);

BatchExecuted

event BatchExecuted(
address indexed module, address indexed keeper, uint256 attempted, uint256 succeeded
);

Errors

OnlyKeeper

error OnlyKeeper();

InvalidTreasury

error InvalidTreasury();

InvalidFeeSchedule

error InvalidFeeSchedule();

InvalidModule

error InvalidModule();

ExecutionNotAllowed

error ExecutionNotAllowed();

NotYetDue

error NotYetDue();

NoRemainingExecutions

error NoRemainingExecutions();

AllowanceExpired

error AllowanceExpired();

PaymentAlreadyProcessed

error PaymentAlreadyProcessed();

InvalidPermitData

error InvalidPermitData();

InvalidPermitBinding

error InvalidPermitBinding();

InsufficientAllowance

error InsufficientAllowance();

AmountExceedsMax

error AmountExceedsMax();

PaymentWindowViolation

error PaymentWindowViolation();

InvalidModuleAddress

error InvalidModuleAddress();

OnlyFactory

error OnlyFactory();

InvalidFactory

error InvalidFactory();

InvalidPermit2

error InvalidPermit2();

InvalidTokenRegistry

error InvalidTokenRegistry();

UnsupportedToken

error UnsupportedToken();

AmountBelowTokenMinimum

error AmountBelowTokenMinimum();

TokenAmountCapExceeded

error TokenAmountCapExceeded();

ProcessorPaused

error ProcessorPaused();

InvalidProcessor

error InvalidProcessor();

InvalidRecipient

error InvalidRecipient();

RescueAmountZero

error RescueAmountZero();

BatchSizeExceeded

error BatchSizeExceeded();

BatchEmpty

error BatchEmpty();

NotAContract

error NotAContract();

InvalidKeeper

error InvalidKeeper();

InvalidFeeConfig

error InvalidFeeConfig();

ModulePermanentlyDeregistered

error ModulePermanentlyDeregistered();

Structs

PaymentReceipt

Bitmap state for idempotent billing execution.

Each billing window is derived from: windowId = nextChargeAt / interval processedWindows[wordIndex] is a 256-bit word where each bit represents one windowId, preventing double-charging. Payment totals and timestamps are derived off-chain from ExecutionExecuted events — no extra SSTOREs needed.

struct PaymentReceipt {
mapping(uint256 => uint256) processedWindows; // wordIndex => 256-bit bitmap
}

Enums

FailCode

Standardized failure codes for execution attempts.

Values 0-8 align with IExecutableModule.ExecutionFailureReason for direct casting. Values 9+ are processor-specific failures (Permit2 checks, idempotency).

enum FailCode {
None, // 0 - No failure (should not emit)
NotFound, // 1 - Entity does not exist
Paused, // 2 - Entity is paused
PlanInactive, // 3 - Plan is not active
NoRemainingExecutions, // 4 - Execution budget exhausted
AllowanceExpired, // 5 - Module-stored allowance expiry passed
NotYetDue, // 6 - Execution not yet due
PaymentWindowViolation, // 7 - Beyond payment window
ExecutionNotAllowed, // 8 - Catch-all for other cases
// Processor-specific failure codes (not from module)
InsufficientAllowance, // 9 - Permit2 allowance too low
AmountExceedsMax, // 10 - Amount > uint160.max
PaymentAlreadyProcessed, // 11 - Idempotency: already processed this window
ProcessorPaused, // 12 - Global processor pause
UnsupportedToken, // 13 - Token is not enabled in registry
AmountBelowTokenMinimum, // 14 - Token amount below registry minimum
TokenAmountCapExceeded, // 15 - Token amount exceeds registry cap
TransferFailed // 16 - Permit2 transfer failed (insufficient balance, revoked, etc)
}