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