Skip to main content
x402Escrow

Trust Assumptions

PartyTrusted toNot trusted to
ClientSign valid EIP-3009 authorizations.Act honestly after signing (escrow protects against overpayment).
FacilitatorCall settle() and release() correctly.Be available 24/7 (timeout refund protects against offline facilitators).
AdminConfigure reasonable timeouts.Upgrade the contract (only Owner can).
OwnerAuthorize safe upgrades.Access escrowed funds directly.
USDC tokenImplement EIP-3009 correctly. Transfer exact amounts (no fee-on-transfer).

Protection Mechanisms

Reentrancy Protection

All state-changing functions that interact with external contracts (settle, release, refundAfterTimeout) are protected by:
  1. ReentrancyGuard — OpenZeppelin’s nonReentrant modifier prevents recursive calls.
  2. Checks-Effects-Interactions pattern — escrow storage is cleared (effects) before any external token transfers (interactions).

MEV Protection

EIP-3009 ReceiveWithAuthorization requires msg.sender == to. Since to is set to the escrow contract address, a mempool observer who extracts the signed authorization from a pending transaction cannot use it — only the escrow contract can execute the transfer.

Fee-on-transfer Detection

After calling receiveWithAuthorization(), settle() verifies that the contract USDC balance increased by exactly maxAmount. If a token charges transfer fees (resulting in a smaller balance increase), the transaction reverts with TransferMismatch(). This prevents a class of attacks where fee-on-transfer tokens would create escrows with less USDC than recorded, leading to insolvency on release.

Access Control Boundaries

Owner       --> _authorizeUpgrade()       (UUPS upgrades only)
Admin       --> setTimeout()              (configuration only)
                grantRole() / revokeRole()
Facilitator --> settle()                  (fund operations only)
                release()
Anyone      --> refundAfterTimeout()      (permissionless safety net)
                getEscrow()
No single role can both configure the contract and access funds. The Owner cannot call release(). The Facilitator cannot call setTimeout(). This limits the blast radius of any key compromise.

Two-step Ownership Transfer

Ownership uses OpenZeppelin’s Ownable2Step:
  1. Current owner calls transferOwnership(newOwner).
  2. New owner calls acceptOwnership().
This prevents accidental or irreversible ownership transfer to the wrong address.

Per-escrow Deadlines

Each escrow stores its own refundAt timestamp at creation. Subsequent calls to setTimeout() do not modify existing escrows. This prevents an attack where an admin could extend timeouts to delay client refunds on active escrows.

Input Validation

InputValidationError
Addresses (initialize)Must be non-zeroZeroAddress()
USDC addressMust be a contractNotContract()
AmountNon-zero, fits uint56InvalidAmount()
Timeout300 ≤ value ≤ 86400InvalidTimeout()
EIP-3009 windowvalidAfter < now < validBeforeNotYetValid() / TimeoutExpired()
Escrow existenceclient != address(0)EscrowNotFound()
Escrow uniquenessNo active escrow with the same IDEscrowAlreadyExists()
Release amountfacilitatorAmount ≤ escrow.amountInvalidAmount()

Attack Scenarios

Facilitator Goes Offline

Impact: client funds are locked until timeout.
Mitigation: refundAfterTimeout() is permissionless. After refundAt, anyone (including a relayer or the client themselves) can trigger the refund. Default timeout is 90 minutes.

Facilitator Overcharges

Impact: facilitator receives more than the fair cost.
Mitigation: the facilitator cannot withdraw more than escrow.amount (the client’s signed maximum). The client controls their maximum exposure by choosing the maxAmount in the EIP-3009 signature.

Facilitator Double-Releases

Impact: none.
Mitigation: release() clears the escrow before transferring. A second call reverts with EscrowNotFound().

Admin Sets Extreme Timeout

Impact: new escrows have very short or very long refund windows.
Mitigation: the timeout is bounded to [5 minutes, 24 hours]. Existing escrows are unaffected.

Signature Replay

Impact: funds locked twice from the same authorization.
Mitigation: EIP-3009 nonces are one-time use at the USDC token level. The escrow also checks EscrowAlreadyExists for the derived escrow ID.

Malicious Upgrade

Impact: contract logic replaced with malicious code.
Mitigation: only the Owner (typically a multisig) can authorize upgrades via _authorizeUpgrade(). Two-step ownership transfer prevents accidental owner change.

Upgradeability Considerations

x402Escrow uses the UUPS proxy pattern (ERC-1967). Key properties:
  • State persists across upgrades: proxy storage is preserved when the implementation changes.
  • Storage layout must be compatible: future versions must not reorder or remove existing storage variables.
  • Escrow struct is stable: the packed Escrow struct occupies one slot. If the struct changes in a future version, a storage migration would be required.
  • Upgrade is atomic: upgradeToAndCall() switches the implementation and optionally calls an initializer in one transaction.

Recommendations for Production

  1. Use a multisig for Owner and Admin roles. A single EOA controlling upgrades or role assignment is a single point of failure.
  2. Monitor Deposited and Released events. Off-chain monitoring can detect anomalous patterns (e.g., releases without corresponding service activity).
  3. Set appropriate timeouts. Shorter timeouts reduce client risk but give facilitators less time to settle. The default 90 minutes suits most API workloads.
  4. Rotate facilitator keys. Since release() pays msg.sender, you can add new facilitator addresses and revoke old ones without affecting active escrows.