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:
- User calls module.subscribeWithPermit(...) (or equivalent user-facing function)
- Module forwards to PaymentProcessor.registerWithPermit(...)
- PaymentProcessor:
- gets registration context from module
- validates Permit2 binding (generic)
- consumes Permit2 signature
- calls module.registerExecution(...)
- Future executions are handled by keepers via execute()
- 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
| Name | Type | Description |
|---|---|---|
_treasury | address | Where protocol fees are sent |
feeSchedule_ | address | FeeSchedule contract for tier-based fee resolution |
_initialKeeper | address | First authorized keeper (optional, pass address(0) to skip) |
permit2_ | address | Permit2 contract address (use canonical address 0x000000000022D473030F116dDEE9F6B43aC78BA3 for mainnet) |
tokenRegistry_ | address | TokenRegistry 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:
- Decode Permit2 data
- Get registration context from module
- Validate Permit2 binding (generic)
- Consume Permit2 signature
- 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
| Name | Type | Description |
|---|---|---|
subscriber | address | User creating the registration |
module | address | Address of the registrable module |
planId | uint256 | Module-specific plan identifier |
executionBudget | uint256 | Number of executions allowed |
allowanceExpiry | uint256 | Permit2 allowance expiry timestamp |
permitData | bytes | ABI-encoded Permit2.PermitSingle struct |
signature | bytes | Permit2 signature |
Returns
| Name | Type | Description |
|---|---|---|
id | uint256 | Module-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
| Name | Type | Description |
|---|---|---|
module | address | Address of the executable module |
id | uint256 | Module-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
| Name | Type | Description |
|---|---|---|
module | address | Address of the executable module (same for all IDs) |
ids | uint256[] | 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:
- Check paused
- Quote execution from module
- Token registry validation
- Amount fits uint160
- Idempotency check
- Permit2 allowance check
- Calculate fee splits
- Execute Permit2 transfers (try/catch)
- Mark window processed (on success only)
- Finalize module state (on success only)
function _executeOne(
address module,
uint256 id,
uint16 protocolBps,
uint16 keeperBps
)
internal
returns (bool success);
Parameters
| Name | Type | Description |
|---|---|---|
module | address | Validated module address |
id | uint256 | Module-specific identifier |
protocolBps | uint16 | Cached protocol fee in BPS |
keeperBps | uint16 | Cached keeper share of protocol fee in BPS |
Returns
| Name | Type | Description |
|---|---|---|
success | bool | True 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
| Name | Type | Description |
|---|---|---|
_feeSchedule | address | Address 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
| Name | Type | Description |
|---|---|---|
_factory | address | Address 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
| Name | Type | Description |
|---|---|---|
_tokenRegistry | address | Address 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
| Name | Type | Description |
|---|---|---|
module | address | Address 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
| Name | Type | Description |
|---|---|---|
module | address | Address of the module to migrate |
newProcessor | address | Address 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
| Name | Type | Description |
|---|---|---|
processor | address | Address of the old PaymentProcessor that may migrate modules here |
removeMigrationSource
Removes a processor from authorized migration sources.
function removeMigrationSource(address processor) external onlyOwner;
Parameters
| Name | Type | Description |
|---|---|---|
processor | address | Address 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
| Name | Type | Description |
|---|---|---|
token | address | ERC20 token address to rescue |
to | address | Recipient of the rescued tokens |
amount | uint256 | Amount 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
| Name | Type | Description |
|---|---|---|
module | address | Address of the module (used for tier resolution) |
amount | uint256 | Total payment amount |
Returns
| Name | Type | Description |
|---|---|---|
protocolFee | uint256 | Total protocol fee |
keeperFee | uint256 | Keeper's share of the protocol fee |
treasuryFee | uint256 | Treasury's share of the protocol fee |
merchantAmount | uint256 | Amount 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
| Name | Type | Description |
|---|---|---|
module | address | Address of the module to check |
Returns
| Name | Type | Description |
|---|---|---|
<none> | bool | bool 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
| Name | Type | Description |
|---|---|---|
module | address | Address 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
| Name | Type | Description |
|---|---|---|
module | address | Address of the module to validate |
Returns
| Name | Type | Description |
|---|---|---|
<none> | bool | bool 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
| Name | Type | Description |
|---|---|---|
_feeSchedule | address | Address 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
| Name | Type | Description |
|---|---|---|
_tokenRegistry | address | Address 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)
}