diff --git a/specs/schemes/authCapture/scheme_authCapture.md b/specs/schemes/authCapture/scheme_authCapture.md new file mode 100644 index 0000000..836a4cc --- /dev/null +++ b/specs/schemes/authCapture/scheme_authCapture.md @@ -0,0 +1,85 @@ +# Scheme: `authCapture` + +## Summary + +`authCapture` is a payment scheme where funds can be held and settled later. The client authorizes a maximum amount, and the facilitator submits it — either locking funds in escrow for later settlement (two-phase) or sending them directly to the receiver with refund capability (single-shot). + +The **captureAuthorizer** is the entity authorized to authorize, capture, void, refund, or charge a payment. In a facilitator-submits flow, that's either the facilitator itself or any smart contract that ends up calling the underlying escrow. + +Unlike `exact`, which has no built-in mechanism for returning funds, `authCapture` supports returning funds to the client through void, refund, and reclaim. + +## Example Use Cases + +- Refundable payments with buyer protection +- Delayed delivery where the client needs recourse if the service is unsatisfactory +- Subscription or session billing with periodic captures against a single authorization + +## Settlement Paths + +The scheme supports two settlement paths, selected via `extra.autoCapture`: + +| `autoCapture` | Behavior | +| :---------------- | :--------------------------------------------------------------------------------------------------------------------------- | +| `false` (default) | Two-phase. Funds held in escrow. CaptureAuthorizer can capture, void, refund. Client can reclaim if capture deadline passes. | +| `true` | Single-shot. Funds sent directly to receiver. CaptureAuthorizer can refund post-settlement. | + +### Two-phase (`autoCapture: false`, default) + +``` +AUTHORIZE → RESOURCE DELIVERED → CAPTURE / VOID → (REFUND) +``` + +1. **Authorize**: Client authorization is submitted — funds locked in escrow. +2. **Resource delivered**: Server returns the resource (HTTP 200). +3. **Capture or void**: The captureAuthorizer can capture (finalize funds to the receiver) or void (release escrowed funds back to client). +4. **Reclaim**: If the capture deadline passes without action, the client can reclaim directly. +5. **Refund**: After capture, the captureAuthorizer can refund within the refund window. + +### Single-shot (`autoCapture: true`) + +``` +CHARGE → RESOURCE DELIVERED → (REFUND) +``` + +1. **Charge**: Client authorization is submitted — funds sent directly to receiver. +2. **Resource delivered**: Server returns the resource (HTTP 200). +3. **Refund**: The captureAuthorizer can refund within the refund window. + +No capture, void, or reclaim — funds are never held in escrow. + +## Core Properties + +### Fund Safety + +- Cannot overcharge — settlement amount is capped by the client-signed maximum. +- Two-phase 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 + +- Each payment has a unique nonce derived from the payment parameters and a fresh client-generated salt. +- Nonce is consumed on-chain at settlement, preventing double-spend. + +### Expiry Enforcement + +Two absolute-timestamp deadlines govern the payment lifecycle (network-specific implementations may add a derived pre-approval expiry from `maxTimeoutSeconds`): + +- **Capture deadline** (`captureDeadline`): Last moment to capture escrowed funds (two-phase); after this, the client can reclaim. +- **Refund deadline** (`refundDeadline`): Last moment to issue a refund on captured or charged payments. + +## Relationship to `exact` + +| Aspect | `exact` | `authCapture` | +| :--------- | :----------------- | :-------------------------------------------------------------------- | +| Settlement | Immediate transfer | Via escrow (two-phase) or direct with refund capability (single-shot) | +| Refundable | No | Yes (both paths) | +| Fee system | None | Configurable (min/max bounds, client-signed) | + +## Appendix + +Network-specific implementation details (contracts, signature formats, verification logic) are in per-network documents: `scheme_authCapture_evm.md` (EVM). + +### References + +- [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/authCapture/scheme_authCapture_evm.md b/specs/schemes/authCapture/scheme_authCapture_evm.md new file mode 100644 index 0000000..55d8fee --- /dev/null +++ b/specs/schemes/authCapture/scheme_authCapture_evm.md @@ -0,0 +1,317 @@ +# Scheme: `authCapture` on `EVM` + +## Summary + +The `authCapture` scheme on EVM uses the [base/commerce-payments](https://github.com/base/commerce-payments) contract stack: + +- **AuthCaptureEscrow**: Singleton — locks funds, enforces expiries, distributes on capture/refund. Universal canonical address (CREATE2-deployed; same address on every supported chain). +- **Token Collectors**: Universal canonical addresses, one per `assetTransferMethod`: + - `EIP3009_TOKEN_COLLECTOR_ADDRESS` — collects funds via `receiveWithAuthorization` signatures (USDC, EURC, etc.) + - `PERMIT2_TOKEN_COLLECTOR_ADDRESS` — collects funds via Uniswap Permit2 `permitTransferFrom` (any ERC-20) +- **`captureAuthorizer`**: Address authorized to authorize, capture, void, refund, or charge a payment. The escrow contract gates those operations on `msg.sender` matching this address. In x402's facilitator-submits flow that means either **the facilitator's EOA**, or **any smart contract** that ends up calling the escrow (e.g., an arbiter contract with dispute logic, a multisig, etc.). + +The client signs a single signature (ERC-3009 or Permit2). The facilitator calls `AuthCaptureEscrow.authorize()` (two-phase) or `AuthCaptureEscrow.charge()` (single-shot via `autoCapture: true`), either directly or through a smart contract set as the captureAuthorizer. + +## PaymentRequirements + +AuthCapture-accepting servers advertise with scheme `authCapture`: + +```json +{ + "x402Version": 2, + "accepts": [ + { + "scheme": "authCapture", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2", + "captureAuthorizer": "0xCaptureAuthorizerAddress", + "captureDeadline": 1740758554, + "refundDeadline": 1741276954, + "minFeeBps": 0, + "maxFeeBps": 1000, + "feeRecipient": "0xFeeRecipientAddress", + "autoCapture": false, + "assetTransferMethod": "eip3009" + } + } + ] +} +``` + +### `extra` Fields + +| Field | Required | Type | Description | +| :-------------------- | :------- | :----------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | Yes | `string` | EIP-712 token-domain name (e.g., `"USDC"`). Used for ERC-3009 signing only. | +| `version` | Yes | `string` | EIP-712 token-domain version (e.g., `"2"`). | +| `captureAuthorizer` | Yes | `address` | Address authorized to authorize/capture/void/refund/charge. Committed on-chain as `PaymentInfo.operator`. | +| `captureDeadline` | Yes | `uint48` | Absolute Unix seconds — capture must occur before this. Encoded as `authorizationExpiry`. | +| `refundDeadline` | Yes | `uint48` | Absolute Unix seconds — refunds allowed until this. Encoded as `refundExpiry`. | +| `feeRecipient` | Yes | `address` | Fee recipient (committed on-chain as `PaymentInfo.feeReceiver`). Set to `address(0)` to let the captureAuthorizer specify any non-zero recipient at capture/charge time. | +| `minFeeBps` | Yes | `uint16` | Minimum fee in basis points (the fee floor the captureAuthorizer must take). `0` = no minimum. | +| `maxFeeBps` | Yes | `uint16` | Maximum fee in basis points (the cap on the captureAuthorizer's fee). | +| `autoCapture` | No | `bool` | `true` → facilitator calls `charge()` (atomic). `false` → `authorize()` (two-phase). Default: `false`. | +| `assetTransferMethod` | No | `"eip3009" \| "permit2"` | Which token collector to use. Default: `"eip3009"`. A server MAY list multiple `accepts[]` entries with different `assetTransferMethod` values so clients can pick the method matching their token approvals. | + +**Universal contract addresses** (same on every supported EVM chain via deterministic CREATE2): + +| Constant | Address | +| :------------------------------------ | :------------------------------------------- | +| `AUTH_CAPTURE_ESCROW_ADDRESS` | `0xF8211868187974a7Fb9d99b8fFB171AD70665Dc6` | +| `EIP3009_TOKEN_COLLECTOR_ADDRESS` | `0x7561DC178D9aD5bc5fb103C01f448A510d2A36D0` | +| `PERMIT2_TOKEN_COLLECTOR_ADDRESS` | `0xD8490609d2da0ee626b0e676941b225cbc1A8C08` | +| `PERMIT2_ADDRESS` (Uniswap canonical) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | + +See [Canonical Addresses](#canonical-addresses) for the salt scheme. + +### Spec → on-chain field name mapping + +The wire-format extra uses spec-level field names. The on-chain `PaymentInfo` struct keeps canonical Solidity names so the EIP-712 typehash matches the AuthCaptureEscrow contract byte-for-byte. + +| Wire (`extra`) | On-chain (`PaymentInfo`) | +| :----------------------------------- | :----------------------- | +| `captureAuthorizer` | `operator` | +| `captureDeadline` | `authorizationExpiry` | +| `refundDeadline` | `refundExpiry` | +| `feeRecipient` | `feeReceiver` | +| (derived: `now + maxTimeoutSeconds`) | `preApprovalExpiry` | + +## PaymentPayload + +The payload carries the signature and the client-generated `salt`. The facilitator reconstructs the full `PaymentInfo` from `extra` + `salt` + payer + top-level requirements (`payTo`, `asset`, `amount`). + +### EIP-3009 (default) + +```json +{ + "x402Version": 2, + "resource": { "url": "https://api.example.com/resource", "method": "GET" }, + "accepted": { "scheme": "authCapture", "...": "..." }, + "payload": { + "authorization": { + "from": "0xPayerAddress", + "to": "0xEIP3009TokenCollectorAddress", + "value": "1000000", + "validAfter": "0", + "validBefore": "1740675754", + "nonce": "0xf374...3480" + }, + "signature": "0x2d6a...571c", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc" + } +} +``` + +**Field derivation (EIP-3009):** + +| Payload field | Derived from | +| :-------------------------- | :---------------------------------------------------------------------------------------------------------- | +| `authorization.from` | Client's own address | +| `authorization.to` | `EIP3009_TOKEN_COLLECTOR_ADDRESS` (universal constant) | +| `authorization.value` | `requirements.amount` | +| `authorization.validAfter` | `0` (the token collector hardcodes the lower bound) | +| `authorization.validBefore` | `now + requirements.maxTimeoutSeconds` (also used as `preApprovalExpiry` when reconstructing `PaymentInfo`) | +| `authorization.nonce` | Payer-agnostic `PaymentInfo` hash — see [Nonce Derivation](#nonce-derivation-both-methods) | +| `salt` | Fresh `bytes32` generated client-side per signing call | +| EIP-712 domain | `{ name, version }` from `extra`; `chainId` from `network`; `verifyingContract = requirements.asset` | + +### Permit2 + +```json +{ + "x402Version": 2, + "resource": { "url": "https://api.example.com/resource", "method": "GET" }, + "accepted": { "scheme": "authCapture", "...": "..." }, + "payload": { + "permit2Authorization": { + "from": "0xPayerAddress", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "1000000" + }, + "spender": "0xPermit2TokenCollectorAddress", + "nonce": "110210486920734568342928534950928740912034856789012345678901234567890123456789", + "deadline": "1740675754" + }, + "signature": "0x2d6a...571c", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc" + } +} +``` + +**Field derivation (Permit2):** + +| Payload field | Derived from | +| :-------------------------------------- | :---------------------------------------------------------------------------------------------------------- | +| `permit2Authorization.from` | Client's own address | +| `permit2Authorization.permitted.token` | `requirements.asset` | +| `permit2Authorization.permitted.amount` | `requirements.amount` | +| `permit2Authorization.spender` | `PERMIT2_TOKEN_COLLECTOR_ADDRESS` (universal constant) | +| `permit2Authorization.nonce` | `uint256(payerAgnosticPaymentInfoHash)` — see [Nonce Derivation](#nonce-derivation-both-methods) | +| `permit2Authorization.deadline` | `now + requirements.maxTimeoutSeconds` (also used as `preApprovalExpiry` when reconstructing `PaymentInfo`) | +| `salt` | Fresh `bytes32` generated client-side per signing call | +| EIP-712 domain | Canonical Permit2 contract; `chainId` from `network` | + +**No witness** — the merchant address is bound through the deterministic nonce, not a separate witness struct. + +### Nonce Derivation (both methods) + +The signature nonce is the payer-agnostic `PaymentInfo` hash. Payer is zeroed; everything else is the values that will appear on-chain. + +``` +paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfoWithZeroPayer)) +nonce = keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash)) +``` + +Freshness is enforced by `salt`: each signing call generates a fresh `bytes32` salt, so two payers signing concurrently produce distinct nonces with no collision risk. + +## Verification Logic + +The facilitator performs these checks in order: + +1. **Type guard**: Verify payload matches one of `Eip3009Payload` or `Permit2Payload` (must include `signature` and `salt`). +2. **Scheme match**: `requirements.scheme === "authCapture"` and `payload.accepted.scheme === "authCapture"`. +3. **Network match**: `payload.accepted.network === requirements.network` and format is `eip155:`. +4. **Extra validation**: `requirements.extra` contains all required fields (`captureAuthorizer`, `captureDeadline`, `refundDeadline`, `feeRecipient`, `minFeeBps`, `maxFeeBps`, `name`, `version`). +5. **Method routing**: `extra.assetTransferMethod` (default `"eip3009"`) matches the payload shape. +6. **Deadline ordering**: `refundDeadline >= captureDeadline`, `captureDeadline > now + 6s`, and `payload.validBefore` (EIP-3009) / `payload.deadline` (Permit2) `<= captureDeadline`. +7. **Time window**: `payload.deadline / validBefore > now + 6s` (not expired) and `validAfter <= now` (active, EIP-3009 only). +8. **Spender / collector match**: `payload.to === EIP3009_TOKEN_COLLECTOR_ADDRESS` (EIP-3009) or `payload.spender === PERMIT2_TOKEN_COLLECTOR_ADDRESS` (Permit2). +9. **Token match**: `payload.permitted.token === requirements.asset` (Permit2 only — EIP-3009 binds via signing domain). +10. **Signature verify**: Recover signer from EIP-712 (`ReceiveWithAuthorization` or `PermitTransferFrom`); must match `payer`. +11. **Amount**: `authorization.value` (EIP-3009) or `permit2Authorization.permitted.amount` (Permit2) matches `requirements.amount`. +12. **Nonce match**: Reconstruct `PaymentInfo` from extra + payload.salt + payer + requirements; recompute payer-agnostic hash; assert it matches the wire nonce. This transitively enforces equality on every field encoded in `PaymentInfo` (receiver, token, deadlines, fee bounds, feeRecipient), so individual field-by-field checks for those values are unnecessary. +13. **Simulate** `AUTH_CAPTURE_ESCROW.authorize(...)` or `.charge(...)` to ensure success. + +### 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 + +1. **Re-verify** the payload (catches expired/invalid payloads before spending gas). +2. **Determine function**: `extra.autoCapture === true ? "charge" : "authorize"`. +3. **Resolve collector**: `EIP3009_TOKEN_COLLECTOR_ADDRESS` or `PERMIT2_TOKEN_COLLECTOR_ADDRESS` (per `assetTransferMethod`). +4. **Encode `collectorData`**: raw ERC-3009 signature, or ABI-encoded Permit2 signature. +5. **Call escrow**: `AUTH_CAPTURE_ESCROW.(paymentInfo, amount, tokenCollector, collectorData)`. +6. **Wait for receipt**: 60s timeout. +7. **Return result**: tx hash, network, payer. + +## Error Codes + +The authCapture scheme uses the standard x402 error codes plus these scheme-specific codes: + +### Verification Errors + +| Error Code | Description | +| :---------------------------------- | :-------------------------------------------------------------------------------- | +| `invalid_payload_format` | Payload doesn't match `Eip3009Payload` or `Permit2Payload`. | +| `unsupported_scheme` | Scheme is not `authCapture`. | +| `network_mismatch` | Payload network doesn't match requirements. | +| `invalid_network` | Network format is not `eip155:`. | +| `invalid_authCapture_extra` | Extra is missing required fields. | +| `unsupported_asset_transfer_method` | `assetTransferMethod` is not `"eip3009"` or `"permit2"`. | +| `payload_method_mismatch` | Payload shape doesn't match `assetTransferMethod`. | +| `capture_deadline_expired` | `captureDeadline <= now + 6s`. | +| `invalid_deadline_ordering` | Deadlines violate `now + maxTimeoutSeconds <= captureDeadline <= refundDeadline`. | +| `authorization_expired` | EIP-3009 `validBefore` (or Permit2 `deadline`) `<= now + 6s`. | +| `authorization_not_yet_valid` | EIP-3009 `validAfter > now`. | +| `invalid_authCapture_signature` | Signature verification failed. | +| `amount_mismatch` | Authorization value doesn't match `requirements.amount`. | +| `token_collector_mismatch` | `to` / `spender` doesn't match the canonical collector for the method. | +| `token_mismatch` | Permit2 `permitted.token` doesn't match `requirements.asset`. | +| `nonce_mismatch` | Wire nonce doesn't match the recomputed payer-agnostic PaymentInfo hash. | +| `insufficient_balance` | Payer balance is less than required amount. | +| `simulation_failed` | Settlement simulation reverted with an unmapped error. | + +### Typed simulation reverts + +If the simulate call reverts with an `AuthCaptureEscrow` custom error declared in the call's ABI, the facilitator decodes it via `BaseError.walk()` + `ContractFunctionRevertedError` and surfaces a stable reason instead of the opaque `simulation_failed` fallback: + +| Custom error | `invalidReason` | +| :------------------------------ | :------------------------------------ | +| `AfterPreApprovalExpiry` | `authorization_expired` | +| `InvalidExpiries` | `invalid_deadline_ordering` | +| `ExceedsMaxAmount` | `amount_mismatch` | +| `PaymentAlreadyCollected` | `payment_already_collected` | +| `TokenCollectionFailed` | `token_collection_failed` | +| `InvalidCollectorForOperation` | `invalid_collector` | +| `InvalidSender` | `invalid_capture_authorizer` | +| `ZeroAmount` / `AmountOverflow` | `amount_mismatch` / `amount_overflow` | +| `FeeBpsOverflow` | `invalid_fee_bps` | +| `InvalidFeeBpsRange` | `invalid_fee_bps_range` | +| `FeeBpsOutOfRange` | `fee_bps_out_of_range` | +| `ZeroFeeReceiver` | `zero_fee_receiver` | +| `InvalidFeeReceiver` | `invalid_fee_receiver` | +| `AfterAuthorizationExpiry` | `capture_deadline_expired` | +| `InsufficientAuthorization` | `insufficient_authorization` | +| `ZeroAuthorization` | `zero_authorization` | + +### Settlement Errors + +| Error Code | Description | +| :--------------------- | :------------------------------------------------ | +| `verification_failed` | Re-verification before settlement failed. | +| `transaction_reverted` | On-chain transaction reverted after confirmation. | + +## Appendix + +### PaymentInfo Struct (canonical Solidity — wire-level field names map per the table above) + +```solidity +struct PaymentInfo { + address operator; // = extra.captureAuthorizer + address payer; // payload-derived + address receiver; // = requirements.payTo + address token; // = requirements.asset + uint120 maxAmount; // = requirements.amount + uint48 preApprovalExpiry; // = now + maxTimeoutSeconds (client-derived) + uint48 authorizationExpiry; // = extra.captureDeadline + uint48 refundExpiry; // = extra.refundDeadline + uint16 minFeeBps; + uint16 maxFeeBps; + address feeReceiver; // = extra.feeRecipient + uint256 salt; // = payload.salt (client-generated, fresh per request) +} +``` + +### Expiry Ordering + +The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry`. + +| Expiry | Wire field | Enforced at | Effect | +| :-------------------- | :---------------- | :------------------------- | :---------------------------------- | +| `preApprovalExpiry` | derived | `authorize()` / `charge()` | Blocks settlement after this time | +| `authorizationExpiry` | `captureDeadline` | `capture()` | Blocks capture; enables `reclaim()` | +| `refundExpiry` | `refundDeadline` | `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` (`extra.feeRecipient`) 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 + +### Canonical Addresses + +> **Requirement**: The escrow and token collectors are deployed at the same address across every supported EVM chain via deterministic CREATE2. Bytecode is byte-identical (locked compiler, optimizer, and dependency pins); anyone with the source can reproduce and verify the addresses, and any first-mover deployer who broadcasts the canonical bytecode at the canonical salt lands at the same address. + +**Source**: [base/commerce-payments@v1.0.0](https://github.com/base/commerce-payments/releases/tag/v1.0.0). + +**Salt scheme**: `bytes20(0) || 0x00 || bytes11(keccak256(label))`. The leading 21 bytes are constant; the label is the per-contract namespace below. + +| Constant | Salt label | Canonical Address | +| :------------------------------------ | :------------------------------------------------ | :------------------------------------------- | +| `AUTH_CAPTURE_ESCROW_ADDRESS` | `commerce-payments::v1::AuthCaptureEscrow` | `0xF8211868187974a7Fb9d99b8fFB171AD70665Dc6` | +| `EIP3009_TOKEN_COLLECTOR_ADDRESS` | `commerce-payments::v1::ERC3009PaymentCollector` | `0x7561DC178D9aD5bc5fb103C01f448A510d2A36D0` | +| `PERMIT2_TOKEN_COLLECTOR_ADDRESS` | `commerce-payments::v1::Permit2PaymentCollector` | `0xD8490609d2da0ee626b0e676941b225cbc1A8C08` | +| `PERMIT2_ADDRESS` (Uniswap canonical) | (Uniswap canonical, not CREATE2'd by this scheme) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | diff --git a/specs/schemes/commerce/scheme_commerce.md b/specs/schemes/commerce/scheme_commerce.md deleted file mode 100644 index 12e5f67..0000000 --- a/specs/schemes/commerce/scheme_commerce.md +++ /dev/null @@ -1,86 +0,0 @@ -# Scheme: `commerce` - -## Summary - -`commerce` is a payment scheme where funds can be held and settled later. The client authorizes a maximum amount, and the facilitator submits it — either locking funds in escrow for later settlement (authorize) or sending them directly to the receiver with refund capability (charge). - -The **operator** is the entity that routes funds and manages the payment lifecycle (capture, refund, void). It may be the facilitator itself, a separate authorized account, or a smart contract — depending on the network and implementation. - -Unlike `exact`, which has no built-in mechanism for returning funds, `commerce` supports returning funds to the client through void, refund, and reclaim. - -## Example Use Cases - -- Refundable payments with buyer protection -- 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: - -| 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) - -``` -AUTHORIZE → RESOURCE DELIVERED → CAPTURE / VOID → (REFUND) -``` - -1. **Authorize**: Client authorization is submitted — funds locked in escrow -2. **Resource delivered**: Server returns the resource (HTTP 200) -3. **Capture or void**: The operator can capture (finalize funds to the receiver) or void (release escrowed funds back to client). -4. **Reclaim**: If the capture deadline passes without action, the client can reclaim directly. -5. **Refund**: After capture, the operator can refund within the refund window. - -### Charge - -``` -CHARGE → RESOURCE DELIVERED → (REFUND) -``` - -1. **Charge**: Client authorization is submitted — funds sent directly to receiver -2. **Resource delivered**: Server returns the resource (HTTP 200) -3. **Refund**: The operator can refund within the refund window. - -No capture, void, or reclaim — funds are never held in escrow. - -## Core Properties - -### Fund Safety - -- 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 - -- Each payment has a unique nonce derived from the payment parameters -- Nonce is consumed on-chain at settlement, preventing double-spend - -### Expiry Enforcement - -Three ordered deadlines govern the payment lifecycle: - -- **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 - -## Relationship to `exact` - -| Aspect | `exact` | `commerce` | -| :--------- | :----------------- | :--------------------------------------------------------------- | -| 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 - -Network-specific implementation details (contracts, signature formats, verification logic) are in per-network documents: `scheme_commerce_evm.md` (EVM). - -### References - -- [Commerce Scheme Proposal — Agentokratia (Issue #834)](https://github.com/coinbase/x402/issues/834) -- [Commerce Scheme Proposal — x402r (Issue #1011)](https://github.com/coinbase/x402/issues/1011) diff --git a/specs/schemes/commerce/scheme_commerce_evm.md b/specs/schemes/commerce/scheme_commerce_evm.md deleted file mode 100644 index a1d39ad..0000000 --- a/specs/schemes/commerce/scheme_commerce_evm.md +++ /dev/null @@ -1,238 +0,0 @@ -# Scheme: `commerce` on `EVM` - -## Summary - -The `commerce` 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. - -The commerce scheme uses ERC-3009 (`receiveWithAuthorization`) exclusively. The commerce-payments token collector architecture supports pluggable collection methods; future collectors (e.g., Permit2) could be added via `assetTransferMethod` in `extra` without changing the scheme. - -## PaymentRequirements - -Commerce-accepting servers advertise with scheme `commerce`: - -```json -{ - "x402Version": 2, - "accepts": [ - { - "scheme": "commerce", - "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", - "preApprovalExpirySeconds": 3600, - "authorizationExpirySeconds": 86400, - "refundExpirySeconds": 604800 - } - } - ] -} -``` - -### `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` | Seconds until pre-approval / ERC-3009 sig expires | -| `authorizationExpirySeconds` | No | `uint48` | Seconds until capture deadline for escrowed funds | -| `refundExpirySeconds` | No | `uint48` | Seconds until refund request deadline | - -## PaymentPayload - -```json -{ - "x402Version": 2, - "resource": { - "url": "https://api.example.com/resource", - "method": "GET" - }, - "accepted": { - "scheme": "commerce", - "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": "1740675754", - "nonce": "0xf374...3480" - }, - "signature": "0x2d6a...571c", - "paymentInfo": { - "operator": "0xOperatorAddress", - "receiver": "0xReceiverAddress", - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "maxAmount": "1000000", - "preApprovalExpiry": 1740675754, - "authorizationExpiry": 1740758554, - "refundExpiry": 1741276954, - "minFeeBps": 0, - "maxFeeBps": 1000, - "feeReceiver": "0xOperatorAddress", - "salt": "0x0000...0001" - } - } -} -``` - -### Nonce Derivation - -The ERC-3009 nonce is deterministically derived from the payment parameters: - -``` -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 === "commerce"` and `payload.accepted.scheme === "commerce"` -3. **Network match**: Verify `payload.accepted.network === requirements.network` and format is `eip155:` -4. **Extra validation**: Verify `requirements.extra` contains required commerce 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. **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 - -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` - -## Error Codes - -The commerce scheme uses the standard x402 error codes plus these scheme-specific codes: - -### Verification Errors - -| Error Code | Description | -| :---------------------------- | :----------------------------------------------------------------------------------- | -| `invalid_payload_format` | Payload missing `authorization`, `signature`, or `paymentInfo` | -| `unsupported_scheme` | Scheme is not `commerce` | -| `network_mismatch` | Payload network does not match requirements | -| `invalid_network` | Network format is not `eip155:` | -| `invalid_commerce_extra` | Missing required extra fields (`escrowAddress`, `operatorAddress`, `tokenCollector`) | -| `authorization_expired` | `validBefore <= now + 6s` | -| `authorization_not_yet_valid` | `validAfter > now` | -| `invalid_commerce_signature` | ERC-3009 signature verification failed | -| `amount_mismatch` | `authorization.value !== requirements.amount` | -| `token_collector_mismatch` | `authorization.to !== extra.tokenCollector` | -| `token_mismatch` | `paymentInfo.token !== requirements.asset` | -| `receiver_mismatch` | `paymentInfo.receiver !== requirements.payTo` | -| `insufficient_balance` | Payer balance is less than required amount | -| `simulation_failed` | Settlement simulation via `eth_call` failed | - -### Settlement Errors - -| Error Code | Description | -| :--------------------- | :----------------------------------------------- | -| `verification_failed` | Re-verification before settlement failed | -| `transaction_reverted` | On-chain transaction reverted after confirmation | - -## 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