-
Notifications
You must be signed in to change notification settings - Fork 978
Description
x402r: Refundable Payments for x402
A secure, transparent refund system for x402 payments. Enable dispute resolution and buyer protection with minimal code changes.
Overview
When agents or users pay for services via x402, things can go wrong:
- An API returns empty data after payment
- A purchased item arrives damaged or wrong
- A merchant becomes unresponsive
x402r solves this by routing payments through escrow contracts, enabling trustless refunds and dispute resolution.
How It Works
┌─────────────────────────────────────────────────────────────────────────┐
│ STANDARD x402 PAYMENT │
│ │
│ Client ──────────────────────────────────────────────► Merchant │
│ pays directly (no recourse) │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ x402r REFUNDABLE PAYMENT │
│ │
│ Client ────► Proxy ────► Escrow ────► Merchant │
│ │ │ │
│ │ └─ Holds funds until dispute period ends │
│ │ If disputed: Arbiter decides │
│ │ │
│ └─ Deterministic address (computed, not deployed) │
│ Client sees no difference! │
└─────────────────────────────────────────────────────────────────────────┘
The Flow
- Merchant registers with a shared escrow contract and deploys their relay proxy at registration (one-time setup)
- Server marks routes as refundable →
payTois swapped to a proxy address - Client pays to proxy address (no code changes needed)
- Facilitator routes payment through escrow
- Escrow holds funds for dispute period
- Disputes can be raised; arbiter resolves them
Key Concepts
Deterministic Proxy Addresses
Each merchant gets a unique proxy address computed using CREATE3 via CreateX:
proxyAddress = CREATE3(salt) // via CreateX
where salt = keccak256(merchantPayout + factoryAddress)
Why this matters:
| Property | Benefit |
|---|---|
| Deterministic | Same inputs → same address, always |
| Computed locally | No blockchain calls needed on server |
| No bytecode needed | CREATE3 doesn't require contract bytecode to compute address in comparison to CREATE2 |
The Proxy Pattern
Instead of deploying a full contract per merchant (expensive), we use proxies:
┌─────────────────────────────────┐
│ Shared Implementation │ ← One copy of the logic (deployed once)
│ (DepositRelay) │
└─────────────────────────────────┘
▲
│ delegatecall
│
┌─────────────────────────────────┐
│ Merchant Proxy │ ← Small contract per merchant (~1KB)
│ (RelayProxy) │
│ │
│ Stores as immutables: │
│ - MERCHANT_PAYOUT │
│ - TOKEN │
│ - ESCROW │
│ - IMPLEMENTATION │
└─────────────────────────────────┘
Cost comparison:
- Without proxies: 50KB × N merchants = expensive
- With proxies: 50KB (shared) + 1KB × N merchants = cheap
Integration Guide
For Merchants
Step 1: Register with escrow and deploy your relay proxy
Register your payout address with a shared escrow contract and deploy your relay proxy via the contracts.
Step 2: Install the package
npm install @x402/extensionsStep 3: Mark routes as refundable
import { refundable, withRefund } from "@x402/extensions/refund";
const routes = {
"/api/premium": {
accepts: refundable({
scheme: "exact",
payTo: "0xYourMerchantPayout.. .",
price: "$0.01",
network: "eip155:84532",
}),
},
"/api/basic": {
// Not wrapped in refundable() → pays directly to merchant
accepts: {
scheme: "exact",
payTo: "0xYourMerchantPayout...",
price: "$0.001",
network: "eip155:84532",
},
},
};
// Transform refundable routes
const processedRoutes = withRefund(routes, FACTORY_ADDRESS);What happens under the hood:
refundable()marks the payment option and stores the originalpayTowithRefund()processes each route:- Computes deterministic proxy address via CREATE3 using CreateX
- Replaces
payTowith proxy address - Adds
extensions.refundwith proxy factory address
- Client signs payment to proxy address (no changes needed)
- Extension flows through: Server → Client → Facilitator
For Facilitators
Step 1: Install the package
npm install @x402/extensionsStep 2: Add the refund hook
import { settleWithRefundHelper } from "@x402/extensions/refund";
// Settle hook - routes payment through escrow (proxy must be deployed by merchant)
facilitator.onBeforeSettle(async (context) => {
const result = await settleWithRefundHelper(
context. paymentPayload,
context.paymentRequirements,
facilitator.signer
);
if (result) {
return { abort: true, reason: "handled_by_refund_helper" };
}
return null;
});What the helper does:
- Extract
factoryAddressfromextensions.refund. info. factoryAddress - If no extension → return null (not a refund payment)
- Check if proxy is deployed (via code check) - if not, throws error (merchant must deploy at registration)
- Read
merchantPayoutfrom proxy storage - Check merchant is registered with escrow
- Route payment through escrow
For Clients
No code changes required.
The payTo address is already swapped to the proxy address, so clients sign normally. The payment automatically routes through escrow.
Optional Helpers
Refund Info Endpoint
Facilitators can optionally provide a /refund-info endpoint that returns refund and arbiter information for a given proxy address or merchant payout. This allows servers and clients to query refund details before or after payment.
Example endpoint response:
{
"proxyAddress": "0x...",
"merchantPayout": "0x...",
"escrowAddress": "0x...",
"arbiter": "0x...",
"arbiterUrl": "https://arbiter.example",
"factoryAddress": "0x...",
"tokenAddress": "0x..."
}This information can be used by:
- Servers: To display refund/arbiter information to users before payment
- Clients: To query escrow status, dispute information, or arbiter details
- UIs: To show merchant trust information and dispute resolution options
Comprehensive Verify
For more thorough validation, facilitators can use validateRefundInfo() during the verify phase to check refund extension data before settlement:
import { validateRefundInfo } from "@x402/extensions/refund";
facilitator.onBeforeVerify(async (context) => {
const { paymentRequirements } = context;
// Comprehensive validation of refund data
const isValid = validateRefundInfo(paymentRequirements);
if (!isValid) {
return {
abort: true,
reason: "invalid_refund_extension"
};
}
// Additional validation can be performed here:
// - Check proxy is deployed
// - Verify merchant registration
// - Validate factory address
// - Check escrow contract exists
return null; // Continue with normal verification
});This provides early validation and better error messages before settlement attempts.
Architecture
Contract Structure
┌─────────────────────────────────────────────────────────────────┐
│ DepositRelayFactory │
│ │
│ Stores: │
│ - TOKEN (ERC-20 address) │
│ - IMPLEMENTATION (shared logic) │
│ - ESCROW (shared escrow contract) │
│ - CREATEX (CREATE3 deployer) │
│ │
│ Functions: │
│ - deployRelay(merchantPayout) → deploys proxy via CREATE3 │
│ - computeProxyAddress(merchantPayout) → returns address │
└─────────────────────────────────────────────────────────────────┘
│
│ deploys via CREATE3
▼
┌─────────────────────────────────────────────────────────────────┐
│ RelayProxy (per merchant) │
│ │
│ Immutable storage: │
│ - MERCHANT_PAYOUT │
│ - TOKEN │
│ - ESCROW │
│ - IMPLEMENTATION │
│ │
│ Functions: │
│ - executeDeposit() → delegatecalls to implementation │
└─────────────────────────────────────────────────────────────────┘
│
│ delegatecall
▼
┌─────────────────────────────────────────────────────────────────┐
│ DepositRelay (shared implementation) │
│ │
│ Logic: │
│ - Transfer tokens from payer │
│ - Call escrow. noteDeposit() │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Escrow (shared) │
│ │
│ Functions: │
│ - registerMerchant(payout, arbiter) │
│ - noteDeposit(merchant, amount, payer) │
│ - raiseDispute(depositId, evidence) │
│ - resolveDispute(depositId, decision) │
│ - release(depositId) │
└─────────────────────────────────────────────────────────────────┘
Security Properties
| Property | Guarantee |
|---|---|
| Deterministic addresses | Same merchant + factory → same proxy address |
| Immutable proxy data | Cannot be changed after deployment |
| On-chain verification | Merchant registration checked before settlement |
| Deployment security | Merchant must deploy through factory at registration; only factory deployments are officially tracked in the merchant mapping |
Extension Schema
The refund extension follows the standard x402 extension format:
{
"extensions": {
"refund": {
"info": {
"factoryAddress": "0x...",
"proxies": {
"0xProxyAddress...": "0xMerchantPayout..."
}
},
"schema": {
"type": "object",
"properties": {
"factoryAddress": { "type": "string" },
"proxies": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["factoryAddress", "proxies"]
}
}
}
}Our Fee Structure
Zero fees for merchants and clients.
Revenue model:
- Escrow float is lent in DeFi protocols
- Yield distributed to arbiters (~75%) and protocol (~25%)
This is the first model. We will support more in the future
API Reference
refundable(paymentOption)
Marks a payment option as refundable.
import { refundable } from "@x402/extensions/refund";
const option = refundable({
scheme: "exact",
payTo: "0xMerchantAddress",
price: "$0.01",
network: "eip155:84532",
});Parameters:
paymentOption: Standard x402 payment option (must useexactscheme)
Returns: Marked payment option (to be processed by withRefund)
withRefund(routes, factoryAddress, createXAddress? )
Transforms refundable routes by swapping payTo to proxy addresses.
import { withRefund } from "@x402/extensions/refund";
const processed = withRefund(routes, FACTORY_ADDRESS);
// createXAddress is optional - uses standard address for networkParameters:
routes: Route configuration objectfactoryAddress: X402DepositRelayFactory addresscreateXAddress(optional): CreateX contract address (defaults to standard address)
Returns: Processed routes with:
payToreplaced with deterministic proxy addressextensions.refundadded with factory info
settleWithRefundHelper(paymentPayload, paymentRequirements, signer)
Handles refund payment settlement.
import { settleWithRefundHelper } from "@x402/extensions/refund";
const result = await settleWithRefundHelper(payload, requirements, signer);
if (result) {
// Refund payment handled - abort normal settlement
}Returns: null if not a refund payment, or settlement result
Roadmap
| Status | Feature |
|---|---|
| ✅ Done | Escrow and proxy contracts |
| ✅ Done | Deterministic address computation |
| ✅ Done | Required Extension helpers |
| ✅ Done | Merchant registration UI |
| 🚧 In Progress | Optional Extension helpers |
| 🚧 In Progress | Demo UI |
| 🔮 Future | Multi-arbiter support (call center model) |
| 🔮 Future | Customer-selectable arbiters out of merchant list |
| 🔮 Future | Configurable escrow periods |
| 🔮 Future | Multi-token factories |
FAQ
Why not a native escrow scheme?
An escrow scheme would require:
- Client would need to register the new scheme
- Facilitator would need to register the new scheme
- Protocol-level changes needed
x402r works with existing infrastructure:
- Clients need zero changes
- Facilitators add hooks
- Uses existing schemes
This enables incremental adoption without breaking changes.
Can anyone deploy the proxy?
The merchant must deploy their proxy at registration time. While anyone could technically deploy a proxy to the CREATE3 address, only deployments through the factory are officially tracked in the factory's merchant mapping, which is required for the system to recognize the proxy as valid. The proxy's immutable storage (merchantPayout, escrow, etc.) is set at deployment based on the factory's configuration.
What if the proxy isn't deployed yet?
The proxy must be deployed by the merchant at registration time before accepting payments. If a payment is attempted to a proxy that hasn't been deployed, the facilitator will reject the payment with an error. The server computes the address without deployment, but the merchant must deploy the proxy before clients can successfully pay to that address.
What if merchant isn't registered?
The facilitator checks registration during settle. If not registered, the payment fails with an error directing to the contracts for registration.
What is CreateX and why is it needed?
CreateX is a smart contract that provides CREATE3 functionality for deterministic address generation. Unlike CREATE2 (which requires bytecode to compute addresses), CREATE3 allows you to compute contract addresses deterministically using only a salt value and the CreateX deployer address.
Why CreateX is needed:
- No bytecode required: Servers can compute proxy addresses locally without needing the proxy contract's bytecode
- Deterministic addresses: Same inputs (factory address, merchant payout, CreateX address) always produce the same proxy address
- Standard implementation: CreateX is a widely-used, audited contract that provides a standard way to deploy contracts via CREATE3 across different networks
The factory uses CreateX as the deployer contract to deploy merchant proxies. The proxy address depends only on the CreateX contract address and the salt (derived from factory address + merchant payout), making address computation simple and efficient.
Resources
- Smart Contracts: x402r-contracts
- x402 Protocol: x402
- Merchant Registration: [app.x402r. org](https://app.x402r. org)
This issue is very much open to comments and feedback! We'd also love to help onboard and test with any facilitator or arbiter who'd be interested!