Escrow Lifecycle
Every escrow ultimately reaches one of two terminal states:States
| State | Storage | Meaning |
|---|---|---|
| Empty | client == address(0) | No escrow exists for this ID. |
| Active | client != address(0) | USDC is locked. Waiting for settlement or timeout. |
| Released | Deleted | Facilitator received payment; client received remainder. Terminal. |
| Refunded | Deleted | Client received full refund after timeout. Terminal. |
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:| Role | Permissions | Typical holder |
|---|---|---|
| Owner | Authorize UUPS upgrades. Transfer ownership (two-step). | Multisig or governance contract. |
Admin (DEFAULT_ADMIN_ROLE) | Grant/revoke roles. Adjust timeout window. | Operations team or multisig. |
Facilitator (FACILITATOR_ROLE) | Call settle() and release(). Receives payment on release. | Service operator or relay server. |
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
| Property | approve + transferFrom | EIP-3009 |
|---|---|---|
| Client transaction required | Yes (approve tx) | No |
| MEV frontrunning risk | High (anyone can call transferFrom) | None (only to address can execute) |
| Gas cost for the client | ~46k gas for approve | 0 (off-chain signature) |
| Nonce management | Token-level nonce | Per-authorization nonce |
| Replay protection | Allowance-based | Nonce-based, one-time use |
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
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.Facilitator calls settle()
Submits the client’s signature to the escrow contract with the EIP-3009 parameters.
Escrow calls receiveWithAuthorization()
The contract calls the USDC token to verify the client’s signature and pull funds.
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.
Facilitator calls release()
Provides the actual cost (
facilitatorAmount ≤ maxAmount). The contract clears the escrow from storage.Payment to facilitator
The contract transfers
facilitatorAmount to msg.sender (the calling facilitator).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