x402Escrow
Architecture Overview
+---------------+ HTTP 402 +---------------+
| Client / |<--------------------->| Facilitator / |
| AI Agent | X-PAYMENT header | Service API |
+-------+-------+ +-------+-------+
| |
| signs EIP-3009 | calls settle()
| authorization | / release()
| |
| +--------------+ |
+----------->| x402Escrow |<-----------+
| (on-chain) |
+------+-------+
|
+------+-------+
| USDC Token |
| (EIP-3009) |
+--------------+
Facilitator Integration
The facilitator is the service operator — the side that accepts payment and delivers work.
1. Receive the Client’s Payment Authorization
In the x402 flow, the client sends an HTTP request with an X-PAYMENT header containing the signed EIP-3009 authorization. Parse the header to extract:
interface PaymentAuthorization {
client: string; // client wallet address
maxAmount: bigint; // max USDC (6 decimals)
validAfter: bigint; // unix timestamp
validBefore: bigint; // unix timestamp
nonce: string; // bytes32
v: number;
r: string;
s: string;
}
2. Lock Funds with settle()
Call settle() to pull USDC from the client into escrow:
import { ethers } from "ethers";
const escrow = new ethers.Contract(ESCROW_ADDRESS, X402_ESCROW_ABI, facilitatorSigner);
const tx = await escrow.settle(
auth.client,
auth.maxAmount,
auth.validAfter,
auth.validBefore,
auth.nonce,
auth.v,
auth.r,
auth.s
);
const receipt = await tx.wait();
const depositedLog = receipt.logs.find(
(log) => log.address.toLowerCase() === ESCROW_ADDRESS.toLowerCase()
);
const escrowId = escrow.interface.parseLog(depositedLog).args.escrowId;
Store escrowId — you’ll need it for release.
3. Execute the Service
Perform the metered work (AI inference, API calls, data processing, etc.) and track the actual cost.
4. Release with Actual Cost
const actualCost = ethers.parseUnits("2.50", 6); // actual USDC cost
const tx = await escrow.release(escrowId, actualCost);
await tx.wait();
// facilitator receives actualCost
// client receives (maxAmount - actualCost) automatically
If the service failed or no cost was incurred, release with zero:
await escrow.release(escrowId, 0); // full refund to client
Error Handling
| Scenario | Action |
|---|
settle() reverts with TransferMismatch | The client’s USDC balance may be insufficient, or a non-standard token is in use. |
settle() reverts with TimeoutExpired | Authorization expired before you submitted it. Ask the client for a fresh signature. |
release() reverts with EscrowNotFound | Escrow was already released or refunded (timeout). Check getEscrow() first. |
| Service crashes mid-execution | You have until refundAt to call release(). Monitor active escrows. |
Client Integration
The client is the payer — typically an AI agent or application that consumes a metered service.
1. Sign the EIP-3009 Authorization
Construct and sign a ReceiveWithAuthorization message:
import { ethers } from "ethers";
const domain = {
name: "USD Coin", // USDC token name
version: "2", // USDC token version
chainId: 8453, // Base
verifyingContract: USDC_ADDRESS
};
const types = {
ReceiveWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" }
]
};
const message = {
from: clientAddress,
to: ESCROW_ADDRESS, // must be the escrow contract
value: ethers.parseUnits("10.00", 6), // max USDC to lock
validAfter: 0, // valid immediately
validBefore: Math.floor(Date.now() / 1000) + 300, // expires in 5 min
nonce: ethers.hexlify(ethers.randomBytes(32))
};
const signature = await clientSigner.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);
The to field must be the escrow contract address. This prevents anyone else from using your signature.
2. Send with the HTTP Request
Include the authorization in the X-PAYMENT header per the x402 protocol:
const response = await fetch("https://api.example.com/inference", {
method: "POST",
headers: {
"X-PAYMENT": JSON.stringify({
client: clientAddress,
maxAmount: message.value.toString(),
validAfter: message.validAfter.toString(),
validBefore: message.validBefore.toString(),
nonce: message.nonce,
v, r: r, s: s
}),
"Content-Type": "application/json"
},
body: JSON.stringify({ prompt: "..." })
});
3. Monitor the Escrow (Optional)
Check the escrow status on-chain:
const escrowId = ethers.keccak256(
ethers.solidityPacked(["address", "bytes32"], [clientAddress, message.nonce])
);
const view = await escrow.getEscrow(escrowId);
console.log({
amount: view.amount,
canRefund: view.canRefund,
timeUntilRefund: view.timeUntilRefund
});
4. Claim Timeout Refund (If Needed)
If the escrow was settled but the facilitator never called release(), claim a refund after the timeout:
if (view.canRefund) {
await escrow.refundAfterTimeout(escrowId);
// full amount returned to client
}
This is permissionless — any address can trigger it, and funds always go to the original client.
Escrow ID Computation
The escrow ID is deterministic and can be computed off-chain before the transaction:
escrowId = keccak256(abi.encodePacked(client, nonce))
In ethers.js:
const escrowId = ethers.keccak256(
ethers.solidityPacked(["address", "bytes32"], [clientAddress, nonce])
);
In Python (web3.py):
from web3 import Web3
escrow_id = Web3.keccak(
Web3.to_bytes(hexstr=client_address) + nonce_bytes
)
Event Monitoring
Subscribe to escrow events for off-chain tracking:
escrow.on("Deposited", (escrowId, client, amount) => {
console.log(`Escrow ${escrowId}: ${amount} USDC locked by ${client}`);
});
escrow.on("Released", (escrowId, facilitator, toFacilitator, toClient) => {
console.log(`Escrow ${escrowId}: ${toFacilitator} to facilitator, ${toClient} refunded`);
});
escrow.on("Refunded", (escrowId, client, amount) => {
console.log(`Escrow ${escrowId}: ${amount} USDC refunded to ${client}`);
});