Escrow Lifecycle
Every escrow ultimately reaches one of two terminal states:States
Once an escrow reaches a terminal state, its storage slot is cleared. The same
escrowId cannot be reused — it is derived from keccak256(client, nonce), where the nonce is a one-time EIP-3009 value.
Roles
x402Escrow uses OpenZeppelin’s AccessControl with three distinct roles:Why Roles Are Separated
- The Owner has the most dangerous capability (upgrading contract logic) but cannot touch escrowed funds.
- The Admin manages day-to-day configuration but cannot upgrade the contract.
- The Facilitator handles funds but cannot change configuration or upgrade logic.
EIP-3009: Signed Authorization
x402Escrow does not use the standard ERC-20approve + transferFrom pattern. Instead, it uses EIP-3009 — a signed authorization scheme supported natively by USDC token.
How It Works
-
The client constructs and signs a
ReceiveWithAuthorizationmessage off-chain. This message specifies:- from: the client address
- to: the escrow contract address (critical — prevents signature reuse)
- value: the maximum USDC to lock
- validAfter / validBefore: time window for the authorization
- nonce: unique per-authorization (prevents replay)
-
The facilitator submits this signature to
settle(). The contract callsreceiveWithAuthorization()on the USDC token, which verifies the signature and transfers funds atomically.
Why EIP-3009 Over approve/transferFrom
The
to field in ReceiveWithAuthorization is set to the escrow contract address. Only the escrow contract (as msg.sender == to) can execute the transfer. This is a critical anti-MEV property — even if the signed data leaks, no other contract or EOA can use it.
Escrow ID
Each escrow is identified by a deterministic ID:- The same client with different nonces produces different escrow IDs.
- The escrow ID is known before the transaction is submitted (useful for off-chain tracking).
- Collision requires the same client to reuse a nonce, which EIP-3009 prevents at the token level.
Storage Efficiency
Each active escrow occupies exactly one 32-byte storage slot:- uint40 refundAt: Unix timestamp up to year 36,812. Sufficient for any practical timeout.
- uint56 amount: Supports up to approximately 72 billion USDC (72,057,594,037,927,935 micro-units). More than the entire USDC supply.
Timeout Mechanism
Each escrow stores its ownrefundAt timestamp, calculated at settlement time:
- Per-escrow deadline: Changing the global
timeoutSecsviasetTimeout()does not affect existing escrows. Only new escrows use the updated value. - Permissionless refund:
refundAfterTimeout()can be called by anyone — not just the client. Funds always go to the storedclientaddress. This enables relayer-based gasless refunds. - Bounded range: The admin can set
timeoutSecsbetween 5 minutes and 24 hours.
Payment Routing
Whenrelease() is called, the facilitator payment goes to msg.sender — not to a stored address. This is intentional:
- Multiple addresses can hold
FACILITATOR_ROLE. - The address that calls
release()receives the payment. - This enables facilitator rotation, load balancing, and hot-wallet separation without contract changes.
Payment Flow
1
Client signs authorization (off-chain)
Constructs and signs an EIP-3009
ReceiveWithAuthorization message. No gas required. The signed payload specifies the from, to (escrow address), value (max USDC), validAfter/validBefore, and a unique nonce. The client sends this signature to the facilitator, typically via an X-PAYMENT HTTP header per the x402 protocol.2
Facilitator calls settle()
Submits the client’s signature to the escrow contract with the EIP-3009 parameters.
3
Escrow calls receiveWithAuthorization()
The contract calls the USDC token to verify the client’s signature and pull funds.
4
USDC transferred
The token verifies the signature, checks that the nonce hasn’t been used, and transfers funds from the client to the escrow contract. Atomic — if any check fails, the entire transaction reverts.
5
Escrow ID returned
The contract validates the amount, computes
escrowId = keccak256(client, nonce), stores the escrow with refundAt = block.timestamp + timeoutSecs, and returns the ID to the facilitator.6
Facilitator calls release()
Provides the actual cost (
facilitatorAmount ≤ maxAmount). The contract clears the escrow from storage.7
Payment to facilitator
The contract transfers
facilitatorAmount to msg.sender (the calling facilitator).8
Remainder to client
The contract refunds
maxAmount - facilitatorAmount to the stored client address.Timeout Refund
If the facilitator never callsrelease():
- Permissionless: any address can trigger it. The client does not need to be online.
- Funds always go to client: the stored
clientaddress receives the refund regardless of who triggers it. - Default timeout: 90 minutes (configurable by admin between 5 min and 24 hours).
Typical x402 HTTP Integration
- Agent sends HTTP request to a metered API
- Server responds with HTTP 402 + payment requirements (price, token, escrow address)
- Agent signs EIP-3009 authorization for the max amount
- Agent re-sends the request with
X-PAYMENTheader containing the signature - Server (as facilitator) calls settle() to lock funds
- Server executes the request
- Server calls release() with the actual cost
- Agent receives the HTTP response + any USDC remainder