Skip to content
Merged
120 changes: 79 additions & 41 deletions packages/evm/src/escrow/facilitator/scheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ import { isEscrowPayload, isEscrowExtra } from '../shared/types'
import type { EscrowExtra, EscrowPayload } from '../shared/types'
import { parseChainId } from '../shared/utils'

/**
* Build the on-chain PaymentInfo struct from the client's payload.
* Used by both verify (simulation) and settle (transaction).
*/
function buildPaymentInfo(escrowPayload: EscrowPayload) {
return {
operator: escrowPayload.paymentInfo.operator,
payer: escrowPayload.authorization.from,
receiver: escrowPayload.paymentInfo.receiver,
token: escrowPayload.paymentInfo.token,
maxAmount: BigInt(escrowPayload.paymentInfo.maxAmount),
preApprovalExpiry: escrowPayload.paymentInfo.preApprovalExpiry,
authorizationExpiry: escrowPayload.paymentInfo.authorizationExpiry,
refundExpiry: escrowPayload.paymentInfo.refundExpiry,
minFeeBps: escrowPayload.paymentInfo.minFeeBps,
maxFeeBps: escrowPayload.paymentInfo.maxFeeBps,
feeReceiver: escrowPayload.paymentInfo.feeReceiver,
salt: BigInt(escrowPayload.paymentInfo.salt),
}
}

/**
* Escrow Facilitator Scheme - implements x402's SchemeNetworkFacilitator
*
Expand Down Expand Up @@ -142,11 +163,20 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
}
}

// Verify amount meets requirements
if (BigInt(escrowPayload.authorization.value) < BigInt(requirements.amount)) {
// Verify amount exactly matches requirements
if (BigInt(escrowPayload.authorization.value) !== BigInt(requirements.amount)) {
return {
isValid: false,
invalidReason: 'insufficient_amount',
invalidReason: 'amount_mismatch',
payer,
}
}

// Verify authorization recipient is the token collector
if (escrowPayload.authorization.to.toLowerCase() !== extra.tokenCollector.toLowerCase()) {
return {
isValid: false,
invalidReason: 'token_collector_mismatch',
payer,
}
}
Expand All @@ -169,25 +199,53 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
}
}

// H4: Balance check — verify payer has sufficient token balance
// Simulate the settlement transaction via eth_call to catch issues before
// spending gas (balance, consumed nonces, domain mismatches, contract errors).
const settlementMethod = extra.settlementMethod ?? 'authorize'
const functionName = settlementMethod === 'charge' ? 'charge' : 'authorize'
const paymentInfo = buildPaymentInfo(escrowPayload)
const settlementArgs = [
paymentInfo,
BigInt(escrowPayload.authorization.value),
extra.tokenCollector,
escrowPayload.signature,
] as const

try {
const balance = await this.signer.readContract({
address: requirements.asset as `0x${string}`,
abi: ERC20_BALANCE_OF_ABI,
functionName: 'balanceOf',
args: [payer],
await this.signer.readContract({
address: extra.operatorAddress,
abi: OPERATOR_ABI,
functionName,
args: settlementArgs,
})
} catch {
// Simulation failed — check balance for a more actionable error
try {
const balance = (await this.signer.readContract({
address: requirements.asset as `0x${string}`,
abi: ERC20_BALANCE_OF_ABI,
functionName: 'balanceOf',
args: [payer],
})) as bigint

if (BigInt(balance as string) < BigInt(requirements.amount)) {
return {
isValid: false,
invalidReason: 'insufficient_balance',
payer,
if (balance < BigInt(requirements.amount)) {
return {
isValid: false,
invalidReason: 'insufficient_balance',
payer,
}
}
} catch {
// Balance check also failed (e.g., RPC outage)
}

// Hard reject on simulation failure — matches exact scheme behavior.
// Safer to reject than accept a payment that may revert on-chain.
return {
isValid: false,
invalidReason: 'simulation_failed',
payer,
}
} catch {
// If balance check fails (e.g., non-standard token), skip it.
// The on-chain transaction will fail anyway if balance is insufficient.
}

return {
Expand Down Expand Up @@ -215,41 +273,21 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {

const escrowPayload = payload.payload as unknown as EscrowPayload
const extra = requirements.extra as unknown as EscrowExtra
const { operatorAddress, tokenCollector } = extra

const paymentInfo = {
operator: escrowPayload.paymentInfo.operator,
payer: escrowPayload.authorization.from,
receiver: escrowPayload.paymentInfo.receiver,
token: escrowPayload.paymentInfo.token,
maxAmount: BigInt(escrowPayload.paymentInfo.maxAmount),
preApprovalExpiry: escrowPayload.paymentInfo.preApprovalExpiry,
authorizationExpiry: escrowPayload.paymentInfo.authorizationExpiry,
refundExpiry: escrowPayload.paymentInfo.refundExpiry,
minFeeBps: escrowPayload.paymentInfo.minFeeBps,
maxFeeBps: escrowPayload.paymentInfo.maxFeeBps,
feeReceiver: escrowPayload.paymentInfo.feeReceiver,
salt: BigInt(escrowPayload.paymentInfo.salt),
}

// Pass raw signature — ERC3009PaymentCollector/ERC6492SignatureHandler
// handles EIP-6492 unwrapping and wallet deployment on-chain
const collectorData = escrowPayload.signature

const target = operatorAddress
const paymentInfo = buildPaymentInfo(escrowPayload)
const settlementMethod = extra.settlementMethod ?? 'authorize'
const functionName = settlementMethod === 'charge' ? 'charge' : 'authorize'

try {
const txHash = await this.signer.writeContract({
address: target,
address: extra.operatorAddress,
abi: OPERATOR_ABI,
functionName,
args: [
paymentInfo,
BigInt(escrowPayload.authorization.value),
tokenCollector,
collectorData,
extra.tokenCollector,
escrowPayload.signature,
],
})

Expand Down
99 changes: 41 additions & 58 deletions specs/schemes/escrow/scheme_escrow.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,102 +2,85 @@

## 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).
`escrow` is a scheme that decouples authorization from final settlement. The client authorizes a maximum amount, and the facilitator settles — either holding funds in escrow (pre-settlement) or sending them directly to the receiver with post-settlement refund capability.

This scheme reuses audited commerce-payments contracts deployed on Base and other EVM chains.
Unlike `exact`, which transfers funds immediately and irrevocably, `escrow` supports refundable payments.

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.

Looks good to me, but make sure wording is what you expecting. The md file might have unnecessary information

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — the escrow abstract spec is longer than exact's, but escrow has more moving parts (two settlement paths, expiry tiers, fee system, post-settlement actions). I think the current content is justified. Happy to trim if you spot anything specific that feels redundant though.

## Example Use Cases

- Refundable payments with buyer protection
- Post-settlement refunds via the charge path
- Subscription / session billing with periodic captures
- Delayed delivery where the client needs recourse if the service is unsatisfactory
- Subscription or session billing with periodic captures against a single authorization

## Settlement Methods

The scheme supports two settlement paths through the commerce-payments operator:
The scheme supports two settlement paths:

| 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
| Method | Behavior |
| :---------- | :--------------------------------------------------------------------- |
| `authorize` | Funds held in escrow. Can be captured, refunded, voided, or reclaimed. |
| `charge` | Funds sent directly to receiver. Refundable post-settlement. |

### Authorize (default)

```
SIGN → AUTHORIZE → RESOURCE DELIVERED
AUTHORIZE → RESOURCE DELIVERED → CAPTURE / REFUND / VOID
```

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).
1. **Authorize**: Client authorization is submitted — funds locked in escrow
2. **Resource delivered**: Server returns the resource (HTTP 200)
3. **Post-settlement**: Escrowed funds can be captured (finalized to the receiver), refunded (returned to client), or voided (released before capture). If the capture deadline passes without action, the client can reclaim funds directly.

### Charge

```
SIGN → CHARGE → RESOURCE DELIVERED
CHARGE → RESOURCE DELIVERED → (REFUND)
```

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 |
1. **Charge**: Client authorization is submitted — funds sent directly to receiver
2. **Resource delivered**: Server returns the resource (HTTP 200)
3. **Post-settlement**: A refund can be issued within the refund window by the operator (which may be a smart contract or an authorized account). Since funds are already with the receiver, the client cannot unilaterally reclaim. This path trades the safety of pre-settlement escrow for simpler settlement, relying on the refund window as the buyer protection mechanism.

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
## Core Properties

### 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
- Cannot overcharge — settlement amount is capped by the client-signed maximum
- Authorize path: client can reclaim escrowed funds after the capture deadline if no action is taken
- Fee bounds are client-signed and enforced at settlement

### 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
- Each payment has a unique nonce derived from the payment parameters
- Nonce is consumed on-chain at settlement, preventing double-spend

### Expiry Enforcement

The contract enforces strict ordering: `preApprovalExpiry <= authorizationExpiry <= refundExpiry`
Three ordered deadlines govern the payment lifecycle:

- `preApprovalExpiry`: Deadline for the ERC-3009 signature (doubles as `validBefore`)
- `authorizationExpiry`: Deadline for capturing escrowed funds
- `refundExpiry`: Deadline for requesting refunds on captured payments
- **Authorization deadline**: Last moment to submit the client's authorization for settlement
- **Capture deadline**: Last moment to capture escrowed funds (authorize path); after this, the client can reclaim
- **Refund deadline**: Last moment to issue a refund on captured or charged payments

## Appendix
## Settlement Response

### Commerce Payments Protocol
On success, the `PAYMENT-RESPONSE` header contains a `SettleResponse` with the settlement transaction hash, network, payer address, and the full payment information from the client's original payload. Including payment information makes the response self-contained — the client can derive the payment nonce, query escrow state, and initiate post-settlement actions without retaining client-side state from the original request.

The escrow scheme is built on Base's [Commerce Payments Protocol](https://blog.base.dev/commerce-payments-protocol), which provides:
The structure of the payment information is network-specific — see per-network documents for details.

## Relationship to `exact`

| Aspect | `exact` | `escrow` |
| :--------- | :----------------- | :--------------------------------------------------------------- |
| Settlement | Immediate transfer | Via escrow (authorize) or direct with refund capability (charge) |
| Refundable | No | Yes (both paths) |
| Fee system | None | Configurable (min/max bounds, client-signed) |

## Appendix

- **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)
Network-specific implementation details (contracts, signature formats, verification logic) are in per-network documents: `scheme_escrow_evm.md` (EVM).

### 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)
43 changes: 38 additions & 5 deletions specs/schemes/escrow/scheme_escrow_evm.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,11 @@ The facilitator performs these checks in order:
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)
7. **Amount**: Verify `authorization.value === requirements.amount`
8. **Recipient match**: Verify `authorization.to === requirements.extra.tokenCollector`
9. **Token match**: Verify `paymentInfo.token === requirements.asset`
10. **Receiver match**: Verify `paymentInfo.receiver === requirements.payTo`
11. **Simulate** `operator.authorize(...)` or `operator.charge(...)` to ensure success

### EIP-6492 Support

Expand All @@ -151,14 +152,46 @@ Settlement is performed by the facilitator calling the operator:
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
5. **Return result**: Transaction hash, network, payer address, and `paymentInfo`

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`

## Settlement Response

On success, the `PAYMENT-RESPONSE` header contains a `SettleResponse` with the full `paymentInfo` from the client's original payload:

```json
{
"success": true,
"transaction": "0xabc...def",
"network": "eip155:8453",
"payer": "0xPayerAddress",
"paymentInfo": {
"operator": "0xOperatorAddress",
"receiver": "0xReceiverAddress",
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"maxAmount": "1000000",
"preApprovalExpiry": 1740672154,
"authorizationExpiry": 4294967295,
"refundExpiry": 281474976710655,
"minFeeBps": 0,
"maxFeeBps": 1000,
"feeReceiver": "0xOperatorAddress",
"salt": "0x0000...0001"
}
}
```

The client uses `paymentInfo` for post-settlement tracking:

- **Payment nonce**: Derived from `paymentInfo` — unique on-chain identifier for querying escrow state
- **Escrow address**: From `requirements.extra.escrowAddress` in the original request
- **Settlement method**: From `requirements.extra.settlementMethod` — determines available actions (capture/refund/void/reclaim vs refund only)

## Appendix

### PaymentInfo Struct
Expand Down