diff --git a/specs/schemes/escrow/scheme_escrow.md b/specs/schemes/escrow/scheme_escrow.md new file mode 100644 index 0000000..eeb6947 --- /dev/null +++ b/specs/schemes/escrow/scheme_escrow.md @@ -0,0 +1,103 @@ +# Scheme: `escrow` + +## Summary + +The `escrow` scheme transfers funds through an on-chain escrow contract, decoupling authorization from settlement. The client signs once to authorize a maximum amount, and the facilitator settles through the [Commerce Payments Protocol](https://github.com/base/commerce-payments) — routing funds into escrow (pre-settlement hold) or directly to the receiver (post-settlement refundable). + +This scheme reuses audited commerce-payments contracts deployed on Base and other EVM chains. + +## Example Use Cases + +- Refundable payments with buyer protection +- Post-settlement refunds via the charge path +- Subscription / session billing with periodic captures + +## Settlement Methods + +The scheme supports two settlement paths through the commerce-payments operator: + +| Method | Function | Behavior | +| :---------- | :------------ | :----------------------------------------------------------- | +| `authorize` | `authorize()` | Funds held in escrow. Can be captured, refunded, or voided. | +| `charge` | `charge()` | Funds sent directly to receiver. Refundable post-settlement. | + +Both methods share identical function signatures and use the same operator, fee system, and token collector infrastructure. + +## Lifecycle + +### Authorize (default) + +``` +SIGN → AUTHORIZE → RESOURCE DELIVERED +``` + +1. **Sign**: Client signs an ERC-3009 `receiveWithAuthorization` for the maximum amount +2. **Authorize**: Facilitator calls `authorize()` on the operator — funds locked in escrow +3. **Resource delivered**: Server returns the resource (HTTP 200) + +Post-settlement, the commerce-payments contracts enable capture, refund, void, or reclaim — see [Commerce Payments Protocol](#commerce-payments-protocol). + +### Charge + +``` +SIGN → CHARGE → RESOURCE DELIVERED +``` + +1. **Sign**: Client signs an ERC-3009 authorization (same as above) +2. **Charge**: Facilitator calls `charge()` on the operator — funds go directly to receiver +3. **Resource delivered**: Server returns the resource (HTTP 200) + +Post-settlement, the operator can refund within `refundExpiry` if needed. Unlike the authorize path, the payer cannot `reclaim()` — funds are already with the receiver. + +## Relationship to `exact` + +| Aspect | `exact` | `escrow` | +| :----------------- | :----------------- | :------------------------------------------------- | +| Settlement | Immediate transfer | Via escrow contract (authorize) or direct (charge) | +| Refundable | No | Yes (both paths) | +| Fee system | None | Commerce-payments managed (min/max bps) | +| Gas payer | Facilitator | Facilitator | +| Signature | ERC-3009 / Permit2 | ERC-3009 | +| On-chain contracts | Token only | Token + Escrow + Operator + Collector | + +The `charge` settlement method gives `escrow` a direct-settlement path (like `exact`) while retaining post-settlement refund capability through the commerce-payments infrastructure. + +## Security Considerations + +### Fund Safety + +- Funds held in audited [AuthCaptureEscrow](https://github.com/base/commerce-payments) contract +- Cannot overcharge — `amount` capped by client-signed `maxAmount` +- Client can reclaim funds after `authorizationExpiry` if operator disappears +- Fee bounds (`minFeeBps`/`maxFeeBps`) are client-signed and enforced on-chain + +### Replay Prevention + +- Nonces derived from `keccak256(chainId, escrowAddress, paymentInfoHash)` — unique per payment +- ERC-3009 nonce consumed on-chain by the token contract +- `salt` field provides additional entropy for session uniqueness + +### Expiry Enforcement + +The contract enforces strict ordering: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` + +- `preApprovalExpiry`: Deadline for the ERC-3009 signature (doubles as `validBefore`) +- `authorizationExpiry`: Deadline for capturing escrowed funds +- `refundExpiry`: Deadline for requesting refunds on captured payments + +## Appendix + +### Commerce Payments Protocol + +The escrow scheme is built on Base's [Commerce Payments Protocol](https://blog.base.dev/commerce-payments-protocol), which provides: + +- **Escrow**: Singleton contract managing fund locking, capture, refund, and reclaim +- **Operators**: Route payments through escrow with configurable fees +- **Token Collectors**: Pluggable modules for different token authorization methods (ERC-3009, Permit2) + +### References + +- [Commerce Payments Protocol](https://github.com/base/commerce-payments) +- [EIP-3009: Transfer With Authorization](https://eips.ethereum.org/EIPS/eip-3009) +- [Escrow Scheme Proposal — Agentokratia (Issue #834)](https://github.com/coinbase/x402/issues/834) +- [Escrow Scheme Proposal — x402r (Issue #1011)](https://github.com/coinbase/x402/issues/1011) diff --git a/specs/schemes/escrow/scheme_escrow_evm.md b/specs/schemes/escrow/scheme_escrow_evm.md new file mode 100644 index 0000000..4994409 --- /dev/null +++ b/specs/schemes/escrow/scheme_escrow_evm.md @@ -0,0 +1,203 @@ +# Scheme: `escrow` on `EVM` + +## Summary + +The `escrow` scheme on EVM uses the [Commerce Payments Protocol](https://github.com/base/commerce-payments) contract stack: + +- **Escrow** (`AuthCaptureEscrow`): Singleton — locks funds, enforces expiries, distributes on capture/refund +- **Operator**: Routes payments through escrow with fee management +- **Token Collector** (`ERC3009PaymentCollector`): Collects funds via `receiveWithAuthorization` signatures + +The client signs a single ERC-3009 authorization. The facilitator submits it to the operator, which handles token collection, escrow locking, and fee distribution — all in one transaction. + +## PaymentRequirements + +Escrow-accepting servers advertise with scheme `escrow`: + +```json +{ + "x402Version": 2, + "accepts": [ + { + "scheme": "escrow", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2", + "escrowAddress": "0xEscrowAddress", + "operatorAddress": "0xOperatorAddress", + "tokenCollector": "0xCollectorAddress", + "settlementMethod": "authorize", + "minFeeBps": 0, + "maxFeeBps": 1000, + "feeReceiver": "0xOperatorAddress" + } + } + ] +} +``` + +### `extra` Fields + +| Field | Required | Type | Description | +| :--------------------------- | :------- | :------------------------ | :------------------------------------------------- | +| `name` | Yes | `string` | EIP-712 domain name for the token (e.g., `"USDC"`) | +| `version` | Yes | `string` | EIP-712 domain version (e.g., `"2"`) | +| `escrowAddress` | Yes | `address` | AuthCaptureEscrow contract address | +| `operatorAddress` | Yes | `address` | Operator address | +| `tokenCollector` | Yes | `address` | Token collector contract address | +| `settlementMethod` | No | `"authorize" \| "charge"` | Settlement path. Default: `"authorize"` | +| `minFeeBps` | No | `uint16` | Minimum fee in basis points. Default: `0` | +| `maxFeeBps` | No | `uint16` | Maximum fee in basis points. Default: `0` | +| `feeReceiver` | No | `address` | Fee recipient. Default: `address(0)` (no fees) | +| `preApprovalExpirySeconds` | No | `uint48` | ERC-3009 signature validity / pre-approval expiry | +| `authorizationExpirySeconds` | No | `uint48` | Deadline for capturing escrowed funds | +| `refundExpirySeconds` | No | `uint48` | Deadline for refund requests | + +## PaymentPayload + +```json +{ + "x402Version": 2, + "resource": { + "url": "https://api.example.com/resource", + "method": "GET" + }, + "accepted": { + "scheme": "escrow", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2", + "escrowAddress": "0xEscrowAddress", + "operatorAddress": "0xOperatorAddress", + "tokenCollector": "0xCollectorAddress", + "settlementMethod": "authorize", + "minFeeBps": 0, + "maxFeeBps": 1000, + "feeReceiver": "0xOperatorAddress" + } + }, + "payload": { + "authorization": { + "from": "0xPayerAddress", + "to": "0xCollectorAddress", + "value": "1000000", + "validAfter": "0", + "validBefore": "1740672154", + "nonce": "0xf374...3480" + }, + "signature": "0x2d6a...571c", + "paymentInfo": { + "operator": "0xOperatorAddress", + "receiver": "0xReceiverAddress", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "maxAmount": "1000000", + "preApprovalExpiry": 1740672154, + "authorizationExpiry": 4294967295, + "refundExpiry": 281474976710655, + "minFeeBps": 0, + "maxFeeBps": 1000, + "feeReceiver": "0xOperatorAddress", + "salt": "0x0000...0001" + } + } +} +``` + +### Nonce Derivation + +The ERC-3009 nonce is deterministically derived from the payment parameters. The inner hash uses the `PaymentInfo` typehash and sets `payer=address(0)` so the nonce is payer-agnostic (computed before the payer is known): + +``` +paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, operator, address(0), receiver, ...)) +nonce = keccak256(abi.encode(chainId, escrowAddress, paymentInfoHash)) +``` + +This ties the off-chain signature to the specific escrow contract and payment terms, preventing cross-chain or cross-contract replay. + +## Verification Logic + +The facilitator performs these checks in order: + +1. **Type guard**: Verify `payload` contains `authorization`, `signature`, and `paymentInfo` fields +2. **Scheme match**: Verify `requirements.scheme === "escrow"` and `payload.accepted.scheme === "escrow"` +3. **Network match**: Verify `payload.accepted.network === requirements.network` and format is `eip155:` +4. **Extra validation**: Verify `requirements.extra` contains required escrow fields (`escrowAddress`, `operatorAddress`, `tokenCollector`) +5. **Time window**: Verify `validBefore > now + 6s` (not expired) and `validAfter <= now` (active) +6. **ERC-3009 signature**: Recover signer from EIP-712 typed data (`ReceiveWithAuthorization` primary type) and verify matches `authorization.from` +7. **Amount**: Verify `authorization.value >= requirements.amount` +8. **Token match**: Verify `paymentInfo.token === requirements.asset` +9. **Receiver match**: Verify `paymentInfo.receiver === requirements.payTo` +10. **Balance check**: Verify payer has sufficient token balance (soft check — skip on RPC failure) + +### EIP-6492 Support + +For smart wallet clients, the signature may be EIP-6492 wrapped (containing deployment bytecode). The facilitator extracts the inner ECDSA signature for verification. The on-chain `ERC6492SignatureHandler` in the token collector handles wallet deployment during settlement. + +## Settlement Logic + +Settlement is performed by the facilitator calling the operator: + +1. **Re-verify** the payload (catch expired/invalid payloads before spending gas) +2. **Determine function**: `settlementMethod === "charge" ? "charge" : "authorize"` +3. **Call operator**: `operator.(paymentInfo, amount, tokenCollector, collectorData)` +4. **Wait for receipt**: Confirm transaction success with 60s timeout +5. **Return result**: Transaction hash, network, and payer address + +The operator handles: + +- Calling the token collector to execute `receiveWithAuthorization` with the client's signature (EIP-712 primary type: `ReceiveWithAuthorization`, not `TransferWithAuthorization`) +- Routing funds to escrow (authorize) or directly to receiver (charge) +- Validating fee bounds against the client-signed `PaymentInfo` + +## Appendix + +### PaymentInfo Struct + +This struct is signed by the client and validated on-chain: + +```solidity +struct PaymentInfo { + address operator; // Operator address + address payer; // Client wallet (authorization.from) + address receiver; // Fund recipient (payTo) + address token; // ERC-20 token address + uint120 maxAmount; // Maximum authorized amount + uint48 preApprovalExpiry; // ERC-3009 validBefore / pre-approval deadline + uint48 authorizationExpiry;// Capture deadline (authorize path only) + uint48 refundExpiry; // Refund request deadline + uint16 minFeeBps; // Minimum acceptable fee (basis points) + uint16 maxFeeBps; // Maximum acceptable fee (basis points) + address feeReceiver; // Fee recipient (address(0) = flexible) + uint256 salt; // Client-provided entropy +} +``` + +### Expiry Ordering + +The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` + +| Expiry | Enforced At | Effect | +| :-------------------- | :------------------------- | :---------------------------------- | +| `preApprovalExpiry` | `authorize()` / `charge()` | Blocks settlement after this time | +| `authorizationExpiry` | `capture()` | Blocks capture; enables `reclaim()` | +| `refundExpiry` | `refund()` | Blocks refund requests | + +### Fee System + +Fees are enforced on-chain by the escrow contract: + +- `minFeeBps` and `maxFeeBps` set by the client in `PaymentInfo` (0–10,000 bps) +- `feeBps` at capture/charge must fall within `[minFeeBps, maxFeeBps]` +- If `feeReceiver` is set in `PaymentInfo`, actual `feeReceiver` at capture/charge must match +- If `feeReceiver` is `address(0)`, the caller can specify any non-zero address +- Fee distribution: `feeAmount = amount * feeBps / 10000`, remainder goes to receiver