Skip to main content

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?

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

Base Sepolia: 0xe01CEd771A30A23a7A4C9c1db604C74D4Dc4ebe8 Base Mainnet: 0xA50F51254E8B08899EdB76Bd24b4DC6A61ba7dE7

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

Base Sepolia: 0x206D4DbB6E7b876e4B5EFAAD2a04e7d7813FB6ba Base Mainnet: 0x2714EA3e839Ac50F52B2e2a5788F614cACeE5316

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 CaseRecommended Period
Digital goods / services1-3 days
Physical goods (domestic)7-14 days
Physical goods (international)14-30 days
Large purchases / services30-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

Base Sepolia: 0x199fed16577773Bb6b2D76CC3cD1c76c22D28835 Base Mainnet: 0xCAEd9474c06bf9139AC36C874dED838e1Bcb9310

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:
ConditionAddress (Base Sepolia)Address (Base Mainnet)Description
PayerCondition0xBAF68176FF94CAdD403EF7FbB776bbca548AC09D0xb33D6502EdBbC47201cd1E53C49d703EC0a660b8Only payer can call
ReceiverCondition0x12EDefd4549c53497689067f165c0f101796Eb6D0xed02d3E5167BCc9582D851885A89b050AB816a56Only receiver can call
AlwaysTrueCondition0x785cC83DEa3d46D5509f3bf7496EAb26D42EE6100xc9BbA6A2CF9838e7Dd8c19BC8B3BAC620B9D8178Anyone can call

Example Deployments

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

Freeze Duration Guidelines

DurationUse Case
1 dayQuick investigation period
3 daysStandard fraud check window
5-7 daysExtended investigation
14+ daysComplex 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):
OperationGas CostUSD (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