Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions specs/schemes/escrow/scheme_escrow.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the funds are directly send to the receiver (server), how are refunds supposed to work? How is it decided if a refund is needed? Can a client request a refund? What happens in case of disputes? If the funds are never held in the escrow, why is this under a escrow scheme?

Copy link
Copy Markdown
Collaborator

@phdargen phdargen Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact already guarantees that no payment is made on server failure. So I suppose whats covered here would be if the client is not happy with the delivered service which is inherently subjective

Copy link
Copy Markdown
Contributor Author

@A1igator A1igator Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So base commerce-payments is unopinionated on most of those questions and it would be implementation specific. You can check out our implementation at https://www.x402r.org/ .

I agree the naming might be a bit confusing so maybe "commerce" scheme would be better?

Regarding server failure and subjectiveness, correct. I will say one thing objective the middleware currently doesn't do is double check the payload matches a merchant set scheme but that could be added later.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I'd prefer "commerce" as scheme name aligning with the protocol it is build on

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not sure what the value add of the charge() mode is wrt exact. If funds are in the servers wallet, how do they flow back to the client? Can the operator claw back the funds? Or is it supposed to pay the refund out of its own pocket?

Seems to be strictly better and more straightforward to just keep the funds in the escrow until the challenge window is expired

Copy link
Copy Markdown
Contributor Author

@A1igator A1igator Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Charge (and refund as defined by base commerce-payments more specifically which is what charge is good for) is meant to be for scenarios where like a marketplace wants to guarantee certain amount of refunds as they only allow above certain reputation merchants for example. Another scenario is merchants themselves paying out of pocket or out of a bond they're supposed to put up.

Putting funds in the escrow for long periods of time is always strictly better for the client yes but it comes with the tradeoffs of merchant not getting their money fast even if they have a low refund rate that they could cover out of pocket.

Ditto on "commerce". Will rename next pass.


## 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)
203 changes: 203 additions & 0 deletions specs/schemes/escrow/scheme_escrow_evm.md
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth-capture vs escrow?

Copy link
Copy Markdown
Contributor Author

@A1igator A1igator Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to "commerce" as per Duke's suggestion above: #1425 (comment)

"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"
Comment thread
A1igator marked this conversation as resolved.
Outdated
}
}
]
}
```

### `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:<chainId>`
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`
Comment thread
A1igator marked this conversation as resolved.
Outdated
8. **Token match**: Verify `paymentInfo.token === requirements.asset`
9. **Receiver match**: Verify `paymentInfo.receiver === requirements.payTo`
Comment thread
A1igator marked this conversation as resolved.
Outdated
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.<functionName>(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