Overview
x402r uses the factory pattern with CREATE2 for gas-efficient, deterministic contract deployments. Factories enable on-demand instance creation with predictable addresses.
Why Factories?
Deterministic Addresses (CREATE2)
Addresses are predictable before deployment, enabling:
Off-chain address generation
Cross-chain address consistency
Contract-to-contract communication without registries
Multiple instances can share immutable configuration:
Lower deployment costs
Consistent behavior across instances
Centralized ownership control
Calling factory with same parameters returns existing contract:
Safe to call multiple times
No duplicate deployments
Automatic deduplication
Singleton conditions deployed once, reused everywhere:
PayerCondition, ReceiverCondition deployed once
All operators share the same condition instances
Minimal storage overhead
Payment Operator Factory
Deploys PaymentOperator instances with deterministic addresses.
Contract Address
All factories use unified CREATE3 addresses (same on every chain).
PaymentOperatorFactory: 0x4D9BC2Ba2D0d9AFb6B63E3afBbfC95143E6E8Da9
Configuration Structure
struct OperatorConfig {
address feeRecipient; // Who receives operator fees
address feeCalculator; // Operator fee calculator (IFeeCalculator)
address authorizeCondition;
address authorizeRecorder;
address chargeCondition;
address chargeRecorder;
address releaseCondition;
address releaseRecorder;
address refundInEscrowCondition;
address refundInEscrowRecorder;
address refundPostEscrowCondition;
address refundPostEscrowRecorder;
}
Deployment Method
function deployOperator (
OperatorConfig calldata config
) external returns ( address operator )
Parameters (in config):
feeRecipient - Who receives operator fees (arbiter, service provider, treasury, etc.)
authorizeCondition through refundPostEscrowRecorder - 10-slot configuration
Note: maxFeeBps and protocolFeePct are set at factory level (shared across all operators)
Returns: Address of deployed operator (or existing if already deployed)
Address Prediction
Predict the operator address before deployment:
function computeAddress (
OperatorConfig calldata config
) external view returns ( address )
Usage:
const config = {
feeRecipient: arbiterAddress ,
authorizeCondition: ALWAYS_TRUE_CONDITION ,
// ... rest of config
};
const predictedAddress = await factory . computeAddress ( config );
console . log ( "Operator will be deployed at:" , predictedAddress );
// Deploy - will use same address
const deployedAddress = await factory . deployOperator ( config );
assert ( deployedAddress === predictedAddress );
Example Deployment
Marketplace Operator
import { createWalletClient , http , getContract , zeroAddress } from 'viem' ;
import { base } from 'viem/chains' ;
import { privateKeyToAccount } from 'viem/accounts' ;
import { PaymentOperatorABI } from '@x402r/core/abis' ;
const FACTORY_ADDRESS = '0x...' ; // Replace with actual factory address
const account = privateKeyToAccount ( '0x...' );
const walletClient = createWalletClient ({
account ,
chain: base ,
transport: http ()
});
const factory = getContract ({
address: FACTORY_ADDRESS ,
abi: PaymentOperatorABI ,
client: walletClient
});
// Deploy condition for arbiter via factory
const arbiterConditionHash = await staticAddressConditionFactory . write . deploy ([ arbiterAddress ]);
const arbiterConditionAddress = /* get from receipt */ ;
// Deploy release condition: arbiter AND escrow period passed
const releaseConditionHash = await andConditionFactory . write . deploy ([
[ arbiterConditionAddress , escrowPeriodAddress ]
]);
const releaseConditionAddress = /* get from receipt */ ;
// Define configuration
const config = {
feeRecipient: arbiterAddress , // Arbiter earns fees
feeCalculator: feeCalculatorAddress ,
authorizeCondition: ALWAYS_TRUE_CONDITION ,
authorizeRecorder: escrowPeriodAddress ,
chargeCondition: zeroAddress , // Default allow
chargeRecorder: zeroAddress , // No recording
releaseCondition: releaseConditionAddress ,
releaseRecorder: zeroAddress ,
refundInEscrowCondition: arbiterConditionAddress ,
refundInEscrowRecorder: zeroAddress ,
refundPostEscrowCondition: arbiterConditionAddress ,
refundPostEscrowRecorder: zeroAddress
};
// Deploy operator
const hash = await factory . write . deployOperator ([ config ]);
const receipt = await walletClient . waitForTransactionReceipt ({ hash });
const operatorAddress = receipt . logs [ 0 ]. address ;
console . log ( "Deployed marketplace operator at:" , operatorAddress );
Subscription Operator
// Deploy condition for service provider
const providerCondition = await new StaticAddressCondition ( serviceProviderAddress );
const config = {
feeRecipient: serviceProviderAddress , // Provider earns fees
authorizeCondition: PAYER_CONDITION ,
authorizeRecorder: zeroAddress ,
chargeCondition: providerCondition . address ,
chargeRecorder: zeroAddress ,
releaseCondition: providerCondition . address ,
releaseRecorder: zeroAddress ,
refundInEscrowCondition: zeroAddress , // No refunds
refundInEscrowRecorder: zeroAddress ,
refundPostEscrowCondition: zeroAddress ,
refundPostEscrowRecorder: zeroAddress
};
const hash = await factory . write . deployOperator ([ config ]);
console . log ( "Deployed subscription operator, tx:" , hash );
If you call deployOperator() with the same configuration twice, the factory returns the existing operator address without deploying a new contract.
Escrow Period Factory
Deploys EscrowPeriod contracts - combined recorder and condition for time-based release logic.
Contract Address
EscrowPeriodFactory: 0x15DB06aADEB3a39D47756Bf864a173cc48bafe24
Deployment Method
function deploy (
uint256 escrowPeriod ,
bytes32 authorizedCodehash
) external returns ( address escrowPeriodAddr )
Parameters:
escrowPeriod - Duration in seconds (e.g., 7 * 24 * 60 * 60 for 7 days)
authorizedCodehash - Runtime codehash of authorized caller (bytes32(0) = operator-only)
Returns: Address of deployed EscrowPeriod contract
How It Works
The factory deploys a single EscrowPeriod contract that:
Extends AuthorizationTimeRecorder (implements IRecorder)
Implements ICondition
Records authorization timestamp when used as recorder
Checks if escrow period has passed when used as condition
Architecture:
Use the SAME EscrowPeriod address for both AUTHORIZE_RECORDER and RELEASE_CONDITION slots on the operator. For freeze functionality, deploy a separate Freeze condition and compose via AndCondition([escrowPeriod, freeze]).
Example Deployment
import { getContract , zeroHash } from 'viem' ;
const factory = getContract ({
address: ESCROW_PERIOD_FACTORY_ADDRESS ,
abi: EscrowPeriodFactory . abi ,
client: walletClient
});
// Deploy 7-day escrow (operator-only access)
const hash = await factory . write . deploy ([
7 * 24 * 60 * 60 , // 7 days
zeroHash // bytes32(0) = operator-only
]);
const receipt = await walletClient . waitForTransactionReceipt ({ hash });
const escrowPeriodAddress = receipt . logs [ 0 ]. address ;
console . log ( "EscrowPeriod:" , escrowPeriodAddress );
// Use SAME address for both recorder and condition
const config = {
authorizeCondition: ALWAYS_TRUE_CONDITION ,
authorizeRecorder: escrowPeriodAddress , // Record auth time
// ...
releaseCondition: escrowPeriodAddress , // Check escrow passed
releaseRecorder: zeroAddress , // No additional recording needed
// ...
};
Common Escrow Periods
Use Case Recommended Period Digital goods / services 1-3 days Physical goods (domestic) 7-14 days Physical goods (international) 14-30 days Large purchases / services 30-60 days No escrow (instant release) 0 (use different condition)
Freeze Factory
Deploys Freeze condition contracts that block release when a payment is frozen.
Contract Address
FreezeFactory: 0xdf129EFFE040c3403aca597c0F0bb704859a78Fd
Deployment Method
function deploy (
address freezeCondition ,
address unfreezeCondition ,
uint256 freezeDuration ,
address escrowPeriodContract
) external returns ( address freezeAddr )
Parameters:
freezeCondition - ICondition that authorizes freeze calls (e.g., PayerCondition)
unfreezeCondition - ICondition that authorizes unfreeze calls (e.g., PayerCondition, ArbiterCondition)
freezeDuration - How long freeze lasts in seconds (0 = permanent until unfrozen)
escrowPeriodContract - Address of EscrowPeriod contract (address(0) = freeze unconstrained by time)
Returns: Address of deployed Freeze condition
Full Freeze Deployment Example
// Step 1: Deploy EscrowPeriod (7 days, operator-only recording)
const escrowPeriod = await escrowPeriodFactory . write . deploy ([
7 * 24 * 60 * 60 , // 7 days
zeroHash // bytes32(0) = operator-only
]);
// Step 2: Deploy Freeze condition (payer freeze/unfreeze, 3-day duration, linked to EscrowPeriod)
const freeze = await freezeFactory . write . deploy ([
PAYER_CONDITION , // Only payer can freeze
PAYER_CONDITION , // Only payer can unfreeze (or use ARBITER_CONDITION)
3 * 24 * 60 * 60 , // 3 days (auto-expires)
escrowPeriod // Link to EscrowPeriod (or zeroAddress for unconstrained)
]);
// Step 3: Compose with EscrowPeriod for release condition
const releaseCondition = await andConditionFactory . write . deploy ([
[ escrowPeriod , freeze ]
]);
// Use in operator config
const config = {
// ...
releaseCondition: releaseCondition ,
// ...
};
Condition Singletons
Use these pre-deployed condition contracts:
Condition Address (all chains) Description PayerCondition 0x33F5F1154A02d0839266EFd23Fd3b85a3505bB4BOnly payer can call ReceiverCondition 0xF41974A853940Ff4c18d46B6565f973c1180E171Only receiver can call AlwaysTrueCondition 0xb295df7E7f786fd84D614AB26b1f2e86026C3483Anyone can call
Example Deployments
Payer Freeze
Receiver Freeze
Payer OR Receiver
Arbiter Controlled
Payer can freeze, arbiter can unfreeze (or auto-expires after 3 days): const freeze = await freezeFactory . deploy (
PAYER_CONDITION , // Only payer can freeze
ARBITER_CONDITION , // Only arbiter can unfreeze
3 * 24 * 60 * 60 , // 3 days (auto-expires)
escrowPeriodAddress // Link to EscrowPeriod
);
Receiver can freeze, arbiter can unfreeze (or auto-expires after 5 days): const freeze = await freezeFactory . deploy (
RECEIVER_CONDITION , // Only receiver can freeze
ARBITER_CONDITION , // Only arbiter can unfreeze
5 * 24 * 60 * 60 , // 5 days (auto-expires)
escrowPeriodAddress // Link to EscrowPeriod
);
Either payer or receiver can freeze, both can unfreeze: // First deploy OrCondition
const orCondition = await new OrCondition ([
PAYER_CONDITION ,
RECEIVER_CONDITION
]);
const freeze = await freezeFactory . deploy (
orCondition . address , // Payer OR Receiver can freeze
orCondition . address , // Payer OR Receiver can unfreeze
3 * 24 * 60 * 60 , // 3 days
escrowPeriodAddress // Link to EscrowPeriod
);
Only arbiter can freeze/unfreeze: const freeze = await freezeFactory . deploy (
ARBITER_CONDITION , // Only arbiter can freeze
ARBITER_CONDITION , // Only arbiter can unfreeze
7 * 24 * 60 * 60 , // 7 days
escrowPeriodAddress // Link to EscrowPeriod
);
Freeze Duration Guidelines
Duration Use Case 1 day Quick investigation period 3 days Standard fraud check window 5-7 days Extended investigation 14+ days Complex dispute resolution
Freeze duration should balance payer protection with receiver UX. Too long and receivers may avoid the platform. Too short and payers can’t adequately investigate.
Factory Ownership
All factories are owned by a multisig wallet for security.
Owner Capabilities
Factory owners can:
Update factory configuration (if mutable fields exist)
Rescue stuck ETH (via rescueETH())
Transfer ownership (2-step process)
Factory owners cannot:
Modify deployed instances
Pause or stop operations
Access funds in deployed operators
Ownership Transfer
// Current owner initiates
factory. requestOwnershipHandover (newOwner);
// New owner completes (within 48 hours)
factory. completeOwnershipHandover ();
Gas Costs
Approximate gas costs for factory deployments (Base Sepolia):
Operation Gas Cost USD (at 0.1 gwei, $3000 ETH) Deploy PaymentOperator ~2.5M gas ~$0.75 Deploy EscrowPeriod (condition + recorder) ~1.8M gas ~$0.54 Deploy Freeze ~1.0M gas ~$0.30 Predict address (view call) 0 gas $0.00
Use predict*Address() functions before deploying to verify addresses off-chain and avoid unnecessary deployments.
CREATE2 Details
Salt Generation
Each factory uses different salt strategies:
PaymentOperatorFactory:
bytes32 key = keccak256 ( abi . encodePacked (
config.feeRecipient,
config.feeCalculator,
config.authorizeCondition,
config.authorizeRecorder,
config.chargeCondition,
config.chargeRecorder,
config.releaseCondition,
config.releaseRecorder,
config.refundInEscrowCondition,
config.refundInEscrowRecorder,
config.refundPostEscrowCondition,
config.refundPostEscrowRecorder
));
EscrowPeriodFactory:
bytes32 key = keccak256 ( abi . encodePacked (escrowPeriod, authorizedCodehash));
bytes32 salt = keccak256 ( abi . encodePacked ( "escrowPeriod" , key));
FreezeFactory:
bytes32 key = keccak256 ( abi . encodePacked (freezeCondition, unfreezeCondition, freezeDuration, escrowPeriodContract));
bytes32 salt = keccak256 ( abi . encodePacked ( "freeze" , key));
Cross-Chain Addresses
Same configuration on different chains = same address:
import { createPublicClient } from 'viem' ;
import { baseSepolia , optimismSepolia } from 'viem/chains' ;
// Deploy on Base Sepolia
const baseFactory = getContract ({
address: FACTORY_ADDRESS ,
abi: PaymentOperatorFactory . abi ,
client: baseWalletClient
});
const baseHash = await baseFactory . write . deployOperator ([ config ]);
const baseReceipt = await baseWalletClient . waitForTransactionReceipt ({ hash: baseHash });
const addressBaseSepolia = baseReceipt . logs [ 0 ]. address ;
// Deploy on Optimism Sepolia with identical params
const opFactory = getContract ({
address: FACTORY_ADDRESS ,
abi: PaymentOperatorFactory . abi ,
client: opWalletClient
});
const opHash = await opFactory . write . deployOperator ([ config ]);
const opReceipt = await opWalletClient . waitForTransactionReceipt ({ hash: opHash });
const addressOptimismSepolia = opReceipt . logs [ 0 ]. address ;
// Addresses are identical!
console . assert ( addressBaseSepolia === addressOptimismSepolia );
This enables:
Consistent addressing across chains
Simplified multi-chain integrations
Predictable contract locations
Best Practices
1. Predict Before Deploy
Always verify predicted address before deployment:
const predicted = await factory . read . computeAddress ([ config ]);
const hash = await factory . write . deployOperator ([ config ]);
const receipt = await walletClient . waitForTransactionReceipt ({ hash });
// deployed address matches predicted
2. Reuse Condition Singletons
Don’t deploy new PayerCondition/ReceiverCondition - use existing singletons:
// ✅ Good: Reuse singleton
const config = {
authorizeCondition: PAYER_CONDITION , // Pre-deployed singleton
// ...
};
// ❌ Bad: Deploy new instance
const payerCondition = await new PayerCondition ();
const config = {
authorizeCondition: payerCondition . address , // Wastes gas
// ...
};
3. Test Configuration First
Deploy on testnet with same configuration before mainnet:
// Test on Base Sepolia first
const testHash = await testnetFactory . write . deployOperator ([ config ]);
// ... test thoroughly ...
// Deploy on mainnet with identical config (same address!)
const mainnetHash = await mainnetFactory . write . deployOperator ([ config ]);
4. Document Your Config
Keep a record of your deployed configurations:
const deployments = {
"marketplace-arbiter" : {
arbiter: "0x..." ,
operator: "0x..." ,
escrowPeriod: 7 * 24 * 60 * 60 ,
freezeDuration: 3 * 24 * 60 * 60 ,
maxFeeBps: 5 ,
protocolFeePct: 25 ,
network: "base-sepolia"
}
};
Next Steps
Conditions Learn about the pluggable condition system.
Examples See real-world configuration examples.
Deploy an Operator Use the SDK’s deployMarketplaceOperator() for simplified deployment.
SDK Installation Install the SDK packages.