These contracts have been deployed but have not been audited. Users should exercise extreme caution and conduct thorough due diligence before interacting with these contracts. The developers assume no liability for any losses incurred from using these contracts.
For auditors and developers:
| Document | Description |
|---|---|
| AUDIT_PREP.md | 📦 Complete audit preparation package (beta release) |
| SECURITY_REVIEW.md | 📋 Comprehensive security review documentation |
| SECURITY.md | 🔒 Security overview and threat model |
| OPERATOR_SECURITY.md | 🛡️ Operator-specific security considerations |
| TOKENS.md | 🪙 Token compatibility and handling |
| FUZZING.md | 🔬 Fuzzing methodology and invariants |
| DEPLOYMENT.md | 🚀 Contract deployment guide |
| DEPLOYMENT_CHECKLIST.md | ✅ Production deployment checklist |
| MONITORING.md | 📈 Event monitoring and indexing |
| GAS_BREAKDOWN.md | 📊 Detailed gas cost analysis |
# Clone and build
git clone --recursive https://github.com/x402r/x402r-contracts.git
cd x402r-contracts
forge build
# Run tests
forge test
# Check formatting
forge fmt --checkimport {AuthCaptureEscrow} from "commerce-payments/AuthCaptureEscrow.sol";
import {ProtocolFeeConfig} from "src/plugins/fees/ProtocolFeeConfig.sol";
import {PaymentOperatorFactory} from "src/operator/PaymentOperatorFactory.sol";
import {PaymentOperator} from "src/operator/payment/PaymentOperator.sol";
// 1. Deploy infrastructure
AuthCaptureEscrow escrow = new AuthCaptureEscrow();
ProtocolFeeConfig feeConfig = new ProtocolFeeConfig(address(0), feeReceiver, owner);
PaymentOperatorFactory factory = new PaymentOperatorFactory(address(escrow), address(feeConfig));
// 2. Configure and deploy operator
PaymentOperatorFactory.OperatorConfig memory config = PaymentOperatorFactory.OperatorConfig({
feeReceiver: feeReceiver,
feeCalculator: address(0), // No operator fee
authorizePreActionCondition: address(0), // Anyone can authorize
authorizePostActionHook: address(0), // No post-action hook
chargePreActionCondition: address(0),
chargePostActionHook: address(0),
capturePreActionCondition: address(0), // Anyone can capture
capturePostActionHook: address(0),
voidPreActionCondition: address(0),
voidPostActionHook: address(0),
refundPreActionCondition: address(0),
refundPostActionHook: address(0)
});
address operator = factory.deployOperator(config);
// 3. Use the operator
PaymentOperator op = PaymentOperator(operator);
op.authorize(paymentInfo, amount, tokenCollector, "");
op.capture(paymentInfo, amount);Source of truth: @x402r/sdk
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER INTERACTIONS │
├─────────────────────────────────────────────────────────────────────────────┤
│ Payer Receiver Designated Address (operator-config) │
│ │ │ │ │
│ │ authorize() │ capture() │ capture() (if configured)│
│ │ freeze() │ charge() │ void() (if configured)│
│ │ requestRefund() │ requestRefund() │ refund() (if configured)│
└────┼───────────────────┼─────────────────────────┼───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ PAYMENT OPERATOR │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Condition Slots (before action) Hook Slots (after action) │ │
│ │ ───────────────────────────────── ───────────────────────────── │ │
│ │ AUTHORIZE_PRE_ACTION_CONDITION ──────► AUTHORIZE_POST_ACTION_HOOK │ │
│ │ CHARGE_PRE_ACTION_CONDITION ─────────► CHARGE_POST_ACTION_HOOK │ │
│ │ CAPTURE_PRE_ACTION_CONDITION ────────► CAPTURE_POST_ACTION_HOOK │ │
│ │ VOID_PRE_ACTION_CONDITION ───────────► VOID_POST_ACTION_HOOK │ │
│ │ REFUND_PRE_ACTION_CONDITION ─────────► REFUND_POST_ACTION_HOOK │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ Owner Functions (7-day Timelock): │ │
│ - queueFeesEnabled() │ │
│ - executeFeesEnabled() │ │
│ - cancelFeesEnabled() │ │
└────────────────────────────────────┼────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTH CAPTURE ESCROW │
│ (Base Commerce Payments) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Payment State Machine: │ │
│ │ │ │
│ │ NonExistent ──authorize()──► InEscrow ──capture()──► Captured │ │
│ │ │ │ │ │
│ │ void() │ refund() │ │ │
│ │ reclaim() │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Settled │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Conditions are composable plugins that control access to operator actions:
┌──────────────────────────────────────────────────────────────────┐
│ CONDITION COMBINATORS │
├──────────────────────────────────────────────────────────────────┤
│ │
│ AndCondition([A, B, C]) ──► A && B && C │
│ OrCondition([A, B]) ──► A || B │
│ NotCondition(A) ──► !A │
│ │
│ Example: Capture requires (Receiver OR DesignatedAddr) AND EscrowPassed│
│ │
│ OrCondition([ │
│ ReceiverCondition, │
│ StaticAddressCondition(designatedAddr) │
│ ]) │
│ └──► AndCondition([ │
│ <above>, │
│ EscrowPeriod │
│ ]) │
│ │
└──────────────────────────────────────────────────────────────────┘
Freeze and EscrowPeriod are now separate, composable modules:
- EscrowPeriod: ICondition that blocks capture during the escrow period
- Freeze: Standalone ICondition with
freeze()/unfreeze()methods
Compose them via AndCondition([escrowPeriod, freeze]) when you want both behaviors.
Timeline:
├─────────────── ESCROW_PERIOD (e.g., 7 days) ───────────────┼──── Post-Escrow ────►
│ │
│ [Payer can freeze via Freeze contract] │ [Capture allowed]
│ [Capture blocked by EscrowPeriod] │ [Freeze blocked]
│ │
│ Freeze.freeze() ──► PaymentFrozen │
│ Freeze.unfreeze() ──► PaymentUnfrozen │
│ │
└─────────────────────────────────────────────────────────────┴─────────────────────►
MEV Protection: Payers should freeze EARLY, not at deadline.
Use private mempool (Flashbots Protect) if freezing near expiry.
┌─────────────────────────┐
│ PaymentOperatorFactory │◄─── Owner (Multisig in production)
└────────────┬────────────┘
│ deploys
▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ PaymentOperator │─────►│ AuthCaptureEscrow │
│ (per-config instance) │ │ (shared singleton) │
└────────────┬────────────┘ └─────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────┐
│ PLUGGABLE CONDITIONS │
├─────────────────────────────────────────────────────────────┤
│ Access Conditions: │ Time/State Conditions: │
│ - PayerCondition │ - EscrowPeriod │
│ - ReceiverCondition │ - Freeze │
│ - StaticAddressCondition │ │
│ - AlwaysTrueCondition │ │
├─────────────────────────────────────────────────────────────┤
│ Combinators: │ Hooks (Optional): │
│ - AndCondition │ - PaymentIndexRecorderHook │
│ - OrCondition │ - HookCombinator │
│ - NotCondition │ │
├─────────────────────────────────────────────────────────────┤
│ Auxiliary: │ │
│ - RefundRequest │ │
│ - FreezeFactory │ │
└─────────────────────────────────────────────────────────────┘
| Role | Capabilities |
|---|---|
| Payer | authorize(), freeze(), unfreeze(), requestRefund(), cancelRefundRequest(), void() (after expiry) |
| Receiver | capture() (if condition allows), charge() |
| Designated Address | Per operator configuration - can include void(), refund(), capture() (arbiter, service provider, DAO, etc.) |
| Owner | queueFeesEnabled(), executeFeesEnabled(), cancelFeesEnabled(), rescueETH() |
Authorization Expiry: The PaymentInfo struct includes an authorizationExpiry field from base commerce-payments. Payers can set this to limit how long receivers can charge funds. Set to type(uint48).max for no expiry, or specify a timestamp for time-limited authorizations (useful for subscriptions). After expiry, payers can reclaim unused funds via void().
| Feature | Implementation |
|---|---|
| Reentrancy Protection | ReentrancyGuardTransient on escrow |
| CEI Pattern | All functions: Checks → Effects → Interactions |
| 2-Step Ownership | Solady's requestOwnershipHandover() + completeOwnershipHandover() |
| 7-day Timelock | Fee changes require queue → wait → execute |
| Multisig Requirement | Owner must be Gnosis Safe in production |
| Incident Response | See SECURITY.md |
Typical gas costs for common operations (measured with via-IR optimization and reentrancy protection):
| Operation | Gas Cost | With Indexing | Notes |
|---|---|---|---|
| Payment Authorization | ~231,000 | ~273,000 | Minimal storage (fees only) |
| Capture | ~65,000 | ~65,000 | Capture after escrow period |
| Direct Charge | ~285,000 | ~327,000 | Immediate capture (no escrow) |
| Void | ~45,000 | ~45,000 | Cancel authorization, return funds to payer |
| Refund | ~50,000 | ~50,000 | Return captured funds via ReceiverRefundCollector |
| Freeze Payment | ~50,000 | ~50,000 | Payer freezes during escrow |
Implementation: Payment indexing is optional via PaymentIndexRecorderHook. Deploy with indexing for on-chain queries (+42k gas first, +22k subsequent) or skip for gas savings when using external indexers (The Graph).
| Conditions | Gas Cost | Scaling |
|---|---|---|
| 1 condition | ~50,000 | Single check |
| 2 conditions (AND) | ~75,000 | Linear |
| 5 conditions (AND) | ~150,000 | Linear |
| 10 conditions (MAX) | ~479,000 | Near-linear |
Recommended Complexity: Keep combinator depth ≤ 5 for optimal gas efficiency.
| Test | Gas Cost | Result |
|---|---|---|
| Fee-on-transfer detection | ~473,000 | ✅ Rejects (strict balance check) |
| Rebasing token detection | ~485,000 | ✅ Detects accounting mismatch |
| Standard ERC20 | ~473,000 | ✅ Accepts |
Token Safety: Protocol intentionally rejects fee-on-transfer and rebasing tokens to prevent accounting errors. See TOKENS.md for details.
Already Implemented:
- ✅ Solady library (assembly-optimized)
- ✅ Via-IR compilation
- ✅ ReentrancyGuardTransient (transient storage, EIP-1153)
- ✅ Immutable variables
- ✅ Packed storage layout
- ✅ Custom errors
Status: Gas costs are excellent for the security features provided. See GAS_OPTIMIZATION_REPORT.md for detailed analysis.
Estimated transaction costs on different networks (at typical gas prices):
| Network | Gas Price | Authorization | Capture | Charge |
|---|---|---|---|---|
| Base Mainnet | 0.001 gwei | ~$0.0002 | ~$0.0001 | ~$0.0003 |
| Base Sepolia | Free | Free | Free | Free |
| Ethereum L1 | 30 gwei | ~$6.93 | ~$1.95 | ~$8.55 |
Recommendation: Deploy on Base for low-cost transactions (100-1000x cheaper than Ethereum L1).
| Protocol | Authorization | Capture | Notes |
|---|---|---|---|
| x402r | ~231k | ~65k | Minimal storage + reentrancy protection + flexible conditions |
| Gnosis Safe | ~300k | ~250k | Multi-sig overhead, less flexible |
| Uniswap Permit2 | ~150k | ~100k | Signature-based, no escrow |
| Superfluid | ~400k | Streaming | Continuous flow, different model |
Trade-off: Competitive gas costs with significantly better security and flexibility ✓
Optional Feature: Deploy PaymentIndexRecorderHook to enable on-chain payment lookups.
| Query Type | Gas Cost | Notes |
|---|---|---|
| Get 10 payments | ~20,000 | Paginated query (hash + amount) |
| Get 50 payments | ~82,000 | Scales linearly with count |
| Get single payment | ~2,000 | Direct index access |
API: PaymentIndexRecorderHook.getPayerPayments(address, offset, count) returns (bytes32[] hashes, uint256 total):
hashes: Array of payment hashes for escrow lookuptotal: Total number of payments for this address
Note: For timestamps, use EscrowPeriod which tracks authorization times. For amounts, query the escrow's paymentState(hash).capturableAmount.
- With indexing: On-chain queries available, no external indexer needed
- Without indexing: Use external indexer (The Graph) for lower gas costs
- Fully on-chain, decentralized when enabled
- Bounded gas cost (never unbounded array returns)
Gas costs are continuously monitored in CI/CD:
- Baseline: Updated on every merge to
main - Regression Detection: PRs fail if gas increases > 5%
- Nightly Benchmarks: Tracked in
.gas-snapshot
See CI_CD_GUIDE.md for details.
The commerce-payments contracts provide refund functionality for Base Commerce Payments authorizations:
-
PaymentOperator:
src/commerce-payments/operator/arbitration/PaymentOperator.sol- Generic operator contract with pluggable conditions for flexible authorization logic. Supports marketplace, subscriptions, streaming, DAO governance, and custom payment flows.
-
RefundRequest:
src/commerce-payments/requests/refund/RefundRequest.sol- Contract for managing refund requests for Base Commerce Payments authorizations. Users can create refund requests, cancel their own pending requests, and merchants or arbiters can approve or deny them based on capture status.
Freeze is a standalone ICondition contract with freeze()/unfreeze() methods. It's separate from EscrowPeriod for better composability.
Deploy via FreezeFactory:
// Deploy Freeze with freeze/unfreeze conditions and optional EscrowPeriod constraint
address freeze = freezeFactory.deploy(
freezeCondition, // ICondition - who can freeze (e.g., PayerCondition)
unfreezeCondition, // ICondition - who can unfreeze (e.g., PayerCondition)
freezeDuration, // uint256 - how long freeze lasts (0 = permanent until unfrozen)
escrowPeriodContract // address(0) = unconstrained, or EscrowPeriod address
);Freeze/Unfreeze conditions determine who can freeze/unfreeze using ICondition contracts:
| Condition | Description |
|---|---|
PayerCondition |
Allows the payment's payer |
ReceiverCondition |
Allows the payment's receiver |
StaticAddressCondition(addr) |
Allows a designated address (arbiter, service provider, DAO, platform, etc.) |
AlwaysTrueCondition |
Allows anyone |
Example:
// 1. Deploy EscrowPeriod (7 days, operator-only recording)
address escrowPeriod = escrowPeriodFactory.deploy(7 days, bytes32(0));
// 2. Deploy Freeze (payer can freeze/unfreeze, 3-day duration, constrained to escrow period)
address freeze = freezeFactory.deploy(payerCondition, payerCondition, 3 days, escrowPeriod);
// 3. Compose for capture condition: must pass both escrow period AND not be frozen
address capturePreActionCondition = address(new AndCondition([ICondition(escrowPeriod), ICondition(freeze)]));Composition Patterns:
- Escrow period only:
capturePreActionCondition = escrowPeriod - Freeze only:
capturePreActionCondition = freeze - Both:
capturePreActionCondition = AndCondition([escrowPeriod, freeze])
The PaymentOperatorFactory provides a single generic deployOperator(OperatorConfig) method. There are no convenience methods - users must construct the full OperatorConfig struct:
struct OperatorConfig {
address feeReceiver;
address feeCalculator;
address authorizePreActionCondition;
address authorizePostActionHook;
address chargePreActionCondition;
address chargePostActionHook;
address capturePreActionCondition;
address capturePostActionHook;
address voidPreActionCondition;
address voidPostActionHook;
address refundPreActionCondition;
address refundPostActionHook;
}Example: Deploy a marketplace operator with arbiter
// Deploy arbiter condition via factory (deterministic address, idempotent)
address arbiterCondition = staticAddressConditionFactory.deploy(arbiterAddress);
PaymentOperatorFactory.OperatorConfig memory config = PaymentOperatorFactory.OperatorConfig({
feeReceiver: arbiterAddress, // Arbiter earns fees for dispute resolution
feeCalculator: address(feeCalc), // Operator fee calculator
authorizePreActionCondition: address(0), // Anyone can authorize
authorizePostActionHook: address(0), // No post-action work
chargePreActionCondition: address(receiverCondition), // Only receiver can charge
chargePostActionHook: address(0),
capturePreActionCondition: address(escrowPeriodCondition), // Capture allowed only after escrow period
capturePostActionHook: address(escrowPeriodHook), // Record timestamp
voidPreActionCondition: arbiterCondition, // Only arbiter can void
voidPostActionHook: address(0),
refundPreActionCondition: arbiterCondition, // Only arbiter for post-capture refunds
refundPostActionHook: address(0)
});
address operator = factory.deployOperator(config);Note: address(0) for a condition means "allow all" (no restriction). address(0) for a hook means "no-op" (no post-action work).
PaymentIndexRecorderHook provides on-chain payment lookups by payer/receiver. Deploy once and share across operators:
// Deploy indexer (optional)
PaymentIndexRecorderHook indexHook = new PaymentIndexRecorderHook(address(escrow), bytes32(0));
// Option 1: Enable indexing
PaymentOperatorFactory.OperatorConfig memory config = PaymentOperatorFactory.OperatorConfig({
// ...
authorizePostActionHook: address(indexHook), // Index on authorize
chargePostActionHook: address(indexHook), // Index on charge
// ...
});
// Option 2: Skip indexing (lower gas, use The Graph instead)
PaymentOperatorFactory.OperatorConfig memory config = PaymentOperatorFactory.OperatorConfig({
// ...
authorizePostActionHook: address(0), // No indexing
chargePostActionHook: address(0), // No indexing
// ...
});
// Query payments (requires indexing enabled)
(bytes32[] memory hashes, uint256 total) = indexHook.getPayerPayments(payer, 0, 10);
// hashes[0] - Payment hash for escrow lookup
// For amounts: escrow.paymentState(hashes[0]).capturableAmount
// For timestamps: use EscrowPeriod.authorizationTimes(hash)Benefits:
- Efficient Storage: Stores only payment hashes (minimal gas cost)
- Gas Savings: ~55k per authorization when indexing disabled
- Flexibility: Deploy with or without on-chain queries
- Composability: Combine with other hooks via
HookCombinator - No Duplication: Use
EscrowPeriodfor timestamps, escrow for amounts
When to use indexing:
- ✅ Need on-chain payment history queries
- ✅ Building fully decentralized applications
- ✅ Want to avoid external dependencies
When to skip indexing:
- ✅ Using external indexer (The Graph, Dune)
- ✅ Optimizing for minimum gas costs
- ✅ Don't need on-chain payment history
All deployment scripts use factory contracts that provide:
- Deterministic addresses (CREATE2): Same inputs = same address, even if not yet deployed
- Idempotent deployment: Safe to call multiple times, returns existing if already deployed
- Shared configuration: Escrow, protocol fees set once in factory
- Centralized owner control: Factory owner controls all deployed instances
Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.
Foundry consists of:
- Forge: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- Cast: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Local Ethereum node, akin to Ganache, Hardhat Network.
- Chisel: Fast, utilitarian, and verbose solidity REPL.
-
Copy the example environment file:
cp .env.example .env
-
Edit
.envand add your API keys:ETHERSCAN_API_KEY=your_basescan_api_key_here PRIVATE_KEY=your_private_key_here
Get your Basescan API key from: https://basescan.org/myapikey
-
Load environment variables before running commands:
source .envOr export them manually:
export ETHERSCAN_API_KEY=your_api_key export PRIVATE_KEY=your_private_key
$ forge build$ forge test$ forge fmt$ forge snapshot$ anvilDeploy contracts using the deployment scripts. The --verify flag will automatically verify contracts on Basescan using the ETHERSCAN_API_KEY from your .env file.
$ cast <subcommand>$ forge --help
$ anvil --help
$ cast --help