Skip to main content
x402Escrow

Escrow Lifecycle

Every escrow ultimately reaches one of two terminal states:
                settle()                    release()
  [empty] ------------------> [active] ------------------> [released]
                                 |
                                 |
                                 |  refundAfterTimeout()
                                 +-----------------------> [refunded]

States

StateStorageMeaning
Emptyclient == address(0)No escrow exists for this ID.
Activeclient != address(0)USDC is locked. Waiting for settlement or timeout.
ReleasedDeletedFacilitator received payment; client received remainder. Terminal.
RefundedDeletedClient received full refund after timeout. Terminal.
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:
RolePermissionsTypical holder
OwnerAuthorize 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.
This follows the principle of least privilege. The compromise of any single role does not grant full control.

EIP-3009: Signed Authorization

x402Escrow does not use the standard ERC-20 approve + transferFrom pattern. Instead, it uses EIP-3009 — a signed authorization scheme supported natively by USDC token.

How It Works

  1. The client constructs and signs a ReceiveWithAuthorization message 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)
  2. The facilitator submits this signature to settle(). The contract calls receiveWithAuthorization() on the USDC token, which verifies the signature and transfers funds atomically.

Why EIP-3009 Over approve/transferFrom

Propertyapprove + transferFromEIP-3009
Client transaction requiredYes (approve tx)No
MEV frontrunning riskHigh (anyone can call transferFrom)None (only to address can execute)
Gas cost for the client~46k gas for approve0 (off-chain signature)
Nonce managementToken-level noncePer-authorization nonce
Replay protectionAllowance-basedNonce-based, one-time use
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:
escrowId = keccak256(abi.encodePacked(client, nonce))
This means:
  • 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:
+----------------------+-----------+-------------+
|  client (20 bytes)   | refundAt  |   amount    |
|      address         |  uint40   |   uint56    |
+----------------------+-----------+-------------+
                  32 bytes total                    
  • 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.
When an escrow is released or refunded, the slot is zeroed — reclaiming the storage gas refund.

Timeout Mechanism

Each escrow stores its own refundAt timestamp, calculated at settlement time:
refundAt = block.timestamp + timeoutSecs
Key properties:
  • Per-escrow deadline: Changing the global timeoutSecs via setTimeout() 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 stored client address. This enables relayer-based gasless refunds.
  • Bounded range: The admin can set timeoutSecs between 5 minutes and 24 hours.

Payment Routing

When release() 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                Facilitator            x402Escrow            USDC Token
   |                       |                      |                      |
   | 1. Sign EIP-3009 auth |                      |                      |
   |---------------------->|                      |                      |
   |                       |                      |                      |
   |                       | 2. settle(signature) |                      |
   |                       |--------------------->|                      |
   |                       |                      |                      |
   |                       |                      | 3. receiveWithAuth   |
   |                       |                      |--------------------->|
   |                       |                      |                      |
   |                       |                      | 4. USDC transferred  |
   |                       |                      |<---------------------|
   |                       |                      |                      |
   |                       | 5. escrowId returned |                      |
   |                       |<---------------------|                      |
   |                       |                      |                      |
   |          ... service executes ...            |                      |
   |                       |                      |                      |
   |                       | 6. release(cost)     |                      |
   |                       |--------------------->|                      |
   |                       |                      |                      |
   |                       | 7. Payment to        |                      |
   |                       |    facilitator       |                      |
   |                       |<---------------------|                      |
   |                       |                      |                      |
   |       8. Remainder refunded to client        |                      |
   |<----------------------+----------------------|                      |
   |                       |                      |                      |
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.
…service executes — the facilitator performs the metered work and tracks the actual cost…
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 calls release():
 Anyone                       x402Escrow
   │                              │
   │ refundAfterTimeout(escrowId) │
   │----------------------------->│
   │                              │
   │                              │--> Checks block.timestamp ≥ refundAt
   │                              │--> Transfers full amount to client
   │                              │
   │       Refunded event         │
   │<-----------------------------│
  • Permissionless: any address can trigger it. The client does not need to be online.
  • Funds always go to client: the stored client address 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

  1. Agent sends HTTP request to a metered API
  2. Server responds with HTTP 402 + payment requirements (price, token, escrow address)
  3. Agent signs EIP-3009 authorization for the max amount
  4. Agent re-sends the request with X-PAYMENT header containing the signature
  5. Server (as facilitator) calls settle() to lock funds
  6. Server executes the request
  7. Server calls release() with the actual cost
  8. Agent receives the HTTP response + any USDC remainder