Trust Assumptions
| Party | Trusted to | Not trusted to |
|---|---|---|
| Client | Sign valid EIP-3009 authorizations. | Act honestly after signing (escrow protects against overpayment). |
| Facilitator | Call settle() and release() correctly. | Be available 24/7 (timeout refund protects against offline facilitators). |
| Admin | Configure reasonable timeouts. | Upgrade the contract (only Owner can). |
| Owner | Authorize safe upgrades. | Access escrowed funds directly. |
| USDC token | Implement 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:
- ReentrancyGuard — OpenZeppelin’s
nonReentrantmodifier prevents recursive calls. - Checks-Effects-Interactions pattern — escrow storage is cleared (effects) before any external token transfers (interactions).
MEV Protection
EIP-3009ReceiveWithAuthorization 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 callingreceiveWithAuthorization(), 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
release(). The Facilitator cannot call setTimeout(). This limits the blast radius of any key compromise.
Two-step Ownership Transfer
Ownership uses OpenZeppelin’sOwnable2Step:
- Current owner calls
transferOwnership(newOwner). - New owner calls
acceptOwnership().
Per-escrow Deadlines
Each escrow stores its ownrefundAt 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
| Input | Validation | Error |
|---|---|---|
| Addresses (initialize) | Must be non-zero | ZeroAddress() |
| USDC address | Must be a contract | NotContract() |
| Amount | Non-zero, fits uint56 | InvalidAmount() |
| Timeout | 300 ≤ value ≤ 86400 | InvalidTimeout() |
| EIP-3009 window | validAfter < now < validBefore | NotYetValid() / TimeoutExpired() |
| Escrow existence | client != address(0) | EscrowNotFound() |
| Escrow uniqueness | No active escrow with the same ID | EscrowAlreadyExists() |
| Release amount | facilitatorAmount ≤ escrow.amount | InvalidAmount() |
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
Escrowstruct 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
- Use a multisig for Owner and Admin roles. A single EOA controlling upgrades or role assignment is a single point of failure.
- Monitor
DepositedandReleasedevents. Off-chain monitoring can detect anomalous patterns (e.g., releases without corresponding service activity). - Set appropriate timeouts. Shorter timeouts reduce client risk but give facilitators less time to settle. The default 90 minutes suits most API workloads.
- Rotate facilitator keys. Since
release()paysmsg.sender, you can add new facilitator addresses and revoke old ones without affecting active escrows.