Skip to main content

PaymentOperator

The main payment operator contract with pluggable conditions for flexible authorization logic.

Overview

  • Type: Operator instance (one per fee recipient + configuration)
  • Deployment: Via PaymentOperatorFactory
  • Immutability: Cannot be paused or upgraded
  • Configuration: 10 slots for conditions and recorders
  • Use Cases: Marketplace, subscriptions, streaming, grants, custom flows

Immutable Fields

address public immutable ESCROW;              // AuthCaptureEscrow address
address public immutable FEE_RECIPIENT;       // Operator fee recipient
ProtocolFeeConfig public immutable PROTOCOL_FEE_CONFIG; // Shared protocol fee config
IFeeCalculator public immutable FEE_CALCULATOR; // Operator fee calculator

State

// Fee tracking for accurate distribution
mapping(address token => uint256) public accumulatedProtocolFees;

// Fees locked at authorization time
mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees;

10-Slot Configuration

  1. AUTHORIZE_CONDITION - Who can authorize payments
  2. CHARGE_CONDITION - Who can charge partial amounts
  3. RELEASE_CONDITION - Who can release from escrow
  4. REFUND_IN_ESCROW_CONDITION - Who can refund during escrow
  5. REFUND_POST_ESCROW_CONDITION - Who can refund after release
Default: address(0) = always allow
  1. AUTHORIZE_RECORDER - Record authorization (e.g., timestamp)
  2. CHARGE_RECORDER - Record charge event
  3. RELEASE_RECORDER - Record release
  4. REFUND_IN_ESCROW_RECORDER - Record in-escrow refund
  5. REFUND_POST_ESCROW_RECORDER - Record post-escrow refund
Default: address(0) = no recording (no-op)

Key Methods

authorize()

Authorizes a payment and locks funds in escrow.
function authorize(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 amount,
    address tokenCollector,
    bytes calldata collectorData
) external nonReentrant
Parameters:
  • paymentInfo - Payment info struct (must have operator == address(this), feeReceiver == address(this))
  • amount - Amount to authorize
  • tokenCollector - Address of the token collector contract
  • collectorData - Data to pass to the token collector
Flow:
  1. Check AUTHORIZE_CONDITION (if set)
  2. Validate fee bounds compatibility
  3. Store fees at authorization time (prevents protocol fee changes from breaking capture)
  4. Call escrow.authorize()
  5. Call AUTHORIZE_RECORDER (if set)
  6. Emit AuthorizationCreated
Access: Controlled by AUTHORIZE_CONDITION (default: anyone)
Authorization Expiry: The PaymentInfo struct includes an authorizationExpiry field (from base commerce-payments). Set this to type(uint48).max for no expiry, or specify a timestamp to allow the payer to reclaim funds after expiry. This is useful for subscription-based payments where you want to limit the authorization window.

charge()

Direct charge - collects payment and immediately transfers to receiver (no escrow hold).
function charge(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 amount,
    address tokenCollector,
    bytes calldata collectorData
) external nonReentrant
Parameters:
  • paymentInfo - Payment info struct (must have operator == address(this), feeReceiver == address(this))
  • amount - Amount to charge
  • tokenCollector - Address of the token collector contract
  • collectorData - Data to pass to the token collector
Flow:
  1. Check CHARGE_CONDITION (if set)
  2. Validate fee bounds compatibility
  3. Call escrow.charge() - funds go directly to receiver
  4. Accumulate protocol fees for later distribution
  5. Call CHARGE_RECORDER (if set)
  6. Emit ChargeExecuted
Access: Controlled by CHARGE_CONDITION (default: anyone)
Unlike authorize(), funds go directly to receiver without escrow hold. Refunds are only possible via refundPostEscrow().

release()

Releases funds from escrow to receiver (capture).
function release(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 amount
) external nonReentrant
Parameters:
  • paymentInfo - Payment info struct
  • amount - Amount to release
Flow:
  1. Check RELEASE_CONDITION (if set)
  2. Use fees stored at authorization time
  3. Call escrow.capture()
  4. Accumulate protocol fees for later distribution
  5. Call RELEASE_RECORDER (if set)
  6. Emit ReleaseExecuted
Access: Controlled by RELEASE_CONDITION
Marketplace example: Receiver OR StaticAddressCondition(arbiter) + escrow passed Subscription example: StaticAddressCondition(serviceProvider) DAO example: StaticAddressCondition(daoMultisig)

refundInEscrow()

Refunds payment while still in escrow (partial void).
function refundInEscrow(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint120 amount
) external nonReentrant
Parameters:
  • paymentInfo - Payment info struct
  • amount - Amount to return to payer
Flow:
  1. Check REFUND_IN_ESCROW_CONDITION (if set)
  2. Call escrow.partialVoid() to return funds to payer
  3. Call REFUND_IN_ESCROW_RECORDER (if set)
  4. Emit RefundInEscrowExecuted
Access: Controlled by REFUND_IN_ESCROW_CONDITION
Marketplace example: StaticAddressCondition(arbiter) - disputes Return policy example: Receiver OR StaticAddressCondition(arbiter) DAO example: StaticAddressCondition(daoMultisig) Subscription example: address(0) - no refunds

refundPostEscrow()

Refunds payment after it has been released (captured).
function refundPostEscrow(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 amount,
    address tokenCollector,
    bytes calldata collectorData
) external nonReentrant
Parameters:
  • paymentInfo - Payment info struct
  • amount - Amount to refund to payer
  • tokenCollector - Address of the token collector that will source the refund
  • collectorData - Data to pass to the token collector (e.g., signatures)
Flow:
  1. Check REFUND_POST_ESCROW_CONDITION (if set)
  2. Call escrow.refund() - token collector enforces permission
  3. Call REFUND_POST_ESCROW_RECORDER (if set)
  4. Emit RefundPostEscrowExecuted
Access: Controlled by REFUND_POST_ESCROW_CONDITION. Permission is also enforced by the token collector (e.g., receiver must have approved it, or collectorData contains receiver’s signature).
Marketplace example: StaticAddressCondition(arbiter) - post-delivery disputes Return policy example: Receiver - voluntary returns Most configurations: address(0) - no post-escrow refunds

Fee System (Modular, Additive)

Fees are additive and modular: totalFee = protocolFee + operatorFee

Fee Architecture

// Shared protocol fee config (timelocked, swappable calculator)
ProtocolFeeConfig public immutable PROTOCOL_FEE_CONFIG;

// Per-operator fee calculator (immutable, set at deploy)
IFeeCalculator public immutable FEE_CALCULATOR;

// Fee recipients
address public immutable FEE_RECIPIENT;  // Operator fee recipient
// Protocol fee recipient is on ProtocolFeeConfig

// Fee tracking for accurate distribution
mapping(address token => uint256) public accumulatedProtocolFees;
Example Fee Calculation (Additive): For a 1000 USDC payment:
  • Protocol Fee: 3 bps (0.03%) = 0.30 USDC → goes to protocolFeeRecipient
  • Operator Fee: 2 bps (0.02%) = 0.20 USDC → goes to FEE_RECIPIENT
  • Total Fee: 5 bps (0.05%) = 0.50 USDC
  • Receiver Gets: 999.50 USDC
Fee Locking: Fees are calculated and stored at authorize() time in authorizedFees[hash]. This prevents protocol fee changes from breaking capture of already-authorized payments.
struct AuthorizedFees {
    uint16 totalFeeBps;
    uint16 protocolFeeBps;
}
mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees;
FEE_RECIPIENT can be:
  • Arbiter (marketplace with disputes)
  • Service Provider (subscriptions)
  • Platform Treasury (platform-controlled)
  • DAO Multisig (governance-controlled)

Fee Distribution

Fees accumulate in the operator contract and are distributed via distributeFees(token):
// Anyone can call to distribute fees for a token
operator.distributeFees(usdcAddress);
// Protocol share → protocolFeeRecipient
// Operator share → FEE_RECIPIENT

Protocol Fee Changes (7-day Timelock)

Protocol fee calculator changes require a 7-day timelock on ProtocolFeeConfig:
// Step 1: Queue new calculator
protocolFeeConfig.queueCalculator(newCalculatorAddress);

// Step 2: Wait 7 days

// Step 3: Execute
protocolFeeConfig.executeCalculator();
Protocol fee changes require 7-day timelock. Operator fees are immutable (set at deploy time).

Security Features

  • ReentrancyGuardTransient - EIP-1153 transient storage for gas-efficient reentrancy protection
  • Ownership - Solady’s Ownable with 2-step transfer
  • Timelock - 7-day delay on protocol fee changes (operator fees are immutable)
  • Immutable Core - Escrow, conditions, and fee configuration cannot be changed

RefundRequest

Manages refund requests independent of operator implementation.

Overview

  • Type: Singleton (one per network)
  • Deployment: Direct deployment (no factory)
  • Purpose: Track refund request lifecycle

Request Types

Who can request: Payer, Receiver, OR ArbiterTypical flow:
  1. Payer suspects fraud, requests refund
  2. Arbiter investigates
  3. Arbiter approves or denies request
  4. If approved, arbiter calls operator.refundInEscrow()
Use cases:
  • Buyer remorse
  • Seller fraud
  • Payment error

Request Status States

Key Methods

requestRefund()

Creates a new refund request.
function requestRefund(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint120 amount,
    uint256 nonce
) external
Parameters:
  • paymentInfo - Payment info struct
  • amount - Amount being requested for refund
  • nonce - Record index (from PaymentIndexRecorder) identifying which charge/action
Access Control: Only the payer who made the authorization can request
Each refund request is keyed by (paymentInfoHash, nonce) where nonce is the record index. This allows multiple refund requests per payment (one per charge/action).

updateStatus()

Approve or deny a refund request.
function updateStatus(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 nonce,
    RequestStatus newStatus
) external
Parameters:
  • paymentInfo - Payment info struct
  • nonce - Record index identifying which refund request
  • newStatus - The new status (Approved or Denied)
Access: Receiver can always approve/deny. While in escrow, anyone passing the operator’s REFUND_IN_ESCROW_CONDITION can also approve/deny. Valid transitions:
  • PendingApproved
  • PendingDenied

cancelRefundRequest()

Payer cancels their own request.
function cancelRefundRequest(
    AuthCaptureEscrow.PaymentInfo calldata paymentInfo,
    uint256 nonce
) external
Parameters:
  • paymentInfo - Payment info struct
  • nonce - Record index identifying which refund request
Access: Only the payer who created the request Valid transition:
  • PendingCancelled

Usage Example

// 1. Payer requests refund (nonce 0 = first action on this payment)
await refundRequest.requestRefund(paymentInfo, requestedAmount, 0);
// Status: Pending

// 2. Receiver (or arbiter) reviews and approves
await refundRequest.updateStatus(
  paymentInfo,
  0,  // nonce
  RequestStatus.Approved
);
// Status: Approved

// 3. Execute refund via operator (separate transaction)
await operator.refundInEscrow(paymentInfo, refundAmount);
// Funds returned to payer
RefundRequest is advisory only. Approval does not automatically execute refunds - the authorized party must call the operator’s refund function.

AuthCaptureEscrow

Base escrow contract from commerce-payments library.

Overview

  • Type: Singleton (one per network)
  • Source: commerce-payments (x402r fork)
  • Purpose: Hold ERC-20 tokens during payment lifecycle
  • Access: Operator-based (only registered operators can manage payments)
x402r uses a forked version of commerce-payments with added partial void support for handling partially completed orders.

Payment State Machine

Key Methods

authorize()

Locks tokens in escrow. Called by operator.
function authorize(
    bytes32 paymentId,
    address payer,
    address receiver,
    uint256 amount,
    address token,
    address operator
) external onlyOperator
Requires: Payer has approved escrow contract for amount of token
The base escrow contract uses individual parameters (paymentId, payer, receiver, etc.) while the PaymentOperator wraps them in a PaymentInfo struct. The operator translates between the two formats internally.

release()

Releases tokens to receiver. Called by operator.
function release(
    bytes32 paymentId
) external onlyOperator returns (uint256 amount)
State change: InEscrowReleased

void()

Returns tokens to payer (full refund). Called by operator.
function void(
    bytes32 paymentId
) external onlyOperator
State change: InEscrowSettled Use case: Refund during escrow period

reclaim()

Takes tokens back from receiver to give to payer. Called by operator.
function reclaim(
    bytes32 paymentId,
    address from,
    uint256 amount
) external onlyOperator
State change: ReleasedSettled Use case: Refund after release Requires: Receiver has approved escrow for amount

partialVoid()

Returns partial amount to payer.
function partialVoid(
    bytes32 paymentId,
    uint256 amount
) external onlyOperator
Use case: Partial refunds for partially fulfilled orders

Security Features

  • Operator whitelist - Only registered operators can manage payments
  • Reentrancy protection - All state changes protected
  • Event logging - Complete audit trail

Supporting Contracts

ERC3009PaymentCollector

Payment collection with gasless meta-transactions. Features:
  • ERC-3009 transferWithAuthorization() support
  • Nonce-based replay protection
  • Deadline-based expiry
  • Multicall3 integration
Use case: Allow payers to authorize payments via signed messages instead of direct transactions (gasless UX).

TokenStore

Safe token transfer utilities from commerce-payments. Features:
  • Reentrancy-safe transfers
  • SafeERC20 integration
  • Balance tracking

Contract Addresses

Base Sepolia Testnet

Next Steps