From 70bb64874c8e2fd4e810093c71a1d940293effd8 Mon Sep 17 00:00:00 2001 From: Marvin Krause Date: Mon, 27 Jul 2026 15:54:43 +0200 Subject: [PATCH 1/3] Make payment gate work with x402 v2 specification --- docs/architecture.md | 29 +- docs/x402-specification-v2.md | 740 ++++++++++++++++++ lib/x402/facilitator.ex | 6 +- lib/x402/plug/payment_gate.ex | 761 +++++++++++++++---- test/x402/facilitator_test.exs | 32 +- test/x402/plug/payment_gate_test.exs | 1037 +++++++++++++++++--------- 6 files changed, 2104 insertions(+), 501 deletions(-) create mode 100644 docs/x402-specification-v2.md diff --git a/docs/architecture.md b/docs/architecture.md index fc60ec9..dc351cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,19 +42,21 @@ Incoming HTTP request ▼ X402.Plug.PaymentGate │ - ├─ No PAYMENT-SIGNATURE header? → 402 response (PAYMENT-REQUIRED header) + ├─ No PAYMENT-SIGNATURE? → 402 + PAYMENT-REQUIRED (v2 PaymentRequired) │ - └─ Signature present? + └─ PAYMENT-SIGNATURE present? │ - ├─ Call X402.Facilitator.verify/2 - │ └─ POST /verify to facilitator URL + ├─ Decode PaymentPayload (x402Version must be 2) + │ malformed / wrong version → 400 + PAYMENT-REQUIRED │ - ├─ Verify OK? → Call X402.Facilitator.settle/2 - │ └─ POST /settle to facilitator URL + ├─ Match payload.accepted to route accepts + │ (scheme, network, amount, asset, payTo) + │ no match → 402 + PAYMENT-REQUIRED │ - ├─ Hooks: before_verify → after_verify → on_failure + ├─ Facilitator.verify → Facilitator.settle + │ failure → 402 (+ PAYMENT-RESPONSE when settle body present) │ - └─ Pass through to app handler (conn) + └─ Success → PAYMENT-RESPONSE, assigns, pass through ``` ## Optional Dependencies @@ -67,13 +69,16 @@ X402.Plug.PaymentGate All optional deps are guarded by compile-time checks. Must `mix compile --no-optional-deps` successfully. -## Data Formats +## Data Formats (x402 v2) All x402 headers carry **Base64-encoded JSON payloads**: -- `PAYMENT-REQUIRED`: `{scheme, network, maxAmountRequired, payTo, asset, extra}` -- `PAYMENT-SIGNATURE`: `{x402Version, scheme, network, payload, authorization}` -- `PAYMENT-RESPONSE`: `{success, transaction, networkId, errorReason?}` +- `PAYMENT-REQUIRED`: `{x402Version: 2, error?, resource, accepts[], extensions?}` + - Each accept: `{scheme, network, amount, asset, payTo, maxTimeoutSeconds, extra?}` + - `resource`: `{url, description?, mimeType?, serviceName?, tags?, iconUrl?}` +- `PAYMENT-SIGNATURE` (PaymentPayload): `{x402Version: 2, resource?, accepted, payload, extensions?}` + - `accepted` is a full PaymentRequirements object (must match a server accept) +- `PAYMENT-RESPONSE` (SettleResponse): `{success, transaction, network, payer?, amount?, errorReason?, extensions?}` Network IDs use CAIP-2 format: `"eip155:8453"` (Base mainnet), `"eip155:84532"` (Base Sepolia). diff --git a/docs/x402-specification-v2.md b/docs/x402-specification-v2.md new file mode 100644 index 0000000..079a4c6 --- /dev/null +++ b/docs/x402-specification-v2.md @@ -0,0 +1,740 @@ +# X402 Protocol Specification + +**Protocol Version**: 2 + +**Document Scope** + +This specification defines the core x402 protocol for internet-native payments. It covers: + +- **Protocol fundamentals**: Payment requirements format, payment payload structure, and core message schemas +- **Facilitator interface**: Standard APIs for payment verification and settlement +- **Payment schemes**: Extensible payment methods (including `exact`, `upto`, and `batch-settlement`; see `specs/schemes/`) +- **Security considerations**: Replay attack prevention and trust minimization + +**Out of Scope**: This specification does not include: + +- Transport-specific implementations (covered in transport specifications) +- Specific implementation patterns (covered in application notes) +- Framework-specific integrations +- Client-side budget management +- Session handling mechanisms + +**Architecture** + +x402 is made up of three core components: + +1. **Types**: Core data structures (e.g., `PaymentRequirements`, `PaymentPayload`, `SettlementResponse`) that are independent of both transport mechanism and payment scheme +2. **Logic**: Payment formation and verification logic that depends on the payment scheme (e.g., exact, upto, batch-settlement) and network (e.g., evm, solana, etc.) +3. **Representation**: How payment data is transmitted and signaled, which depends on the transport mechanism (e.g., HTTP, MCP, A2A) + +**1. Overview** + +x402 is an open payment standard that enables clients to pay for external resources. The protocol defines standardized message formats and payment flows that can be implemented over various transport layers, providing a standardized mechanism for payments across different payment schemes, networks and transport layers. + +This specification is based on the x402 protocol implementation and documentation available in the [x402 repository](https://github.com/x402-foundation/x402). It aims to provide a comprehensive and implementation-agnostic specification for the x402 protocol. + +**2. Core Payment Flow** + +The x402 protocol follows a standard request-response cycle with payment integration: + +1. **Client Request**: Client makes a request to a resource server +2. **Payment Required Response**: If no valid payment is attached, the server responds with a payment required signal and payment requirements +3. **Payment Authorization Request**: Client submits a signed payment authorization in the subsequent request +4. **Settlement Response**: Server verifies the payment authorization and initiates blockchain settlement + +**3. Protocol Components** + +The x402 protocol involves three primary components: + +- **Resource Server**: A service that requires payment for access to protected resources (APIs, content, data, etc.) +- **Client**: Any application or agent that requests access to protected resources +- **Facilitator**: A service that handles payment verification and blockchain settlement + +**4. Response Types** + +The x402 protocol defines standard response types with specific semantics: + +- **Success**: Request successful, payment verified and settled +- **Payment Required**: Payment required to access the resource +- **Invalid Request**: Invalid payment payload or payment requirements +- **Server Error**: Server error during payment processing + +Transport-specific implementations map these response types to appropriate transport mechanisms (e.g., HTTP status codes, JSON-RPC error codes, etc.). + +**5. Types** + +This section defines the core data structures used in the x402 protocol. These are completely independent of both transport mechanism and payment scheme. All transports and schemes use these exact data structures, differing only in how they represent them (transport layer) and what validation/settlement logic they apply (scheme layer). + +**5.1 PaymentRequired Schema** + +**5.1.1 JSON Payload** + +When a resource server requires payment, it responds with a payment required signal containing the `PaymentRequired` object. The transport defines where this object is carried. For HTTP, the canonical wire location is the base64-encoded `PAYMENT-REQUIRED` response header, see [HTTP Payment Required Signaling](./transports-v2/http.md#payment-required-signaling). + +Example `PaymentRequired` object: + +```json +{ + "x402Version": 2, + "error": "PAYMENT-SIGNATURE header is required", + "resource": { + "url": "https://api.example.com/premium-data", + "description": "Access to premium market data", + "mimeType": "application/json", + "serviceName": "Example Market Data", + "tags": ["market-data", "finance"], + "iconUrl": "https://api.example.com/icon.png" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + } + ], + "extensions": {} +} +``` + +**5.1.2 Field Descriptions** + +The `PaymentRequired` schema contains the following fields: + +| Field Name | Type | Required | Description | +| ------------- | -------- | -------- | ---------------------------------------------------------------------- | +| `x402Version` | `number` | Required | Protocol version identifier (must be 2) | +| `error` | `string` | Optional | Human-readable error message explaining why payment is required | +| `resource` | `object` | Required | ResourceInfo object describing the protected resource | +| `accepts` | `array` | Required | Array of payment requirement objects defining acceptable payment methods | +| `extensions` | `object` | Optional | Protocol extensions data | + +Each `PaymentRequirements` object in the `accepts` array contains: + +| Field Name | Type | Required | Description | +| ------------------- | -------- | -------- |---------------------------------------------------------------------------------------------------------------------------| +| `scheme` | `string` | Required | Payment scheme identifier (e.g., "exact") | +| `network` | `string` | Required | Blockchain network identifier in CAIP-2 format (e.g., "eip155:84532") | +| `amount` | `string` | Required | Required payment amount in atomic token units | +| `asset` | `string` | Required | Token contract address or ISO 4217 currency code for fiat | +| `payTo` | `string` | Required | Recipient wallet address or role constant (e.g., "merchant") | +| `maxTimeoutSeconds` | `number` | Required | Maximum time allowed for payment completion | +| `extra` | `object` | Optional | Scheme-specific additional information | + +The `ResourceInfo` object contains: + +| Field Name | Type | Required | Description | +| --------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `url` | `string` | Required | URL of the protected resource | +| `description` | `string` | Optional | Human-readable description of the resource | +| `mimeType` | `string` | Optional | MIME type of the expected response | +| `serviceName` | `string` | Optional | Human-readable name of the service hosting the resource. Printable ASCII, max 32 characters. | +| `tags` | `array[string]` | Optional | Topical tags for the service, used for discovery filtering. Max 5 entries; each printable ASCII, max 32 characters. | +| `iconUrl` | `string` | Optional | Absolute `https`/`http` URL to an icon representing the service. Max 2048 characters. | + +The `Extensions` object is a key-value map where each key is an extension identifier and each value follows a standardized structure: + +| Field Name | Type | Required | Description | +| ---------- | -------- | -------- | -------------------------------------------------------- | +| `info` | `object` | Required | Extension-specific data provided by the server | +| `schema` | `object` | Required | JSON Schema defining the expected structure of `info` | + +Extensions enable modular optional functionality beyond core payment mechanics. Servers advertise supported extensions in `PaymentRequired`, and clients echo them in `PaymentPayload`. The client must include at least the info received; it may append additional info but cannot delete or overwrite existing info. + +**5.2 PaymentPayload Schema** + +**5.2.1 JSON Structure** + +The client includes payment authorization as JSON in the payment payload field: + +```json +{ + "x402Version": 2, + "resource": { + "url": "https://api.example.com/premium-data", + "description": "Access to premium market data", + "mimeType": "application/json" + }, + "accepted": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "payload": { + "signature": "0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c", + "authorization": { + "from": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "to": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "value": "10000", + "validAfter": "1740672089", + "validBefore": "1740672154", + "nonce": "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480" + } + }, + "extensions": {} +} +``` + +**5.2.2 Field Descriptions** + +The `PaymentPayload` schema contains the following fields: + +| Field Name | Type | Required | Description | +| ------------- | -------- | -------- | ------------------------------------------------------------------- | +| `x402Version` | `number` | Required | Protocol version identifier | +| `resource` | `object` | Optional | ResourceInfo object describing the resource being accessed | +| `accepted` | `object` | Required | PaymentRequirements object indicating the payment method chosen | +| `payload` | `object` | Required | Scheme-specific payment data | +| `extensions` | `object` | Optional | Protocol extensions data | + +The `accepted` field contains a `PaymentRequirements` object (see section 5.1.2). + +The `payload` field contains scheme-specific data. For example, with exact EVM scheme, this includes: + +| Field Name | Type | Required | Description | +| --------------- | -------- | -------- | ----------------------------------- | +| `signature` | `string` | Required | EIP-712 signature for authorization | +| `authorization` | `object` | Required | EIP-3009 authorization parameters | + +The `Authorization` object contains the following fields: + +| Field Name | Type | Required | Description | +| ------------- | -------- | -------- | ----------------------------------------------- | +| `from` | `string` | Required | Payer's wallet address | +| `to` | `string` | Required | Recipient's wallet address | +| `value` | `string` | Required | Payment amount in atomic units | +| `validAfter` | `string` | Required | Unix timestamp when authorization becomes valid | +| `validBefore` | `string` | Required | Unix timestamp when authorization expires | +| `nonce` | `string` | Required | 32-byte random nonce to prevent replay attacks | + +**5.3 SettlementResponse Schema** + +**5.3.1 JSON Structure** + +After payment settlement, the server includes transaction details in the payment response field as JSON: + +```json +{ + "success": true, + "transaction": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "network": "eip155:84532", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66" +} +``` + +**5.3.2 Field Descriptions** + +The `SettleResponse` schema contains the following fields: + +| Field Name | Type | Required | Description | +| ------------- | --------- | -------- | --------------------------------------------------------------------- | +| `success` | `boolean` | Required | Indicates whether the payment settlement was successful | +| `errorReason` | `string` | Optional | Error reason if settlement failed (omitted if successful) | +| `payer` | `string` | Optional | Address of the payer's wallet | +| `transaction` | `string` | Required | Blockchain transaction hash (empty string if settlement failed) | +| `network` | `string` | Required | Blockchain network identifier in CAIP-2 format | +| `amount` | `string` | Optional | The actual amount settled in atomic units (omitted if not applicable) | +| `extensions` | `object` | Optional | Protocol extensions data | + +**5.4 VerifyResponse Schema** + + +**5.4.2 Field Descriptions** + +The `VerifyResponse` schema contains the following fields: + +| Field Name | Type | Required | Description | +| --------------- | --------- | -------- | ------------------------------------------------------- | +| `isValid` | `boolean` | Required | Indicates whether the payment authorization is valid | +| `invalidReason` | `string` | Optional | Reason for invalidity (omitted if valid) | +| `payer` | `string` | Optional | Address of the payer's wallet | +| `extra` | `object` | Optional | Scheme-specific additional data | + +**6. Payment Schemes (The Logic)** + +This section describes the payment schemes supported by the x402 protocol. Payment schemes define how payments are formed, validated, and settled on specific payment networks. Schemes are independent of the underlying transport mechanism. + +Each scheme defines: + +- How to construct the `payload` field within `PaymentPayload` +- Settlement and validation procedures +- Scheme-specific requirements in the `extra` field of `PaymentRequirements` + +**6.1 Exact Scheme (EVM overview)** + +The "exact" scheme uses EIP-3009 (Transfer with Authorization) to enable secure, gasless transfers of specific amounts of ERC-20 tokens. + +**6.1.1 EIP-3009 Authorization** + +The authorization follows the EIP-3009 standard for `transferWithAuthorization`: + +```javascript +const authorizationTypes = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +}; +``` + +**6.1.2 Verification Steps** + +The facilitator performs the following verification steps: + +1. **Signature Validation**: Verify the EIP-712 signature is valid and properly signed by the payer +2. **Balance Verification**: Confirm the payer has sufficient token balance for the transfer +3. **Amount Validation**: Ensure the payment amount exactly matches the required amount +4. **Time Window Check**: Verify the authorization is within its valid time range +5. **Parameter Matching**: Confirm authorization parameters match the original payment requirements +6. **Transaction Simulation**: Simulate the `transferWithAuthorization` transaction to ensure it would succeed + +**6.1.3 Settlement** + +Settlement is performed by calling the `transferWithAuthorization` function on the ERC-20 contract with the signature and authorization parameters provided in the payment payload. + +**6.2 Exact Scheme (SVM overview)** + +For Solana (SVM), the `exact` scheme is implemented using `TransferChecked` for SPL tokens. Critical verification requirements include: + +- Enforcing a strict instruction layout (Compute Unit Limit, Compute Unit Price, TransferChecked) +- Ensuring the facilitator fee payer does not appear in any instruction accounts and is not the transfer `authority` or `source` +- Bounding compute unit price to mitigate gas abuse +- Verifying the destination ATA matches the `payTo`/`asset` PDA and account existence rules +- Requiring the transfer `amount` to exactly equal the `amount` specified in PaymentRequirements + +Full SVM details are specified in `specs/schemes/exact/scheme_exact_svm.md`. + +**7. Facilitator Interface** + +The facilitator provides HTTP REST APIs for payment verification and settlement. This allows resource servers to delegate blockchain operations to trusted third parties or host the endpoints themselves. Note that while the core x402 protocol is transport-agnostic, facilitator APIs are currently standardized as HTTP endpoints. + +**7.1 POST /verify** + +Verifies a payment authorization without executing the transaction on the blockchain. + +**Request (Exact Scheme):** + +```json +{ + "x402Version": 2, + "paymentPayload": { + /* PaymentPayload schema */ + }, + "paymentRequirements": { + /* PaymentRequirements schema */ + } +} +``` + +Example with actual data: + +```json +{ + "x402Version": 2, + "paymentPayload": { + "x402Version": 2, + "resource": { + "url": "https://api.example.com/premium-data", + "description": "Access to premium market data", + "mimeType": "application/json" + }, + "accepted": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "payload": { + "signature": "0x...", + "authorization": { + "from": "0x...", + "to": "0x...", + "value": "10000", + "validAfter": "1740672089", + "validBefore": "1740672154", + "nonce": "0x..." + } + } + }, + "paymentRequirements": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + } +} +``` + +**Successful Response:** + +```json +{ + "isValid": true, + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66" +} +``` + +**Error Response:** + +```json +{ + "isValid": false, + "invalidReason": "insufficient_funds", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66" +} +``` + +**7.2 POST /settle** + +Executes a verified payment by broadcasting the transaction to the blockchain. + +**Request:** Same structure as `/verify` endpoint (contains `paymentPayload` and `paymentRequirements`). + +> **Note**: While the request structure is identical, some payment schemes may assign different semantics to fields at settlement time versus verification time. For example, in the `upto` scheme, the `amount` field in `paymentRequirements` represents the maximum authorized amount at verification time, but the actual amount to settle at settlement time. See individual scheme specifications for details. + +**Successful Response:** + +```json +{ + "success": true, + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "transaction": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "network": "eip155:84532" +} +``` + +**Error Response:** + +```json +{ + "success": false, + "errorReason": "insufficient_funds", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "transaction": "", + "network": "eip155:84532" +} +``` + +**7.3 GET /supported** + +Returns the list of payment schemes, networks, and extensions supported by the facilitator. + +**Response:** + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:84532" + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:8453" + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:43113" + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:43114" + } + ], + "extensions": [], + "signers": { + "eip155:*": ["0x1234567890abcdef1234567890abcdef12345678"], + "solana:*": ["CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"] + } +} +``` + +**7.3.1 SupportedResponse Fields** + +| Field Name | Type | Required | Description | +| ------------ | -------- | -------- | ------------------------------------------------------------------------ | +| `kinds` | `array` | Required | Array of supported payment kind objects | +| `extensions` | `array` | Required | Array of extension identifiers the facilitator has implemented | +| `signers` | `object` | Required | Map of CAIP-2 patterns (e.g., `eip155:*`) to public signer addresses | + +Each `SupportedKind` object in the `kinds` array contains: + +| Field Name | Type | Required | Description | +| ------------- | -------- | -------- | ---------------------------------------------------------- | +| `x402Version` | `number` | Required | Protocol version supported (2 for v2) | +| `scheme` | `string` | Required | Payment scheme identifier (e.g., "exact") | +| `network` | `string` | Required | Blockchain network identifier in CAIP-2 format | +| `extra` | `object` | Optional | Additional scheme-specific configuration | + +**8. Discovery API** + +The x402 protocol includes a discovery mechanism that allows clients to find and explore available x402-enabled resources. This enables the creation of marketplaces (known as "Bazaars") where users can discover and access monetized APIs and digital services. + +Discovery is currently implemented as HTTP REST APIs, though the discovered resources may use any x402-supported transport. + +8.1 GET /discovery/resources + +List discoverable x402 resources from the Bazaar. + +**Request Parameters:** + +| Parameter | Type | Required | Description | Default | +| --------- | -------- | -------- | ------------------------------------------- | ------- | +| `type` | `string` | Optional | Filter by resource type (e.g., "http") | - | +| `payTo` | `string` | Optional | Filter by payment recipient address | - | +| `scheme` | `string` | Optional | Filter by payment scheme (e.g., "exact") | - | +| `network` | `string` | Optional | Filter by payment network (e.g., "eip155:8453") | - | +| `extensions` | `string` | Optional | Filter by extension key present on each resource | - | +| `limit` | `number` | Optional | Maximum number of results to return (1-100) | 20 | +| `offset` | `number` | Optional | Number of results to skip for pagination | 0 | + +**Response:** + +```json +{ + "x402Version": 2, + "items": [ + { + "resource": "https://api.example.com/premium-data", + "type": "http", + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + } + ], + "lastUpdated": 1703123456, + "metadata": { + "category": "finance", + "provider": "Example Corp" + } + } + ], + "pagination": { + "limit": 10, + "offset": 0, + "total": 1 + } +} +``` + +**8.2 GET /discovery/search** + +Search semantics and response shape are defined in the Bazaar extension specification at +`specs/extensions/bazaar.md`, since this endpoint is extension-specific behavior. + +**8.3 Discovered Resource Fields** + +| Field Name | Type | Required | Description | +| ------------- | -------- | -------- | --------------------------------------------------------------- | +| `resource` | `string` | Required | The resource URL or identifier being monetized | +| `type` | `string` | Required | Resource type (currently "http" for HTTP endpoints) | +| `x402Version` | `number` | Required | Protocol version supported by the resource | +| `accepts` | `array` | Required | Array of PaymentRequirements objects specifying payment methods | +| `lastUpdated` | `number` | Required | Unix timestamp of when the resource was last updated | +| `extensions` | `object` | Optional | Additional extension payloads associated with this discovered resource | + +**8.4 Bazaar Concept** + +The Bazaar is a marketplace ecosystem where x402-enabled resources can be discovered and accessed. Key features: + +- **Resource Discovery**: Find APIs and services by category, provider, or payment requirements +- **Payment Transparency**: View pricing and payment methods upfront +- **Provider Information**: Learn about service providers and their offerings +- **Dynamic Updates**: Resources can be added, updated, or removed dynamically + +**8.5 Example Usage** + +```bash +# List financial data APIs +GET /discovery/resources?type=http&limit=10 + +# Search for weather APIs +GET /discovery/search?query=weather+APIs&type=http&limit=5 + +# Continue a paginated search (when server supports it) +GET /discovery/search?query=financial+data&limit=10&cursor=eyJwYWdlIjoyfQ== +``` + +**9. Error Handling** + +The x402 protocol defines standard error codes that may be returned by facilitators or resource servers. These error codes help clients understand why a payment failed and take appropriate action. + +- **`insufficient_funds`**: Client does not have enough tokens to complete the payment +- **`invalid_exact_evm_payload_authorization_valid_after`**: Payment authorization is not yet valid (before validAfter timestamp) +- **`invalid_exact_evm_payload_authorization_valid_before`**: Payment authorization has expired (after validBefore timestamp) +- **`invalid_exact_evm_payload_authorization_value_mismatch`**: Payment amount does not exactly match the required amount +- **`invalid_exact_evm_payload_signature`**: Payment authorization signature is invalid or improperly signed +- **`invalid_exact_evm_payload_recipient_mismatch`**: Recipient address does not match payment requirements +- **`invalid_network`**: Specified blockchain network is not supported +- **`invalid_payload`**: Payment payload is malformed or contains invalid data +- **`invalid_payment_requirements`**: Payment requirements object is invalid or malformed +- **`invalid_scheme`**: Specified payment scheme is not supported +- **`unsupported_scheme`**: Payment scheme is not supported by the facilitator +- **`invalid_x402_version`**: Protocol version is not supported +- **`invalid_transaction_state`**: Blockchain transaction failed or was rejected +- **`unexpected_verify_error`**: Unexpected error occurred during payment verification +- **`unexpected_settle_error`**: Unexpected error occurred during payment settlement + +**10. Security Considerations** + +**10.1 Replay Attack Prevention** + +The x402 protocol implements multiple layers of protection against replay attacks: + +- **EIP-3009 Nonce**: Each authorization includes a unique 32-byte nonce to prevent replay attacks +- **Blockchain Protection**: EIP-3009 contracts inherently prevent nonce reuse at the smart contract level +- **Time Constraints**: Authorizations have explicit valid time windows to limit their lifetime +- **Signature Verification**: All authorizations are cryptographically signed by the payer + +**10.2 Authentication Integration** + +The protocol supports integration with authentication systems (e.g., Sign-In with Ethereum - SIWE) to enable authenticated pricing models where verified users receive discounted rates or special access terms. + +**11. Implementation Notes** + +**11.1 Network Identifiers** + +Networks in x402 v2 use CAIP-2 (Chain Agnostic Improvement Proposal) format: `namespace:reference`. + +**Format:** `{namespace}:{reference}` (e.g., `eip155:8453` for Base mainnet) + +Non-blockchain networks are encouraged to follow the CAIP-2 format (e.g., `ach:us`, `sepa:eu`). + +Both EVM and Solana networks are supported by the reference implementations, e.g.: + +- **`eip155:84532`**: Base Sepolia testnet +- **`eip155:8453`**: Base mainnet +- **`eip155:43113`**: Avalanche Fuji testnet +- **`eip155:43114`**: Avalanche mainnet +- **`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`**: Solana mainnet +- **`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`**: Solana devnet + +**11.2 Supported Assets** + +Token support varies by network: + +**EVM Networks:** +- ERC-20 tokens implementing EIP-3009 (Transfer with Authorization) +- Example: USDC + +**Solana:** +- Any SPL token +- Token2022 program tokens + +Token availability depends on facilitator service capabilities and network-specific deployments. + +**12. Use Cases and Applications** + +The x402 protocol enables diverse monetization scenarios across the internet. While the core protocol is HTTP-native and chain-agnostic, specific implementations can vary based on use case requirements. + +### 12.1 AI Agent Integration + +AI agents can use x402 to autonomously pay for resources and services. The protocol supports: + +- **Automatic payment handling** for resource access +- **Resource discovery** through facilitator services +- **Budget management** and spending controls (implementation-specific) +- **Correlation tracking** for operation grouping (implementation-specific) +- **Multi-transport support** allowing agents to work across HTTP APIs, MCP tools, and other protocol layers + +### 12.2 Human User Applications + +Applications can implement x402 for: + +- **Session-based access** (time-limited subscriptions) +- **Pay-per-use content** (articles, videos, downloads, tools) +- **Resource monetization** with per-call pricing +- **Authentication-based pricing** (discounted rates for verified users) +- **Cross-protocol payments** supporting web, desktop, and AI applications + +### 12.3 Transport Support + +x402 integrates across multiple transport layers: + +- **HTTP**: Web APIs, REST services, server frameworks (Express.js, FastAPI, Next.js, etc.) +- **MCP (Model Context Protocol)**: AI agent tools and resources +- **A2A (Agent-to-Agent Protocol)**: Direct agent-to-agent payments +- **Custom Protocols**: Any request-response based system can implement x402 payment flows + +### 12.4 Server Frameworks + +x402 integrates with popular frameworks: + +- **Express.js**: `require_payment()` middleware +- **FastAPI/Flask**: Framework-specific middleware +- **Hono**: Edge runtime support +- **Next.js**: Fullstack integration +- **ai/agents**: AI agent and MCP frameworks + +### 12.5 Client Libraries + +Clients across different transports can be enhanced with x402 payment capabilities: + +- **HTTP clients**: axios/fetch (browser), httpx/requests (Python), curl (CLI) +- **MCP clients**: ai/agents MCP Clients +- **A2A**: x402_a2a (python) +- **Custom integrations**: Application-specific payment handling + +### 12.6 Advanced Patterns + +The protocol enables sophisticated monetization strategies: + +- **Dynamic pricing** based on user authentication or usage patterns +- **Session management** for time-based access control +- **Batch payments** for multiple resource access +- **Subscription models** built on micropayments + +_Note: Implementation details for specific patterns (such as budget management, correlation tracking, or session handling) are available in application notes and implementation guides. Transport-specific implementation details are covered in the transport specification documents._ + +--- + +## Version History + +| Version | Date | Changes | Author | +| ------- | ----------- | ----------------------------------------------------------------- | ------------------------- | +| v2.0 | 2025-12-9 | Protocol v2: CAIP-2 networks, restructured PaymentPayload/Required, ResourceInfo separation, extensions support | x402 team | +| v0.2 | 2025-10-3 | Transport-agnostic redesign | Ethan Niser | +| v0.1 | 2025-8-29 | Initial draft | [derived from repository] | diff --git a/lib/x402/facilitator.ex b/lib/x402/facilitator.ex index e591d24..8c04af6 100644 --- a/lib/x402/facilitator.ex +++ b/lib/x402/facilitator.ex @@ -248,8 +248,10 @@ defmodule X402.Facilitator do state.url, endpoint, %{ - payload: before_context.payload, - requirements: before_context.requirements + # x402 v2 facilitator wire format (§7.1 / §7.2) + "x402Version" => 2, + "paymentPayload" => before_context.payload, + "paymentRequirements" => before_context.requirements }, max_retries: state.max_retries, retry_backoff_ms: state.retry_backoff_ms, diff --git a/lib/x402/plug/payment_gate.ex b/lib/x402/plug/payment_gate.ex index 1695d8e..7b112cb 100644 --- a/lib/x402/plug/payment_gate.ex +++ b/lib/x402/plug/payment_gate.ex @@ -1,12 +1,26 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do defmodule X402.Plug.PaymentGate do @moduledoc """ - Plug middleware that gates configured routes behind x402 payment verification. + Plug middleware that gates configured routes behind x402 v2 payment verification. + + For matching routes: + + 1. Requests without a `PAYMENT-SIGNATURE` header receive **402** with a + Base64-encoded `PAYMENT-REQUIRED` header (`PaymentRequired` v2 schema). + 2. Requests with `PAYMENT-SIGNATURE` are decoded as `PaymentPayload` v2. + 3. `PaymentPayload.accepted` is matched against the route's advertised + `accepts` (`scheme`, `network`, `amount`, `asset`, `payTo`). + 4. Matched requirements are verified and settled via the facilitator. + 5. Successful settlements attach a `PAYMENT-RESPONSE` header and assign + `:x402_payment_payload` / `:x402_payment_requirements` on the conn. - For matching routes, requests without an `x-payment` header receive a `402` - response with x402 payment requirements. Requests with `x-payment` are - decoded, verified, and settled with the configured facilitator before the - request is allowed to continue through the plug pipeline. + HTTP status mapping (HTTP transport v2): + + - **402** — payment required, no matching requirements, or payment failed + - **400** — malformed / invalid payment payload (including wrong `x402Version`) + + See `docs/x402-specification-v2.md` and the + [HTTP transport](https://github.com/x402-foundation/x402/blob/main/specs/transports-v2/http.md). """ @behaviour Plug @@ -16,14 +30,81 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do alias X402.Facilitator.Error alias X402.Hooks alias X402.Hooks.Default + alias X402.PaymentRequired + alias X402.PaymentResponse alias X402.PaymentSignature + alias X402.Utils require Logger - import Plug.Conn, only: [get_req_header: 2, halt: 1, put_resp_content_type: 2, send_resp: 3] + import Plug.Conn, + only: [ + assign: 3, + get_req_header: 2, + halt: 1, + put_resp_content_type: 2, + put_resp_header: 3, + send_resp: 3 + ] @http_methods [:any, :delete, :get, :head, :options, :patch, :post, :put, :trace] - @route_schemes ["exact", "upto"] + @route_schemes ["exact", "upto"] # TODO Implement batch-settlement scheme + @x402_version 2 + @default_max_timeout_seconds 60 + @default_description "Payment required" + @default_mime_type "application/json" + + # Reasons that map to HTTP 400 Invalid Request (HTTP transport v2). + @invalid_request_reasons [ + :invalid_payment_header, + :invalid_base64, + :invalid_json, + :payload_too_large, + :invalid_payload, + :invalid_x402_version + ] + + @accept_option_schema [ + scheme: [ + type: {:in, @route_schemes}, + default: "exact", + doc: "Payment scheme (`exact` or `upto`)." + ], + price: [ + type: :string, + required: true, + doc: """ + Payment amount in atomic token units (PaymentRequirements `amount`). + For `exact` this is the required amount; for `upto` it is the maximum + authorized amount. + """ + ], + network: [ + type: :string, + required: true, + doc: "Blockchain network in CAIP-2 format (for example `eip155:84532`)." + ], + asset: [ + type: :string, + required: true, + doc: "Token contract address or asset identifier." + ], + pay_to: [ + type: :string, + required: true, + doc: "Recipient wallet address (`payTo` in the PaymentRequirements schema)." + ], + max_timeout_seconds: [ + type: :pos_integer, + default: @default_max_timeout_seconds, + doc: "Maximum time allowed for payment completion." + ], + extra: [ + type: {:custom, __MODULE__, :validate_extra_map, []}, + default: %{}, + doc: "Scheme-specific extra fields (string or atom keys)." + ] + ] @route_schema [ method: [ @@ -36,30 +117,75 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do required: true, doc: "Route path, supporting exact matches and `*` globs (for example `/api/*`)." ], + accepts: [ + type: {:list, {:map, @accept_option_schema}}, + default: [], + doc: """ + Payment options advertised in `PAYMENT-REQUIRED.accepts`. When empty, + a single option is built from the top-level `:scheme`, `:price`, + `:network`, `:asset`, and `:pay_to` fields. + """ + ], scheme: [ type: {:in, @route_schemes}, default: "exact", - doc: "Payment scheme for the route (`exact` or `upto`)." + doc: "Single-option scheme (used when `:accepts` is empty)." ], price: [ type: :string, - required: true, - doc: "Price for `exact` routes or maximum allowed price for `upto` routes." + doc: "Single-option amount (required when `:accepts` is empty)." ], network: [ type: :string, - required: true, - doc: "x402 network identifier (for example `base-sepolia`)." + doc: "Single-option CAIP-2 network (required when `:accepts` is empty)." ], asset: [ type: :string, - required: true, - doc: "Asset symbol (for example `USDC`)." + doc: "Single-option asset (required when `:accepts` is empty)." ], - receiver: [ + pay_to: [ type: :string, - required: true, - doc: "Receiver wallet address." + doc: "Single-option payTo (required when `:accepts` is empty)." + ], + description: [ + type: :string, + default: @default_description, + doc: "ResourceInfo.description." + ], + mime_type: [ + type: :string, + default: @default_mime_type, + doc: "ResourceInfo.mimeType." + ], + service_name: [ + type: {:or, [:string, nil]}, + default: nil, + doc: "ResourceInfo.serviceName (printable ASCII, max 32 characters recommended)." + ], + tags: [ + type: {:list, :string}, + default: [], + doc: "ResourceInfo.tags (max 5 recommended)." + ], + icon_url: [ + type: {:or, [:string, nil]}, + default: nil, + doc: "ResourceInfo.iconUrl (absolute http(s) URL)." + ], + max_timeout_seconds: [ + type: :pos_integer, + default: @default_max_timeout_seconds, + doc: "Default maxTimeoutSeconds for single-option routes." + ], + extra: [ + type: {:custom, __MODULE__, :validate_extra_map, []}, + default: %{}, + doc: "Default extra map for single-option routes." + ], + extensions: [ + type: {:custom, __MODULE__, :validate_extra_map, []}, + default: %{}, + doc: "Protocol extensions advertised in PaymentRequired.extensions." ] ] @@ -85,9 +211,9 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do """ ], routes: [ - type: {:list, {:map, @route_schema}}, + type: {:list, {:custom, __MODULE__, :validate_route, []}}, required: true, - doc: "Route gate definitions." + doc: "Route gate definitions (see route options below)." ] ] @@ -99,19 +225,58 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do routes: [compiled_route()] } + @typedoc false + @type payment_accept :: %{ + scheme: String.t(), + price: String.t(), + network: String.t(), + asset: String.t(), + pay_to: String.t(), + max_timeout_seconds: pos_integer(), + extra: map() + } + @typedoc false @type compiled_route :: %{ method: atom(), matcher: :exact | :glob, path: String.t(), glob_regex: Regex.t() | nil, - scheme: String.t(), - price: String.t(), - network: String.t(), - asset: String.t(), - receiver: String.t() + accepts: [payment_accept()], + description: String.t(), + mime_type: String.t(), + service_name: String.t() | nil, + tags: [String.t()], + icon_url: String.t() | nil, + extensions: map() } + @doc false + @spec validate_extra_map(term()) :: {:ok, map()} | {:error, String.t()} + def validate_extra_map(value) when is_map(value), do: {:ok, value} + def validate_extra_map(_value), do: {:error, "expected a map"} + + @doc false + @spec validate_route(term()) :: {:ok, map()} | {:error, String.t()} + def validate_route(route) when is_map(route) do + keyword_route = map_to_keyword(route) + + case NimbleOptions.validate(keyword_route, @route_schema) do + {:ok, validated} -> + validated_map = Map.new(validated) + + case ensure_accepts_source(validated_map) do + :ok -> {:ok, validated_map} + {:error, message} -> {:error, message} + end + + {:error, %NimbleOptions.ValidationError{} = error} -> + {:error, Exception.message(error)} + end + end + + def validate_route(_route), do: {:error, "expected a route map"} + @doc since: "0.1.0" @doc """ Validates and compiles `X402.Plug.PaymentGate` options. @@ -119,6 +284,14 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do ## Options #{NimbleOptions.docs(@options_schema)} + + ### Route options + + #{NimbleOptions.docs(@route_schema)} + + ### Accept option fields (inside `:accepts`) + + #{NimbleOptions.docs(@accept_option_schema)} """ @spec init(keyword()) :: options() def init(opts) when is_list(opts) do @@ -127,18 +300,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do cache = Keyword.get(validated_opts, :payment_identifier_cache) if is_nil(cache) do - # Emit a compile-time / pipeline-init warning so operators notice the - # missing protection during development and CI builds. - # Without an idempotency cache, the same payment proof can be replayed - # across concurrent requests — each passes the facilitator verify step - # before any can record the result, resulting in double-settlement. - # - # NOTE: in module-based Plug pipelines (Phoenix router `plug/2`), - # `init/1` is evaluated at compile time. For pre-built production - # releases the warning below fires only during the build, not at - # application boot. A separate :persistent_term-gated runtime warning - # is emitted on the first `call/2` invocation so that production - # operators always see it in their application logs. IO.warn( "[X402.Plug.PaymentGate] payment_identifier_cache is not configured. " <> "Duplicate payment proofs will NOT be detected — your deployment is " <> @@ -161,7 +322,7 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do @doc since: "0.1.0" @doc """ - Gates matching requests behind x402 payment verification. + Gates matching requests behind x402 v2 payment verification. """ @spec call(Plug.Conn.t(), options()) :: Plug.Conn.t() def call(%Plug.Conn{} = conn, %{ @@ -170,10 +331,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do payment_identifier_cache: payment_identifier_cache, routes: routes }) do - # Emit a one-time runtime warning when the idempotency cache is not - # configured. This complements the compile-time IO.warn in init/1: - # pre-built releases never execute init/1 at boot, so this ensures - # production application logs always surface the double-settlement risk. if is_nil(payment_identifier_cache), do: warn_no_idempotency_cache_once() request_path = normalize_path(conn.request_path) @@ -219,7 +376,13 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do :missing -> emit(:payment_required, %{method: request_method, path: request_path, route: route.path}) - payment_required_response(conn, route, request_path, "") + payment_error_response( + conn, + route, + request_path, + "PAYMENT-SIGNATURE header is required", + status: 402 + ) {:ok, header} -> verify_and_settle( @@ -241,7 +404,14 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do reason: reason }) - payment_required_response(conn, route, request_path, rejection_error(reason)) + payment_error_response( + conn, + route, + request_path, + rejection_error(reason), + status: status_for_reason(reason), + reason: reason + ) end end @@ -265,29 +435,29 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do request_path, header ) do - requirements = facilitator_requirements(route, request_path) - - # Derive a stable idempotency key from the raw payment header. Two - # concurrent requests carrying the same proof produce the same key. + accepts = route_accepts(route) payment_id = :crypto.hash(:sha256, header) |> Base.encode16(case: :lower) - with {:ok, payment_payload} <- PaymentSignature.decode_and_validate(header, requirements), + with {:ok, payment_payload, requirements} <- + decode_and_validate_payment(header, accepts), {:ok, verify_response} <- facilitator_verify(facilitator, payment_payload, requirements, hooks), - :ok <- ensure_success_status(verify_response), + :ok <- ensure_verify_success(verify_response), :ok <- claim_payment(payment_identifier_cache, payment_id) do - # Claim succeeded. Attempt settlement separately so we can release the - # claim if settlement fails — otherwise a transient network error or - # facilitator timeout would permanently block the payment ID, leaving - # the user unable to retry with the same proof. settle_result = - with {:ok, settle_response} <- - facilitator_settle(facilitator, payment_payload, requirements, hooks) do - ensure_success_status(settle_response) + case facilitator_settle(facilitator, payment_payload, requirements, hooks) do + {:ok, settle_response} -> + case ensure_settle_success(settle_response) do + :ok -> {:ok, settle_response} + {:error, reason} -> {:error, reason, settle_response.body} + end + + {:error, reason} -> + {:error, reason, nil} end case settle_result do - :ok -> + {:ok, settle_response} -> emit(:payment_verified, %{ method: request_method, path: request_path, @@ -295,8 +465,11 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do }) conn + |> assign(:x402_payment_payload, payment_payload) + |> assign(:x402_payment_requirements, requirements) + |> put_payment_response_header(settle_response.body) - {:error, reason} -> + {:error, reason, settle_body} -> release_claim(payment_identifier_cache, payment_id) emit(:payment_rejected, %{ @@ -306,7 +479,14 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do reason: reason }) - payment_required_response(conn, route, request_path, rejection_error(reason)) + payment_error_response( + conn, + route, + request_path, + rejection_error(reason), + status: status_for_reason(reason), + reason: settle_body || reason + ) end else {:error, reason} -> @@ -317,12 +497,17 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do reason: reason }) - payment_required_response(conn, route, request_path, rejection_error(reason)) + payment_error_response( + conn, + route, + request_path, + rejection_error(reason), + status: status_for_reason(reason), + reason: reason + ) end end - # Atomically claims a payment ID to prevent concurrent double-settlement. - # When no cache is configured, the check is skipped (opt-in behaviour). @spec claim_payment(ETSCache.server() | nil, String.t()) :: :ok | {:error, :already_exists} defp claim_payment(nil, _payment_id), do: :ok @@ -331,8 +516,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do ETSCache.put_new(cache, payment_id, :verified) end - # Releases a previously claimed payment ID. Called when settlement fails - # after a successful claim, so the caller can retry with a fresh proof. @spec release_claim(ETSCache.server() | nil, String.t()) :: :ok | {:error, term()} defp release_claim(nil, _payment_id), do: :ok defp release_claim(cache, payment_id), do: ETSCache.delete(cache, payment_id) @@ -357,21 +540,78 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do Facilitator.settle(facilitator, payment_payload, requirements, hooks) end + @spec ensure_accepts_source(map()) :: :ok | {:error, String.t()} + defp ensure_accepts_source(%{accepts: accepts}) when is_list(accepts) and accepts != [] do + :ok + end + + defp ensure_accepts_source(route) do + missing = + Enum.reject([:price, :network, :asset, :pay_to], fn key -> + value = Map.get(route, key) + is_binary(value) and value != "" + end) + + case missing do + [] -> + :ok + + keys -> + {:error, + "route requires either non-empty :accepts or top-level fields #{inspect(keys)}"} + end + end + @spec compile_route(map()) :: compiled_route() defp compile_route(%{} = route) do normalized_path = normalize_path(Map.fetch!(route, :path)) matcher = path_matcher(normalized_path) + accepts = + case Map.get(route, :accepts, []) do + list when is_list(list) and list != [] -> + Enum.map(list, &compile_accept/1) + + _empty -> + [ + compile_accept(%{ + scheme: Map.get(route, :scheme, "exact"), + price: Map.fetch!(route, :price), + network: Map.fetch!(route, :network), + asset: Map.fetch!(route, :asset), + pay_to: Map.fetch!(route, :pay_to), + max_timeout_seconds: + Map.get(route, :max_timeout_seconds, @default_max_timeout_seconds), + extra: Map.get(route, :extra, %{}) + }) + ] + end + %{ method: Map.fetch!(route, :method), matcher: matcher, path: normalized_path, glob_regex: glob_regex(matcher, normalized_path), - scheme: Map.get(route, :scheme, "exact"), - price: Map.fetch!(route, :price), - network: Map.fetch!(route, :network), - asset: Map.fetch!(route, :asset), - receiver: Map.fetch!(route, :receiver) + accepts: accepts, + description: Map.get(route, :description, @default_description), + mime_type: Map.get(route, :mime_type, @default_mime_type), + service_name: Map.get(route, :service_name), + tags: Map.get(route, :tags, []), + icon_url: Map.get(route, :icon_url), + extensions: stringify_keys(Map.get(route, :extensions, %{})) + } + end + + @spec compile_accept(map()) :: payment_accept() + defp compile_accept(accept) do + %{ + scheme: Map.get(accept, :scheme, "exact"), + price: Map.fetch!(accept, :price), + network: Map.fetch!(accept, :network), + asset: Map.fetch!(accept, :asset), + pay_to: Map.fetch!(accept, :pay_to), + max_timeout_seconds: Map.get(accept, :max_timeout_seconds, @default_max_timeout_seconds), + extra: Map.get(accept, :extra, %{}) } end @@ -411,25 +651,180 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do @spec payment_header(Plug.Conn.t()) :: :missing | {:ok, String.t()} | {:error, :invalid_payment_header} defp payment_header(conn) do - case get_req_header(conn, "x-payment") do + case get_req_header(conn, "payment-signature") do [] -> :missing [header | _] when is_binary(header) and header != "" -> {:ok, header} _ -> {:error, :invalid_payment_header} end end - @spec ensure_success_status(%{status: non_neg_integer(), body: map()}) :: - :ok | {:error, {:unexpected_facilitator_status, non_neg_integer()}} - defp ensure_success_status(%{status: status}) when status in 200..299, do: :ok + @spec decode_and_validate_payment(String.t(), [map()]) :: + {:ok, map(), map()} | {:error, term()} + defp decode_and_validate_payment(header, accepts) when is_list(accepts) do + with {:ok, payload} <- PaymentSignature.decode(header), + :ok <- validate_v2_payload_structure(payload), + {:ok, matched} <- find_matching_requirements(accepts, payload), + :ok <- validate_upto_amount(payload, matched) do + {:ok, payload, matched} + end + end + + @spec validate_v2_payload_structure(map()) :: :ok | {:error, term()} + defp validate_v2_payload_structure(payload) when is_map(payload) do + accepted = Utils.map_value(payload, {"accepted", :accepted}) + scheme_payload = Utils.map_value(payload, {"payload", :payload}) + version = Utils.map_value(payload, {"x402Version", :x402Version}) - defp ensure_success_status(%{status: status}), + cond do + version != @x402_version -> + {:error, :invalid_x402_version} + + not is_map(accepted) -> + {:error, :invalid_payload} + + not is_map(scheme_payload) -> + {:error, :invalid_payload} + + true -> + :ok + end + end + + defp validate_v2_payload_structure(_payload), do: {:error, :invalid_payload} + + # Equality on scheme, network, amount, asset, payTo — matches Go + # FindMatchingRequirements. + @spec find_matching_requirements([map()], map()) :: + {:ok, map()} | {:error, :no_matching_requirements} + defp find_matching_requirements(accepts, payment_payload) when is_list(accepts) do + accepted = Utils.map_value(payment_payload, {"accepted", :accepted}) + + if is_map(accepted) do + case Enum.find(accepts, &requirements_match?(&1, accepted)) do + nil -> {:error, :no_matching_requirements} + matched -> {:ok, matched} + end + else + {:error, :no_matching_requirements} + end + end + + @spec requirements_match?(map(), map()) :: boolean() + defp requirements_match?(requirement, accepted) do + requirement_field(requirement, "scheme") == requirement_field(accepted, "scheme") and + requirement_field(requirement, "network") == requirement_field(accepted, "network") and + requirement_field(requirement, "amount") == requirement_field(accepted, "amount") and + requirement_field(requirement, "asset") == requirement_field(accepted, "asset") and + requirement_field(requirement, "payTo") == requirement_field(accepted, "payTo") + end + + @spec requirement_field(map(), String.t()) :: term() + defp requirement_field(map, "scheme"), do: Utils.map_value(map, {"scheme", :scheme}) + defp requirement_field(map, "network"), do: Utils.map_value(map, {"network", :network}) + defp requirement_field(map, "amount"), do: Utils.map_value(map, {"amount", :amount}) + defp requirement_field(map, "asset"), do: Utils.map_value(map, {"asset", :asset}) + defp requirement_field(map, "payTo"), do: Utils.map_value(map, {"payTo", :payTo}) + + @spec validate_upto_amount(map(), map()) :: :ok | {:error, term()} + defp validate_upto_amount(payload, requirements) do + scheme = Utils.map_value(requirements, {"scheme", :scheme}) + + case scheme do + "upto" -> + with {:ok, max_amount} <- extract_max_amount(requirements), + {:ok, payment_value} <- extract_payment_value(payload) do + case Utils.compare_decimal(payment_value, max_amount) do + :gt -> {:error, {:invalid_upto_payment, :payment_value_exceeds_max_price}} + _comparison -> :ok + end + end + + _scheme -> + :ok + end + end + + @spec extract_max_amount(map()) :: + {:ok, {non_neg_integer(), non_neg_integer()}} + | {:error, {:invalid_upto_payment, atom()}} + defp extract_max_amount(requirements) do + value = + Utils.first_present([ + Utils.map_value(requirements, {"amount", :amount}), + Utils.map_value(requirements, {"maxPrice", :maxPrice}), + Utils.map_value(requirements, {"maxAmountRequired", :maxAmountRequired}) + ]) + + case value do + nil -> + {:error, {:invalid_upto_payment, :missing_max_price}} + + max_amount -> + case Utils.parse_decimal(max_amount) do + {:ok, parsed} -> {:ok, parsed} + :error -> {:error, {:invalid_upto_payment, :invalid_max_price}} + end + end + end + + @spec extract_payment_value(map()) :: + {:ok, {non_neg_integer(), non_neg_integer()}} + | {:error, {:invalid_upto_payment, atom()}} + defp extract_payment_value(payload) do + value = + Utils.first_present([ + Utils.map_value(payload, {"value", :value}), + Utils.nested_map_value(payload, [{"payload", :payload}, {"value", :value}]), + Utils.nested_map_value(payload, [ + {"payload", :payload}, + {"authorization", :authorization}, + {"value", :value} + ]), + Utils.nested_map_value(payload, [{"authorization", :authorization}, {"value", :value}]) + ]) + + case value do + nil -> + {:error, {:invalid_upto_payment, :missing_payment_value}} + + payment_value -> + case Utils.parse_decimal(payment_value) do + {:ok, parsed} -> {:ok, parsed} + :error -> {:error, {:invalid_upto_payment, :invalid_payment_value}} + end + end + end + + @spec ensure_verify_success(%{status: non_neg_integer(), body: map()}) :: + :ok + | {:error, + {:unexpected_facilitator_status, non_neg_integer()} + | {:verification_failed, term()}} + defp ensure_verify_success(%{status: status, body: body}) when status in 200..299 do + case Map.get(body, "isValid", Map.get(body, :isValid, true)) do + true -> :ok + _invalid -> {:error, {:verification_failed, Map.get(body, "invalidReason")}} + end + end + + defp ensure_verify_success(%{status: status}), + do: {:error, {:unexpected_facilitator_status, status}} + + @spec ensure_settle_success(%{status: non_neg_integer(), body: map()}) :: + :ok + | {:error, + {:unexpected_facilitator_status, non_neg_integer()} + | {:settlement_failed, term()}} + defp ensure_settle_success(%{status: status, body: body}) when status in 200..299 do + case Map.get(body, "success", Map.get(body, :success, true)) do + true -> :ok + _failed -> {:error, {:settlement_failed, Map.get(body, "errorReason")}} + end + end + + defp ensure_settle_success(%{status: status}), do: {:error, {:unexpected_facilitator_status, status}} - # Emits a Logger.warning at most once per node when payment_identifier_cache - # is not configured. Uses :persistent_term as a lightweight flag so the - # warning fires only on the first request, not on every call. - # Minor: two concurrent first-requests may both log before the flag is set; - # this is acceptable — the consequence is a duplicate log line, not a bug. defp warn_no_idempotency_cache_once do key = {__MODULE__, :no_idempotency_cache_warned} @@ -445,60 +840,158 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do end end - @spec facilitator_requirements(compiled_route(), String.t()) :: map() - defp facilitator_requirements(route, request_path) do + @spec route_accepts(compiled_route()) :: [map()] + defp route_accepts(%{accepts: accepts}) do + Enum.map(accepts, &payment_requirements_from_accept/1) + end + + @spec payment_requirements_from_accept(payment_accept()) :: map() + defp payment_requirements_from_accept(accept) do %{ - "scheme" => route.scheme, - "network" => route.network, - "asset" => route.asset, - "resource" => request_path, - "description" => "Payment required", - "mimeType" => "application/json", - "payTo" => route.receiver, - "maxTimeoutSeconds" => 60, - "extra" => %{} + "scheme" => accept.scheme, + "network" => accept.network, + "amount" => accept.price, + "asset" => accept.asset, + "payTo" => accept.pay_to, + "maxTimeoutSeconds" => accept.max_timeout_seconds, + "extra" => stringify_keys(accept.extra) } - |> Map.merge(scheme_pricing_entry(route.scheme, route.price)) end - @spec payment_required_response(Plug.Conn.t(), compiled_route(), String.t(), String.t()) :: - Plug.Conn.t() - defp payment_required_response(conn, route, request_path, error_message) do - body = payment_required_body(route, request_path, error_message) + @spec stringify_keys(map()) :: map() + defp stringify_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_atom(key) -> {Atom.to_string(key), value} + {key, value} -> {key, value} + end) + end + + @spec resource_info(Plug.Conn.t(), compiled_route(), String.t()) :: map() + defp resource_info(conn, route, request_path) do + base = %{ + "url" => resource_url(conn, request_path), + "description" => route.description, + "mimeType" => route.mime_type + } + + base + |> maybe_put("serviceName", route.service_name) + |> maybe_put_tags(route.tags) + |> maybe_put("iconUrl", route.icon_url) + end + + @spec maybe_put(map(), String.t(), term()) :: map() + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + @spec maybe_put_tags(map(), [String.t()]) :: map() + defp maybe_put_tags(map, tags) when is_list(tags) and tags != [], + do: Map.put(map, "tags", tags) + + defp maybe_put_tags(map, _tags), do: map + + @spec resource_url(Plug.Conn.t(), String.t()) :: String.t() + defp resource_url(conn, request_path) do + scheme = conn.scheme |> to_string() + host = conn.host || "localhost" + port = conn.port + + authority = + cond do + scheme == "https" and port in [443, nil] -> host + scheme == "http" and port in [80, nil] -> host + is_integer(port) -> "#{host}:#{port}" + true -> host + end + + "#{scheme}://#{authority}#{request_path}" + end + + @spec payment_error_response( + Plug.Conn.t(), + compiled_route(), + String.t(), + String.t(), + keyword() + ) :: Plug.Conn.t() + defp payment_error_response(conn, route, request_path, error_message, opts) do + status = Keyword.get(opts, :status, 402) + reason = Keyword.get(opts, :reason) + required_payload = payment_required_payload(conn, route, request_path, error_message) + + conn = + case PaymentRequired.encode(required_payload) do + {:ok, encoded} -> + put_resp_header(conn, "payment-required", encoded) + + {:error, _reason} -> + conn + end + + conn = + case payment_response_from_reason(reason) do + nil -> conn + settle_body -> put_payment_response_header(conn, settle_body) + end conn |> put_resp_content_type("application/json") - |> send_resp(402, body) + |> send_resp(status, "{}") |> halt() end - @spec payment_required_body(compiled_route(), String.t(), String.t()) :: String.t() - defp payment_required_body(route, request_path, error_message) do - Jason.encode!(%{ - "x402Version" => 1, - "accepts" => [accept_entry(route, request_path)], - "error" => error_message - }) + @spec payment_required_payload(Plug.Conn.t(), compiled_route(), String.t(), String.t()) :: + map() + defp payment_required_payload(conn, route, request_path, error_message) do + %{ + "x402Version" => @x402_version, + "error" => error_message, + "resource" => resource_info(conn, route, request_path), + "accepts" => route_accepts(route), + "extensions" => route.extensions + } end - @spec accept_entry(compiled_route(), String.t()) :: map() - defp accept_entry(route, request_path) do + @spec put_payment_response_header(Plug.Conn.t(), map()) :: Plug.Conn.t() + defp put_payment_response_header(conn, body) when is_map(body) do + case PaymentResponse.encode(body) do + {:ok, encoded} -> put_resp_header(conn, "payment-response", encoded) + {:error, _reason} -> conn + end + end + + defp put_payment_response_header(conn, _body), do: conn + + @spec payment_response_from_reason(term()) :: map() | nil + defp payment_response_from_reason(body) when is_map(body), do: body + + defp payment_response_from_reason({:settlement_failed, reason}) when is_binary(reason) do %{ - "scheme" => route.scheme, - "network" => route.network, - "resource" => request_path, - "description" => "Payment required", - "mimeType" => "application/json", - "payTo" => route.receiver, - "maxTimeoutSeconds" => 60, - "extra" => %{} + "success" => false, + "errorReason" => reason, + "transaction" => "", + "network" => "" } - |> Map.merge(scheme_pricing_entry(route.scheme, route.price)) end - @spec scheme_pricing_entry(String.t(), String.t()) :: map() - defp scheme_pricing_entry("upto", price), do: %{"maxPrice" => price} - defp scheme_pricing_entry(_scheme, price), do: %{"maxAmountRequired" => price} + defp payment_response_from_reason({:settlement_failed, reason}) when not is_nil(reason) do + %{ + "success" => false, + "errorReason" => to_string(reason), + "transaction" => "", + "network" => "" + } + end + + defp payment_response_from_reason(_reason), do: nil + + @spec status_for_reason(term()) :: 400 | 402 + defp status_for_reason(reason) when reason in @invalid_request_reasons, do: 400 + defp status_for_reason({:missing_fields, _fields}), do: 400 + defp status_for_reason({:invalid_upto_payment, _reason}), do: 400 + defp status_for_reason({:invalid_format, _fields}), do: 400 + defp status_for_reason(_reason), do: 402 @spec normalize_method(String.t()) :: atom() defp normalize_method("DELETE"), do: :delete @@ -515,22 +1008,34 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do defp normalize_path("/"), do: "/" defp normalize_path(path), do: String.trim_trailing(path, "/") - @spec rejection_error( - :invalid_payment_header - | PaymentSignature.decode_and_validate_error() - | {:unexpected_facilitator_status, non_neg_integer()} - | Hooks.hook_error() - | Error.t() - | term() - ) :: String.t() + @spec map_to_keyword(map()) :: keyword() + defp map_to_keyword(map) do + Enum.map(map, fn + {key, value} when is_atom(key) -> {key, value} + {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} + end) + rescue + ArgumentError -> + Enum.map(map, fn + {key, value} when is_atom(key) -> {key, value} + {key, value} when is_binary(key) -> {String.to_atom(key), value} + end) + end + + @spec rejection_error(term()) :: String.t() defp rejection_error(:invalid_payment_header), do: "invalid payment header" defp rejection_error(:invalid_base64), do: "invalid payment header" defp rejection_error(:invalid_json), do: "invalid payment header" defp rejection_error(:payload_too_large), do: "invalid payment header" - defp rejection_error(:invalid_payload), do: "invalid payment payload" + defp rejection_error(:invalid_payload), do: "invalid_payload" + defp rejection_error(:invalid_x402_version), do: "invalid_x402_version" + defp rejection_error(:no_matching_requirements), do: "No matching payment requirements" defp rejection_error(:already_exists), do: "payment already processed" - defp rejection_error({:missing_fields, _fields}), do: "invalid payment payload" - defp rejection_error({:invalid_upto_payment, _reason}), do: "invalid payment payload" + defp rejection_error({:missing_fields, _fields}), do: "invalid_payload" + defp rejection_error({:invalid_upto_payment, _reason}), do: "invalid_payload" + defp rejection_error({:invalid_format, _fields}), do: "invalid_payload" + defp rejection_error({:verification_failed, _reason}), do: "facilitator rejected payment" + defp rejection_error({:settlement_failed, _reason}), do: "facilitator rejected payment" defp rejection_error({:unexpected_facilitator_status, _status}), do: "facilitator rejected payment" diff --git a/test/x402/facilitator_test.exs b/test/x402/facilitator_test.exs index 9f7a272..16d7ed4 100644 --- a/test/x402/facilitator_test.exs +++ b/test/x402/facilitator_test.exs @@ -146,8 +146,11 @@ defmodule X402.FacilitatorTest do Bypass.expect(bypass, "POST", "/verify", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => ^payment_payload, "requirements" => ^requirements} = - Jason.decode!(body) + assert %{ + "x402Version" => 2, + "paymentPayload" => ^payment_payload, + "paymentRequirements" => ^requirements + } = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"verified" => true})) end) @@ -172,8 +175,11 @@ defmodule X402.FacilitatorTest do Bypass.expect(bypass, "POST", "/settle", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => ^payment_payload, "requirements" => ^requirements} = - Jason.decode!(body) + assert %{ + "x402Version" => 2, + "paymentPayload" => ^payment_payload, + "paymentRequirements" => ^requirements + } = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"settled" => true})) end) @@ -196,8 +202,9 @@ defmodule X402.FacilitatorTest do assert {:ok, body, conn} = Plug.Conn.read_body(conn) assert %{ - "payload" => %{"beforeVerify" => true}, - "requirements" => %{"beforeVerify" => true} + "x402Version" => 2, + "paymentPayload" => %{"beforeVerify" => true}, + "paymentRequirements" => %{"beforeVerify" => true} } = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"verified" => true})) @@ -225,8 +232,9 @@ defmodule X402.FacilitatorTest do assert {:ok, body, conn} = Plug.Conn.read_body(conn) assert %{ - "payload" => %{"beforeSettle" => true}, - "requirements" => %{"beforeSettle" => true} + "x402Version" => 2, + "paymentPayload" => %{"beforeSettle" => true}, + "paymentRequirements" => %{"beforeSettle" => true} } = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"settled" => true})) @@ -313,13 +321,13 @@ defmodule X402.FacilitatorTest do } do Bypass.expect(bypass, "POST", "/verify", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => %{"beforeVerify" => true}} = Jason.decode!(body) + assert %{"paymentPayload" => %{"beforeVerify" => true}} = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"verified" => true})) end) Bypass.expect(bypass, "POST", "/settle", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => %{"beforeSettle" => true}} = Jason.decode!(body) + assert %{"paymentPayload" => %{"beforeSettle" => true}} = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"settled" => true})) end) @@ -462,13 +470,13 @@ defmodule X402.FacilitatorTest do } do Bypass.expect(bypass, "POST", "/verify", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => %{"value" => "9"}} = Jason.decode!(body) + assert %{"paymentPayload" => %{"value" => "9"}} = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"verified" => true})) end) Bypass.expect(bypass, "POST", "/settle", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert %{"payload" => %{"value" => "9"}} = Jason.decode!(body) + assert %{"paymentPayload" => %{"value" => "9"}} = Jason.decode!(body) Plug.Conn.resp(conn, 200, Jason.encode!(%{"settled" => true})) end) diff --git a/test/x402/plug/payment_gate_test.exs b/test/x402/plug/payment_gate_test.exs index 653cc4a..f2296d5 100644 --- a/test/x402/plug/payment_gate_test.exs +++ b/test/x402/plug/payment_gate_test.exs @@ -1,16 +1,47 @@ defmodule X402.Plug.PaymentGateTest do + @moduledoc """ + Spec-aligned tests for `X402.Plug.PaymentGate` against x402 v2. + + https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md + + Sections map to protocol concerns: + + * Route matching (HTTP method/path) + * PaymentRequired signaling (402 + PAYMENT-REQUIRED header) + * PaymentPayload validation and accepted matching + * HTTP status mapping (400 invalid request vs 402 payment required/failed) + * Facilitator verify/settle + PAYMENT-RESPONSE + * Multi-accept routes + * ResourceInfo / extensions + * Lifecycle hooks and telemetry + """ + use ExUnit.Case, async: false import Plug.Conn import Plug.Test + alias X402.PaymentRequired + alias X402.PaymentResponse alias X402.Plug.PaymentGate defmodule MockFacilitator do @moduledoc false use GenServer - @default_verify {:ok, %{status: 200, body: %{"verified" => true}}} - @default_settle {:ok, %{status: 200, body: %{"settled" => true}}} + @default_verify {:ok, %{status: 200, body: %{"isValid" => true, "payer" => "0xpayer"}}} + + @default_settle { + :ok, + %{ + status: 200, + body: %{ + "success" => true, + "transaction" => "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "network" => "eip155:84532", + "payer" => "0x1111111111111111111111111111111111111111" + } + } + } def start_link(opts) when is_list(opts) do GenServer.start_link(__MODULE__, opts) @@ -76,445 +107,722 @@ defmodule X402.Plug.PaymentGateTest do def on_settle_failure(%Context{} = context, _metadata), do: {:cont, context} end + @asset "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + @receiver "0x1111111111111111111111111111111111111111" + @network "eip155:84532" + @amount "10000" + @route %{ method: :get, path: "/api/resource", - price: "0.01", - network: "base-sepolia", - asset: "USDC", - receiver: "0x1111111111111111111111111111111111111111" + price: @amount, + network: @network, + asset: @asset, + pay_to: @receiver } @upto_route Map.put(@route, :scheme, "upto") - test "passes through non-gated routes" do - conn = conn(:get, "/public") - result_conn = run_request(conn, routes: [@route], facilitator: self()) + # --------------------------------------------------------------------------- + # Route matching + # --------------------------------------------------------------------------- - assert result_conn.status == 200 - assert result_conn.resp_body == "ok" - end + describe "route matching" do + test "passes through non-gated routes" do + conn = run_request(conn(:get, "/public"), routes: [@route], facilitator: self()) - test "matches exact paths with normalized trailing slash" do - conn = conn(:get, "/api/resource/") - result_conn = run_request(conn, routes: [@route], facilitator: self()) - body = Jason.decode!(result_conn.resp_body) + assert conn.status == 200 + assert conn.resp_body == "ok" + assert get_resp_header(conn, "payment-required") == [] + end - assert result_conn.status == 402 - assert body["accepts"] |> List.first() |> Map.fetch!("resource") == "/api/resource" - end + test "matches exact paths with normalized trailing slash" do + conn = run_request(conn(:get, "/api/resource/"), routes: [@route], facilitator: self()) + required = decode_payment_required!(conn) - test "matches glob routes" do - route = Map.put(@route, :path, "/api/*") - conn = conn(:get, "/api/v1/items") - result_conn = run_request(conn, routes: [route], facilitator: self()) + assert conn.status == 402 + assert required["resource"]["url"] =~ "/api/resource" + end - assert result_conn.status == 402 - end + test "matches glob routes" do + route = Map.put(@route, :path, "/api/*") + conn = run_request(conn(:get, "/api/v1/items"), routes: [route], facilitator: self()) - test "filters by method and supports :any" do - post_route = Map.put(@route, :method, :post) - any_route = %{post_route | method: :any, path: "/any"} + assert conn.status == 402 + assert get_resp_header(conn, "payment-required") != [] + end - pass_through_conn = - run_request(conn(:get, "/api/resource"), routes: [post_route], facilitator: self()) + test "filters by method and supports :any" do + post_route = Map.put(@route, :method, :post) + any_route = %{post_route | method: :any, path: "/any"} - gated_conn = run_request(conn(:put, "/any"), routes: [any_route], facilitator: self()) + pass = run_request(conn(:get, "/api/resource"), routes: [post_route], facilitator: self()) + gated = run_request(conn(:put, "/any"), routes: [any_route], facilitator: self()) - assert pass_through_conn.status == 200 - assert gated_conn.status == 402 - end + assert pass.status == 200 + assert gated.status == 402 + end - test "returns 402 response body in required x402 format" do - conn = conn(:get, "/api/resource") - result_conn = run_request(conn, routes: [@route], facilitator: self()) - body = Jason.decode!(result_conn.resp_body) - [accept] = body["accepts"] - - assert result_conn.status == 402 - assert get_resp_header(result_conn, "content-type") == ["application/json; charset=utf-8"] - assert body["x402Version"] == 1 - assert body["error"] == "" - assert accept["scheme"] == "exact" - assert accept["network"] == "base-sepolia" - assert accept["maxAmountRequired"] == "0.01" - assert accept["resource"] == "/api/resource" - assert accept["description"] == "Payment required" - assert accept["mimeType"] == "application/json" - assert accept["payTo"] == "0x1111111111111111111111111111111111111111" - assert accept["maxTimeoutSeconds"] == 60 - assert accept["extra"] == %{} - end + test "first matching route wins" do + route1 = Map.put(@route, :path, "/api/resource") + route2 = Map.put(@route, :path, "/api/*") - test "returns 402 response with maxPrice for upto scheme routes" do - conn = conn(:get, "/api/resource") - result_conn = run_request(conn, routes: [@upto_route], facilitator: self()) - body = Jason.decode!(result_conn.resp_body) - [accept] = body["accepts"] + conn = + run_request(conn(:get, "/api/resource"), routes: [route1, route2], facilitator: self()) - assert result_conn.status == 402 - assert accept["scheme"] == "upto" - assert accept["maxPrice"] == "0.01" - refute Map.has_key?(accept, "maxAmountRequired") - end + required = decode_payment_required!(conn) + assert required["resource"]["url"] =~ "/api/resource" + end - test "verifies and settles valid payments before pass-through" do - facilitator = start_mock_facilitator() + test "normalizes root path and unknown methods via :any" do + root = Map.put(@route, :path, "/") + assert run_request(conn(:get, "/"), routes: [root], facilitator: self()).status == 402 - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + any = Map.put(@route, :method, :any) - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + assert run_request(Plug.Test.conn("PURGE", "/api/resource"), + routes: [any], + facilitator: self() + ).status == 402 + end - assert result_conn.status == 200 + test "supports all standard HTTP methods" do + for {method_string, method_atom} <- [ + {"DELETE", :delete}, + {"HEAD", :head}, + {"OPTIONS", :options}, + {"PATCH", :patch}, + {"POST", :post}, + {"PUT", :put}, + {"TRACE", :trace} + ] do + route = Map.put(@route, :method, method_atom) + + conn = + run_request(Plug.Test.conn(method_string, "/api/resource"), + routes: [route], + facilitator: self() + ) + + assert conn.status == 402 + end + end + end - assert_receive {:verify_called, payload, requirements, hooks_module} + # --------------------------------------------------------------------------- + # PaymentRequired (402 signaling) — §5.1 + HTTP transport + # --------------------------------------------------------------------------- + + describe "PaymentRequired response (402)" do + test "emits PAYMENT-REQUIRED header with full v2 PaymentRequired schema" do + conn = run_request(conn(:get, "/api/resource"), routes: [@route], facilitator: self()) + required = decode_payment_required!(conn) + [accept] = required["accepts"] + + assert conn.status == 402 + assert conn.resp_body == "{}" + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + + assert required["x402Version"] == 2 + assert required["error"] == "PAYMENT-SIGNATURE header is required" + assert is_map(required["resource"]) + assert required["resource"]["url"] =~ "/api/resource" + assert required["resource"]["description"] == "Payment required" + assert required["resource"]["mimeType"] == "application/json" + assert required["extensions"] == %{} + + assert accept["scheme"] == "exact" + assert accept["network"] == @network + assert accept["amount"] == @amount + assert accept["asset"] == @asset + assert accept["payTo"] == @receiver + assert accept["maxTimeoutSeconds"] == 60 + assert accept["extra"] == %{} + + # v2: amount not maxAmountRequired; resource not nested under accepts + refute Map.has_key?(accept, "maxAmountRequired") + refute Map.has_key?(accept, "resource") + end - assert payload["transactionHash"] == - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + test "upto scheme advertises amount as max authorized amount" do + conn = run_request(conn(:get, "/api/resource"), routes: [@upto_route], facilitator: self()) + [accept] = decode_payment_required!(conn)["accepts"] - assert requirements["network"] == "base-sepolia" - assert requirements["asset"] == "USDC" - assert requirements["resource"] == "/api/resource" - assert hooks_module == nil + assert accept["scheme"] == "upto" + assert accept["amount"] == @amount + refute Map.has_key?(accept, "maxPrice") + refute Map.has_key?(accept, "maxAmountRequired") + end - assert_receive {:settle_called, payload, ^requirements, ^hooks_module} - assert payload["payerWallet"] == "0x1111111111111111111111111111111111111111" + test "includes optional ResourceInfo fields and extensions" do + route = + Map.merge(@route, %{ + description: "Premium market data", + mime_type: "application/json", + service_name: "Market Data", + tags: ["finance", "market-data"], + icon_url: "https://api.example.com/icon.png", + extensions: %{"bazaar" => %{"info" => %{}, "schema" => %{}}}, + extra: %{"name" => "USDC", "version" => "2"}, + max_timeout_seconds: 120 + }) + + required = + conn(:get, "/api/resource") + |> run_request(routes: [route], facilitator: self()) + |> decode_payment_required!() + + assert required["resource"]["description"] == "Premium market data" + assert required["resource"]["serviceName"] == "Market Data" + assert required["resource"]["tags"] == ["finance", "market-data"] + assert required["resource"]["iconUrl"] == "https://api.example.com/icon.png" + assert required["extensions"]["bazaar"]["info"] == %{} + + [accept] = required["accepts"] + assert accept["maxTimeoutSeconds"] == 120 + assert accept["extra"] == %{"name" => "USDC", "version" => "2"} + end end - test "passes configured hooks module to facilitator calls" do - facilitator = start_mock_facilitator() - - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) - - result_conn = - run_request( - conn, - routes: [@route], - facilitator: facilitator, - hooks: TrackingHooks - ) - - assert result_conn.status == 200 - assert_receive {:verify_called, _payload, requirements, TrackingHooks} - assert requirements["resource"] == "/api/resource" - assert_receive {:settle_called, _payload, ^requirements, TrackingHooks} - end + # --------------------------------------------------------------------------- + # PaymentPayload structure + accepted matching — §5.2 + # --------------------------------------------------------------------------- - test "verifies and settles valid upto payments before pass-through" do - facilitator = start_mock_facilitator() + describe "PaymentPayload structure" do + test "requires x402Version 2 (missing version is invalid)" do + facilitator = start_mock_facilitator() - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_upto_payment_header("0.009")) + header = + valid_payment_payload() + |> Map.delete("x402Version") + |> encode_header() - result_conn = run_request(conn, routes: [@upto_route], facilitator: facilitator) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", header) + |> run_request(routes: [@route], facilitator: facilitator) - assert result_conn.status == 200 + required = decode_payment_required!(conn) - assert_receive {:verify_called, payload, requirements, hooks_module} - assert payload["scheme"] == "upto" - assert payload["value"] == "0.009" - assert requirements["scheme"] == "upto" - assert requirements["maxPrice"] == "0.01" - refute Map.has_key?(requirements, "maxAmountRequired") - assert hooks_module == nil + assert conn.status == 400 + assert required["error"] == "invalid_x402_version" + refute_received {:verify_called, _, _, _} + end - assert_receive {:settle_called, _payload, ^requirements, ^hooks_module} - end + test "rejects x402Version other than 2 with 400" do + facilitator = start_mock_facilitator() - test "rejects upto payments when value exceeds route maxPrice" do - facilitator = start_mock_facilitator() + header = + valid_payment_payload() + |> Map.put("x402Version", 1) + |> encode_header() - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_upto_payment_header("0.02")) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", header) + |> run_request(routes: [@route], facilitator: facilitator) - result_conn = run_request(conn, routes: [@upto_route], facilitator: facilitator) - body = Jason.decode!(result_conn.resp_body) + assert conn.status == 400 + assert decode_payment_required!(conn)["error"] == "invalid_x402_version" + refute_received {:verify_called, _, _, _} + end - assert result_conn.status == 402 - assert body["error"] == "invalid payment payload" - refute_received {:verify_called, _payload, _requirements, _hooks_module} - end + test "rejects payload missing accepted or payload with 400" do + facilitator = start_mock_facilitator() - test "rejects invalid x-payment header values" do - facilitator = start_mock_facilitator() + header = + %{"x402Version" => 2, "network" => @network} + |> Jason.encode!() + |> Base.encode64() - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", "not-valid-base64") + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", header) + |> run_request(routes: [@route], facilitator: facilitator) - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) - body = Jason.decode!(result_conn.resp_body) + assert conn.status == 400 + assert decode_payment_required!(conn)["error"] == "invalid_payload" + refute_received {:verify_called, _, _, _} + end - assert result_conn.status == 402 - assert body["error"] == "invalid payment header" - refute_received {:verify_called, _payload, _requirements, _hooks_module} - end + test "rejects invalid base64 and invalid JSON with 400" do + facilitator = start_mock_facilitator() - test "rejects when facilitator verification fails" do - verify_failure = fn _payment_payload, _requirements -> {:error, :verification_failed} end - facilitator = start_mock_facilitator(verify: verify_failure) + bad_b64 = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", "not-valid-base64") + |> run_request(routes: [@route], facilitator: facilitator) - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + assert bad_b64.status == 400 + assert decode_payment_required!(bad_b64)["error"] == "invalid payment header" - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + bad_json = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", Base.encode64("not json")) + |> run_request(routes: [@route], facilitator: facilitator) - assert result_conn.status == 402 - refute_received {:settle_called, _payload, _requirements, _hooks_module} - end + assert bad_json.status == 400 + assert decode_payment_required!(bad_json)["error"] == "invalid payment header" + end + + test "rejects empty PAYMENT-SIGNATURE with 400" do + facilitator = start_mock_facilitator() - test "emits pass_through, payment_required, payment_verified, and payment_rejected telemetry events" do - ok_facilitator = start_mock_facilitator() + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", "") + |> run_request(routes: [@route], facilitator: facilitator) - reject_verify = fn _payment_payload, _requirements -> {:error, :declined} end - reject_facilitator = start_mock_facilitator(verify: reject_verify) + assert conn.status == 400 + assert decode_payment_required!(conn)["error"] == "invalid payment header" + end + end - handler_id = "payment-gate-#{System.unique_integer([:positive, :monotonic])}" - parent = self() + describe "accepted requirements matching" do + for {field, value} <- [ + {"scheme", "upto"}, + {"network", "eip155:8453"}, + {"asset", "0x0000000000000000000000000000000000000001"}, + {"payTo", "0x2222222222222222222222222222222222222222"}, + {"amount", "99999"} + ] do + test "rejects accepted.#{field} mismatch with 402 and no facilitator call" do + facilitator = start_mock_facilitator() + field = unquote(field) + value = unquote(value) + + header = + valid_payment_payload() + |> put_in(["accepted", field], value) + |> encode_header() + + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", header) + |> run_request(routes: [@route], facilitator: facilitator) + + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "No matching payment requirements" + refute_received {:verify_called, _, _, _} + end + end - :ok = - :telemetry.attach_many( - handler_id, - [ - [:x402, :plug, :pass_through], - [:x402, :plug, :payment_required], - [:x402, :plug, :payment_verified], - [:x402, :plug, :payment_rejected] - ], - fn event, measurements, metadata, _config -> - send(parent, {:telemetry_event, event, measurements, metadata}) - end, - nil - ) + test "uses matched requirements for verify and settle" do + facilitator = start_mock_facilitator() - on_exit(fn -> :telemetry.detach(handler_id) end) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - run_request(conn(:get, "/public"), routes: [@route], facilitator: ok_facilitator) - run_request(conn(:get, "/api/resource"), routes: [@route], facilitator: ok_facilitator) + assert conn.status == 200 - verified_conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) - |> run_request(routes: [@route], facilitator: ok_facilitator) + assert_receive {:verify_called, _payload, requirements, _} + assert requirements["scheme"] == "exact" + assert requirements["network"] == @network + assert requirements["amount"] == @amount + assert requirements["asset"] == @asset + assert requirements["payTo"] == @receiver - rejected_conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) - |> run_request(routes: [@route], facilitator: reject_facilitator) + assert_receive {:settle_called, _payload, ^requirements, _} + end + end - assert verified_conn.status == 200 - assert rejected_conn.status == 402 + # --------------------------------------------------------------------------- + # Multi-accept routes + # --------------------------------------------------------------------------- + + describe "multi-accept routes" do + @solana_accept %{ + scheme: "exact", + price: "5000", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + pay_to: "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5" + } - assert_receive {:telemetry_event, [:x402, :plug, :pass_through], %{count: 1}, - %{path: "/public"}} + @multi_route %{ + method: :get, + path: "/api/resource", + accepts: [ + %{ + scheme: "exact", + price: @amount, + network: @network, + asset: @asset, + pay_to: @receiver + }, + @solana_accept + ] + } - assert_receive {:telemetry_event, [:x402, :plug, :payment_required], %{count: 1}, - %{path: "/api/resource"}} + test "PAYMENT-REQUIRED advertises all accepts" do + required = + conn(:get, "/api/resource") + |> run_request(routes: [@multi_route], facilitator: self()) + |> decode_payment_required!() - assert_receive {:telemetry_event, [:x402, :plug, :payment_verified], %{count: 1}, - %{path: "/api/resource"}} + assert length(required["accepts"]) == 2 - assert_receive {:telemetry_event, [:x402, :plug, :payment_rejected], %{count: 1}, - %{path: "/api/resource"}} - end + assert Enum.any?( + required["accepts"], + &(&1["network"] == @network and &1["amount"] == @amount) + ) - test "init/1 raises NimbleOptions validation errors for invalid config" do - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init(facilitator: self()) + assert Enum.any?( + required["accepts"], + &(&1["network"] == @solana_accept.network and &1["amount"] == "5000") + ) end - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init(routes: :invalid) + test "selects the matching accept among multiple options" do + facilitator = start_mock_facilitator() + + solana_payload = + valid_payment_payload() + |> put_in(["accepted", "scheme"], "exact") + |> put_in(["accepted", "network"], @solana_accept.network) + |> put_in(["accepted", "amount"], @solana_accept.price) + |> put_in(["accepted", "asset"], @solana_accept.asset) + |> put_in(["accepted", "payTo"], @solana_accept.pay_to) + + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", encode_header(solana_payload)) + |> run_request(routes: [@multi_route], facilitator: facilitator) + + assert conn.status == 200 + assert_receive {:verify_called, _payload, requirements, _} + assert requirements["network"] == @solana_accept.network + assert requirements["amount"] == @solana_accept.price + assert requirements["payTo"] == @solana_accept.pay_to end - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init(routes: [%{method: :get, path: "/api"}]) - end + test "rejects when accepted matches none of the multi-accept options" do + facilitator = start_mock_facilitator() - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init( - routes: [ - %{method: :foo, path: "/api", price: "1", network: "n", asset: "a", receiver: "r"} - ] - ) - end + header = + valid_payment_payload() + |> put_in(["accepted", "network"], "eip155:1") + |> encode_header() - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init(routes: [@route], hooks: :not_a_hook_module) - end + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", header) + |> run_request(routes: [@multi_route], facilitator: facilitator) - assert_raise NimbleOptions.ValidationError, fn -> - PaymentGate.init(routes: [Map.put(@route, :scheme, "invalid")]) + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "No matching payment requirements" + refute_received {:verify_called, _, _, _} end end - test "rejects empty x-payment header" do - facilitator = start_mock_facilitator() + # --------------------------------------------------------------------------- + # Happy path: verify → settle → PAYMENT-RESPONSE + assigns + # --------------------------------------------------------------------------- - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", "") + describe "successful payment flow" do + test "verifies, settles, attaches PAYMENT-RESPONSE, and assigns payload" do + facilitator = start_mock_facilitator() - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) - body = Jason.decode!(result_conn.resp_body) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - assert result_conn.status == 402 - assert body["error"] == "invalid payment header" - refute_received {:verify_called, _payload, _requirements, _hooks_module} - end + assert conn.status == 200 + assert conn.assigns[:x402_payment_payload]["x402Version"] == 2 + assert conn.assigns[:x402_payment_requirements]["amount"] == @amount - test "rejects when settlement fails" do - settle_failure = fn _payment_payload, _requirements -> {:error, :settlement_failed} end - facilitator = start_mock_facilitator(settle: settle_failure) + settle = decode_payment_response!(conn) + assert settle["success"] == true + assert settle["network"] == @network + assert settle["transaction"] != "" - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + assert_receive {:verify_called, payload, requirements, nil} + assert payload["accepted"]["scheme"] == "exact" + assert payload["payload"]["authorization"]["from"] == @receiver + assert requirements["asset"] == @asset - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) - - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "payment verification failed" - end - - test "rejects when verify returns non-200 status" do - facilitator = - start_mock_facilitator(verify: {:ok, %{status: 400, body: %{"error" => "invalid"}}}) + assert_receive {:settle_called, ^payload, ^requirements, nil} + end - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + test "passes configured hooks module to facilitator calls" do + facilitator = start_mock_facilitator() - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator, hooks: TrackingHooks) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "facilitator rejected payment" - end + assert conn.status == 200 + assert_receive {:verify_called, _, requirements, TrackingHooks} + assert requirements["amount"] == @amount + assert_receive {:settle_called, _, ^requirements, TrackingHooks} + end - test "rejects when settle returns non-200 status" do - facilitator = - start_mock_facilitator(settle: {:ok, %{status: 500, body: %{"error" => "failed"}}}) + test "verifies and settles valid upto payments under max amount" do + facilitator = start_mock_facilitator() + + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_upto_payment_header("9000")) + |> run_request(routes: [@upto_route], facilitator: facilitator) + + assert conn.status == 200 + assert_receive {:verify_called, payload, requirements, nil} + assert payload["accepted"]["scheme"] == "upto" + assert payload["payload"]["authorization"]["value"] == "9000" + assert requirements["scheme"] == "upto" + assert requirements["amount"] == @amount + assert_receive {:settle_called, _, ^requirements, nil} + end - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + test "rejects upto payments when authorization value exceeds route amount" do + facilitator = start_mock_facilitator() - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_upto_payment_header("20000")) + |> run_request(routes: [@upto_route], facilitator: facilitator) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "facilitator rejected payment" + assert conn.status == 400 + assert decode_payment_required!(conn)["error"] == "invalid_payload" + refute_received {:verify_called, _, _, _} + end end - test "rejects payment with missing required fields" do - facilitator = start_mock_facilitator() - - # Encode JSON with missing required fields (no transactionHash, no scheme) - header = - %{"network" => "base-sepolia"} - |> Jason.encode!() - |> Base.encode64() - - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", header) + # --------------------------------------------------------------------------- + # Facilitator failure modes + PAYMENT-RESPONSE + # --------------------------------------------------------------------------- - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + describe "facilitator failures" do + test "returns 402 when verify returns error" do + facilitator = + start_mock_facilitator(verify: fn _, _ -> {:error, :verification_failed} end) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "invalid payment payload" - refute_received {:verify_called, _payload, _requirements, _hooks_module} - end + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - test "rejection_error for invalid_json in payment header" do - facilitator = start_mock_facilitator() + assert conn.status == 402 + refute_received {:settle_called, _, _, _} + end - # Valid base64 but not valid JSON - header = Base.encode64("not json at all") + test "returns 402 when verify body has isValid false" do + facilitator = + start_mock_facilitator( + verify: + {:ok, + %{ + status: 200, + body: %{"isValid" => false, "invalidReason" => "insufficient_funds"} + }} + ) + + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) + + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "facilitator rejected payment" + refute_received {:settle_called, _, _, _} + end - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", header) + test "returns 402 when settle fails with transport error" do + facilitator = + start_mock_facilitator(settle: fn _, _ -> {:error, :settlement_failed} end) - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "invalid payment header" - refute_received {:verify_called, _payload, _requirements, _hooks_module} - end + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "payment verification failed" + end - test "handles multiple routes with first match winning" do - route1 = Map.put(@route, :path, "/api/resource") - route2 = Map.put(@route, :path, "/api/*") + test "returns 402 with PAYMENT-RESPONSE when settle success is false" do + settle_body = %{ + "success" => false, + "errorReason" => "insufficient_funds", + "transaction" => "", + "network" => @network, + "payer" => @receiver + } - conn = conn(:get, "/api/resource") - result_conn = run_request(conn, routes: [route1, route2], facilitator: self()) + facilitator = + start_mock_facilitator(settle: {:ok, %{status: 200, body: settle_body}}) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["accepts"] |> List.first() |> Map.fetch!("resource") == "/api/resource" - end + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - test "normalize_method handles all HTTP methods" do - for {method_string, method_atom} <- [ - {"DELETE", :delete}, - {"HEAD", :head}, - {"OPTIONS", :options}, - {"PATCH", :patch}, - {"POST", :post}, - {"PUT", :put}, - {"TRACE", :trace} - ] do - route = Map.put(@route, :method, method_atom) + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "facilitator rejected payment" - conn = Plug.Test.conn(method_string, "/api/resource") - result_conn = run_request(conn, routes: [route], facilitator: self()) - assert result_conn.status == 402 + response = decode_payment_response!(conn) + assert response["success"] == false + assert response["errorReason"] == "insufficient_funds" end - end - test "rejects invalid payload reason from facilitator verify" do - facilitator = start_mock_facilitator(verify: {:error, :invalid_payload}) + test "returns 402 when verify or settle return non-2xx HTTP status" do + for {key, result} <- [ + {:verify, {:ok, %{status: 400, body: %{"error" => "invalid"}}}}, + {:settle, {:ok, %{status: 500, body: %{"error" => "failed"}}}} + ] do + facilitator = start_mock_facilitator([{key, result}]) + + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) + + assert conn.status == 402 + assert decode_payment_required!(conn)["error"] == "facilitator rejected payment" + end + end - conn = - conn(:get, "/api/resource") - |> put_req_header("x-payment", valid_payment_header()) + test "maps invalid_payload from facilitator using protocol error code" do + facilitator = start_mock_facilitator(verify: {:error, :invalid_payload}) - result_conn = run_request(conn, routes: [@route], facilitator: facilitator) + conn = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: facilitator) - assert result_conn.status == 402 - body = Jason.decode!(result_conn.resp_body) - assert body["error"] == "invalid payment payload" + # Local and facilitator invalid_payload both surface the protocol code; + # HTTP transport maps invalid payment to 400. + assert conn.status == 400 + assert decode_payment_required!(conn)["error"] == "invalid_payload" + end end - test "normalize_path handles root path" do - route = Map.put(@route, :path, "/") + # --------------------------------------------------------------------------- + # Config validation + # --------------------------------------------------------------------------- + + describe "init/1 validation" do + test "raises on missing routes and invalid values" do + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init(facilitator: self()) + end + + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init(routes: :invalid) + end + + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init(routes: [%{method: :get, path: "/api"}]) + end + + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init( + routes: [ + %{method: :foo, path: "/api", price: "1", network: "n", asset: "a", pay_to: "r"} + ] + ) + end + + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init(routes: [@route], hooks: :not_a_hook_module) + end + + assert_raise NimbleOptions.ValidationError, fn -> + PaymentGate.init(routes: [Map.put(@route, :scheme, "invalid")]) + end + end - conn = conn(:get, "/") - result_conn = run_request(conn, routes: [route], facilitator: self()) - assert result_conn.status == 402 + test "accepts multi-accept routes without top-level price fields" do + opts = + PaymentGate.init( + routes: [ + %{ + method: :get, + path: "/paid", + accepts: [ + %{ + price: "1", + network: "eip155:1", + asset: "0xabc", + pay_to: "0xdef" + } + ] + } + ], + facilitator: self() + ) + + assert length(hd(opts.routes).accepts) == 1 + end end - test "unknown method normalizes to :any" do - route = Map.put(@route, :method, :any) - - # Use a custom method (non-standard) - conn = Plug.Test.conn("PURGE", "/api/resource") - result_conn = run_request(conn, routes: [route], facilitator: self()) - assert result_conn.status == 402 + # --------------------------------------------------------------------------- + # Telemetry + # --------------------------------------------------------------------------- + + describe "telemetry" do + test "emits pass_through, payment_required, payment_verified, payment_rejected" do + ok = start_mock_facilitator() + reject = start_mock_facilitator(verify: fn _, _ -> {:error, :declined} end) + + handler_id = "payment-gate-#{System.unique_integer([:positive, :monotonic])}" + parent = self() + + :ok = + :telemetry.attach_many( + handler_id, + [ + [:x402, :plug, :pass_through], + [:x402, :plug, :payment_required], + [:x402, :plug, :payment_verified], + [:x402, :plug, :payment_rejected] + ], + fn event, measurements, metadata, _ -> + send(parent, {:telemetry_event, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + run_request(conn(:get, "/public"), routes: [@route], facilitator: ok) + run_request(conn(:get, "/api/resource"), routes: [@route], facilitator: ok) + + verified = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: ok) + + rejected = + conn(:get, "/api/resource") + |> put_req_header("payment-signature", valid_payment_header()) + |> run_request(routes: [@route], facilitator: reject) + + assert verified.status == 200 + assert rejected.status == 402 + + assert_receive {:telemetry_event, [:x402, :plug, :pass_through], %{count: 1}, + %{path: "/public"}} + + assert_receive {:telemetry_event, [:x402, :plug, :payment_required], %{count: 1}, + %{path: "/api/resource"}} + + assert_receive {:telemetry_event, [:x402, :plug, :payment_verified], %{count: 1}, + %{path: "/api/resource"}} + + assert_receive {:telemetry_event, [:x402, :plug, :payment_rejected], %{count: 1}, + %{path: "/api/resource"}} + end end + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + defp run_request(conn, opts) do conn |> PaymentGate.call(PaymentGate.init(opts)) @@ -524,27 +832,62 @@ defmodule X402.Plug.PaymentGateTest do defp maybe_send_ok(%Plug.Conn{halted: true} = conn), do: conn defp maybe_send_ok(conn), do: Plug.Conn.send_resp(conn, 200, "ok") - defp valid_payment_header do + defp decode_payment_required!(conn) do + [header] = get_resp_header(conn, "payment-required") + assert {:ok, payload} = PaymentRequired.decode(header) + payload + end + + defp decode_payment_response!(conn) do + [header] = get_resp_header(conn, "payment-response") + assert {:ok, payload} = PaymentResponse.decode(header) + payload + end + + defp valid_payment_payload do %{ - "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "network" => "base-sepolia", - "scheme" => "exact", - "payerWallet" => "0x1111111111111111111111111111111111111111" + "x402Version" => 2, + "resource" => %{ + "url" => "http://www.example.com/api/resource", + "description" => "Payment required", + "mimeType" => "application/json" + }, + "accepted" => %{ + "scheme" => "exact", + "network" => @network, + "amount" => @amount, + "asset" => @asset, + "payTo" => @receiver, + "maxTimeoutSeconds" => 60, + "extra" => %{} + }, + "payload" => %{ + "signature" => + "0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c", + "authorization" => %{ + "from" => @receiver, + "to" => @receiver, + "value" => @amount, + "validAfter" => "1740672089", + "validBefore" => "1740672154", + "nonce" => "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480" + } + }, + "extensions" => %{} } - |> Jason.encode!() - |> Base.encode64() end + defp valid_payment_header, do: encode_header(valid_payment_payload()) + defp valid_upto_payment_header(value) do - %{ - "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "network" => "base-sepolia", - "scheme" => "upto", - "payerWallet" => "0x1111111111111111111111111111111111111111", - "value" => value - } - |> Jason.encode!() - |> Base.encode64() + valid_payment_payload() + |> put_in(["accepted", "scheme"], "upto") + |> put_in(["payload", "authorization", "value"], value) + |> encode_header() + end + + defp encode_header(payload) when is_map(payload) do + payload |> Jason.encode!() |> Base.encode64() end defp start_mock_facilitator(opts \\ []) do From 226c66e8868c5a9f56d4236b017e7ed6ea1d9059 Mon Sep 17 00:00:00 2001 From: Marvin Krause Date: Tue, 28 Jul 2026 16:16:25 +0200 Subject: [PATCH 2/3] update documentation --- README.md | 36 +++-- ROADMAP.md | 2 + guides/getting-started.md | 20 ++- guides/plug-integration.md | 278 ++++++++++++++++++++++++++++++++++--- 4 files changed, 298 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 12ad09f..2703b5e 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,24 @@ end ### Accept payments in Phoenix +Route specification according to V2 specification of x402. +See https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md + ```elixir # In your router or endpoint plug X402.Plug.PaymentGate, facilitator_url: "https://x402-facilitator-app.fly.dev", - routes: %{ - "GET /api/weather" => %{ + routes: [ + %{ + method: :get, + path: "/api/weather", price: "0.005", network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", pay_to: "0xYourWalletAddress", description: "Weather data API" } - } + ] ``` That's it. Requests without payment get a `402` response with payment instructions. Requests with a valid `PAYMENT-SIGNATURE` header are verified and passed through. @@ -99,7 +105,16 @@ end plug X402.Plug.PaymentGate, facilitator_url: "https://x402-facilitator-app.fly.dev", hooks: MyApp.PaymentHooks, - routes: %{...} + routes: [ + %{ + method: :get, + path: "/api/data", + price: "0.01", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress" + } + ] ``` ### "upto" scheme — flexible pricing @@ -108,15 +123,18 @@ plug X402.Plug.PaymentGate, # Server: accept up to a max price (agent bids what they're willing to pay) plug X402.Plug.PaymentGate, facilitator_url: "https://x402-facilitator-app.fly.dev", - routes: %{ - "GET /api/premium" => %{ + routes: [ + %{ + method: :get, + path: "/api/premium", scheme: "upto", - maxPrice: "1.00", + price: "1.00", network: "eip155:8453", - pay_to: "0xYourWallet", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress", description: "Premium data — pay what you want up to $1" } - } + ] # Encode/decode upto payment requirements {:ok, header} = X402.PaymentRequired.encode(%{ diff --git a/ROADMAP.md b/ROADMAP.md index ce36fb9..4bb380b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,8 @@ - [ ] Facilitator client support for upto verification - [ ] Tests + docs +### "batch" settlement scheme +- [ ] --- ## v0.3 — SIWX (Sign-In-With-X) diff --git a/guides/getting-started.md b/guides/getting-started.md index 2adf8ec..77d0fde 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -9,7 +9,7 @@ Add `x402` and a HTTP client to your dependencies: ```elixir def deps do [ - {:x402, "~> 0.1"}, + {:x402, "~> 0.3"}, {:finch, "~> 0.19"} ] end @@ -62,13 +62,16 @@ For the simplest integration, use the Plug middleware in your Phoenix router: pipeline :paid_api do plug X402.Plug.PaymentGate, facilitator_url: "https://x402.org/facilitator", - routes: %{ - "GET /api/data" => %{ + routes: [ + %{ + method: :get, + path: "/api/data", price: "0.01", network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", pay_to: "0xYourWallet" } - } + ] end scope "/api" do @@ -77,6 +80,9 @@ scope "/api" do end ``` -Unpaid requests receive a `402 Payment Required` response with the pricing details -encoded in the `PAYMENT-REQUIRED` header. Clients that include a valid -`PAYMENT-SIGNATURE` header are passed through to your controller. +Unpaid requests receive a `402 Payment Required` response with a +`PAYMENT-REQUIRED` header containing v2 payment options. Clients that include +a valid `PAYMENT-SIGNATURE` header are verified, settled, and passed through +to your controller. The decoded payment payload and matched requirements are +available on `conn.assigns.x402_payment_payload` and +`conn.assigns.x402_payment_requirements`. diff --git a/guides/plug-integration.md b/guides/plug-integration.md index 4f41eb9..d5db0ad 100644 --- a/guides/plug-integration.md +++ b/guides/plug-integration.md @@ -1,48 +1,282 @@ # Plug/Phoenix Integration The `X402.Plug.PaymentGate` module provides drop-in payment gating for any -Plug-compatible application, including Phoenix. +Plug-compatible application, including Phoenix. It implements the +[x402 v2 HTTP transport](https://github.com/x402-foundation/x402/blob/main/specs/transports-v2/http.md). ## Configuration The plug accepts these options (validated via `NimbleOptions`): +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `:facilitator` | `GenServer.server()` | no | `X402.Facilitator` | Facilitator process name or pid for verify/settle calls | +| `:hooks` | `module()` | no | `X402.Hooks.Default` | Lifecycle hook module implementing `X402.Hooks` | +| `:payment_identifier_cache` | `atom() \| pid()` | no | `nil` | `ETSCache` server for idempotency (strongly recommended) | +| `:routes` | `[map()]` | **yes** | — | Route gate definitions (see below) | + +> **Important:** When `:payment_identifier_cache` is not configured, the plug +> emits a runtime warning. Without it, concurrent identical requests can +> double-settle the same payment proof. + +## Route Definitions + +Routes are a list of maps. Each map describes one gated endpoint: + +```elixir +plug X402.Plug.PaymentGate, + facilitator: MyApp.Facilitator, + routes: [ + %{ + method: :get, + path: "/api/data", + price: "0.01", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress" + } + ] +``` + +### Route options + | Option | Type | Required | Description | |--------|------|----------|-------------| -| `:facilitator_url` | string | yes | URL of the x402 facilitator | -| `:routes` | map | yes | Route patterns → payment requirements | -| `:finch` | atom | no | Finch instance name (default: `X402.Finch`) | -| `:on_payment_verified` | function | no | Callback after successful verification | -| `:on_payment_failed` | function | no | Callback after failed verification | +| `:method` | `atom()` | **yes** | HTTP method (`:get`, `:post`, `:put`, `:delete`, `:patch`, `:head`, `:options`, `:trace`, or `:any` for all) | +| `:path` | `String.t()` | **yes** | Route path. Exact matches (`/api/data`) or glob patterns (`/api/*`) | +| `:accepts` | `[map()]` | no | Multiple payment options (see "Multiple Accepts" below) | +| `:scheme` | `String.t()` | no | `"exact"` (default) or `"upto"` | +| `:price` | `String.t()` | conditionally | Payment amount in atomic token units. Required when `:accepts` is empty | +| `:network` | `String.t()` | conditionally | CAIP-2 network identifier (e.g. `"eip155:8453"`) | +| `:asset` | `String.t()` | conditionally | Token contract address | +| `:pay_to` | `String.t()` | conditionally | Recipient wallet address | +| `:description` | `String.t()` | no | Resource description (default: `"Payment required"`) | +| `:mime_type` | `String.t()` | no | Resource MIME type (default: `"application/json"`) | +| `:service_name` | `String.t()` | no | Service name for display (max 32 chars recommended) | +| `:tags` | `[String.t()]` | no | Resource tags (max 5 recommended) | +| `:icon_url` | `String.t()` | no | Absolute URL to a service icon | +| `:max_timeout_seconds` | `pos_integer()` | no | Max payment completion time (default: `60`) | +| `:extra` | `map()` | no | Scheme-specific extra fields | +| `:extensions` | `map()` | no | Protocol extensions advertised in `PAYMENT-REQUIRED` | + +When `:accepts` is empty (the default), a single payment option is built from +the top-level `:scheme`, `:price`, `:network`, `:asset`, and `:pay_to` fields. -## Route Patterns +### Multiple Accepts -Routes are matched by `"METHOD /path"` strings: +For routes that accept multiple payment options (different schemes, networks, or +amounts), use the `:accepts` list: ```elixir -routes = %{ - "GET /api/weather" => %{price: "0.005", network: "eip155:8453", pay_to: "0x..."}, - "POST /api/generate" => %{price: "0.05", network: "eip155:8453", pay_to: "0x..."}, - "* /api/premium/*" => %{price: "0.01", network: "eip155:8453", pay_to: "0x..."} +%{ + method: :post, + path: "/api/generate", + accepts: [ + %{ + scheme: "exact", + price: "0.01", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWallet" + }, + %{ + scheme: "exact", + price: "0.005", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + pay_to: "YourSolanaAddress" + } + ] } ``` -## Custom Callbacks +The client's `PaymentPayload.accepted` is matched against the server's +`accepts` by equality on `scheme`, `network`, `amount`, `asset`, and `payTo`. + +## Lifecycle Hooks + +Hooks let you intercept the payment flow for logging, custom validation, or +post-settlement logic. Implement the `X402.Hooks` behaviour: ```elixir -plug X402.Plug.PaymentGate, - facilitator_url: "https://x402.org/facilitator", - routes: routes, - on_payment_verified: fn conn, payload -> - Logger.info("Payment from #{payload["payerWallet"]}") - conn +defmodule MyApp.PaymentHooks do + @behaviour X402.Hooks + + @impl true + def before_verify(context, _metadata) do + IO.inspect(context.payment, label: "Incoming payment") + {:ok, context} end + + @impl true + def after_verify(context, _metadata) do + {:ok, context} + end + + @impl true + def after_settle(context, _metadata) do + # Post-settlement: update DB, send receipt, etc. + {:ok, context} + end + + @impl true + def before_settle(context, _metadata), do: {:ok, context} + + @impl true + def on_verify_failure(context, _metadata), do: {:ok, context} + + @impl true + def on_settle_failure(context, _metadata), do: {:ok, context} +end +``` + +Pass the module to the plug: + +```elixir +plug X402.Plug.PaymentGate, + facilitator: MyApp.Facilitator, + hooks: MyApp.PaymentHooks, + routes: [...] ``` +## Idempotency (Payment Identifier Cache) + +To prevent double-settlement of the same payment proof from concurrent +requests, configure an ETS cache: + +```elixir +# In your supervision tree +children = [ + {X402.Extensions.PaymentIdentifier.ETSCache, name: MyApp.PaymentCache}, + # ... other children +] + +# In your plug config +plug X402.Plug.PaymentGate, + facilitator: MyApp.Facilitator, + payment_identifier_cache: MyApp.PaymentCache, + routes: [...] +``` + +The plug performs an atomic `put_new` claim on the payment proof hash before +settlement. If the claim fails (duplicate), the request is rejected with +`"payment already processed"`. + +## Conn Assigns + +After successful verification and settlement, the plug assigns these to the +connection: + +| Assign | Value | +|--------|-------| +| `:x402_payment_payload` | The decoded `PaymentPayload` map | +| `:x402_payment_requirements` | The matched `PaymentRequirements` map | + +Your controller can access these: + +```elixir +def show(conn, _params) do + payload = conn.assigns.x402_payment_payload + requirements = conn.assigns.x402_payment_requirements + + # The payer's wallet address, transaction hash, etc. + # are available in the payload + + json(conn, %{data: "premium content"}) +end +``` + +## Payment Response + +On successful settlement, a `PAYMENT-RESPONSE` header is attached to the +response. On settlement failure, the response includes both +`PAYMENT-REQUIRED` (so the client can retry) and `PAYMENT-RESPONSE` (with +the error reason). + +## HTTP Status Codes + +The plug follows the x402 v2 HTTP transport status mapping: + +| Status | When | +|--------|------| +| **402** | Payment required (no `PAYMENT-SIGNATURE` header), no matching requirements, or payment verification/settlement failed | +| **400** | Malformed `PAYMENT-SIGNATURE` header, invalid Base64, invalid JSON, payload too large, or wrong `x402Version` | + ## Telemetry Events The plug emits these telemetry events: -- `[:x402, :plug, :payment_required]` — 402 returned to client -- `[:x402, :plug, :payment_verified]` — payment successfully verified -- `[:x402, :plug, :payment_failed]` — verification failed +| Event | When | +|-------|------| +| `[:x402, :plug, :pass_through]` | Route did not match — request passes through unguarded | +| `[:x402, :plug, :payment_required]` | 402 returned — no `PAYMENT-SIGNATURE` header | +| `[:x402, :plug, :payment_verified]` | Payment successfully verified and settled | +| `[:x402, :plug, :payment_rejected]` | Payment rejected (invalid payload, no match, verification failed, etc.) | + +Metadata includes `%{method: atom(), path: String.t()}` and for +`:payment_required` / `:payment_rejected` also `:route` and `:reason`. + +## Full Example + +```elixir +defmodule MyAppWeb.Router do + use MyAppWeb, :router + + pipeline :paid_api do + plug X402.Plug.PaymentGate, + facilitator: MyApp.Facilitator, + hooks: MyApp.PaymentHooks, + payment_identifier_cache: MyApp.PaymentCache, + routes: [ + %{ + method: :get, + path: "/api/weather", + price: "0.005", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress", + description: "Weather data API" + }, + %{ + method: :post, + path: "/api/generate", + price: "0.05", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress", + description: "AI generation endpoint" + }, + %{ + method: :any, + path: "/api/premium/*", + accepts: [ + %{ + scheme: "exact", + price: "0.01", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress" + }, + %{ + scheme: "upto", + price: "1.00", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0xYourWalletAddress" + } + ], + description: "Premium tier — flexible pricing", + service_name: "MyApp Premium", + tags: ["premium", "ai"] + } + ] + end + + scope "/api" do + pipe_through [:paid_api] + get "/weather", WeatherController, :show + post "/generate", GenerateController, :create + get "/premium/*path", PremiumController, :show + end +end +``` From 949e9bbab3dfab090892c85935accc81e71cf81f Mon Sep 17 00:00:00 2001 From: Marvin Krause Date: Tue, 28 Jul 2026 19:11:03 +0200 Subject: [PATCH 3/3] formatting and credo --- lib/x402/facilitator/http.ex | 1 - lib/x402/plug/payment_gate.ex | 67 +++++++++++++++------------- test/x402/plug/payment_gate_test.exs | 4 +- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/lib/x402/facilitator/http.ex b/lib/x402/facilitator/http.ex index 14f663d..680fa17 100644 --- a/lib/x402/facilitator/http.ex +++ b/lib/x402/facilitator/http.ex @@ -227,7 +227,6 @@ defmodule X402.Facilitator.HTTP do defp decode_json_body(other), do: {:error, {:invalid_body_type, other}} - defp raw_body_map(nil), do: %{} defp raw_body_map(body) when is_binary(body), do: %{"raw_body" => body} defp raw_body_map(body), do: %{"raw_body" => inspect(body)} diff --git a/lib/x402/plug/payment_gate.ex b/lib/x402/plug/payment_gate.ex index 7b112cb..5458f25 100644 --- a/lib/x402/plug/payment_gate.ex +++ b/lib/x402/plug/payment_gate.ex @@ -27,7 +27,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do alias X402.Extensions.PaymentIdentifier.ETSCache alias X402.Facilitator - alias X402.Facilitator.Error alias X402.Hooks alias X402.Hooks.Default alias X402.PaymentRequired @@ -48,7 +47,8 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do ] @http_methods [:any, :delete, :get, :head, :options, :patch, :post, :put, :trace] - @route_schemes ["exact", "upto"] # TODO Implement batch-settlement scheme + # TODO Implement batch-settlement scheme + @route_schemes ["exact", "upto"] @x402_version 2 @default_max_timeout_seconds 60 @default_description "Payment required" @@ -444,17 +444,7 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do facilitator_verify(facilitator, payment_payload, requirements, hooks), :ok <- ensure_verify_success(verify_response), :ok <- claim_payment(payment_identifier_cache, payment_id) do - settle_result = - case facilitator_settle(facilitator, payment_payload, requirements, hooks) do - {:ok, settle_response} -> - case ensure_settle_success(settle_response) do - :ok -> {:ok, settle_response} - {:error, reason} -> {:error, reason, settle_response.body} - end - - {:error, reason} -> - {:error, reason, nil} - end + settle_result = settle_with_hooks(facilitator, payment_payload, requirements, hooks) case settle_result do {:ok, settle_response} -> @@ -508,6 +498,27 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do end end + @spec settle_with_hooks( + Facilitator.server(), + map(), + map(), + module() + ) :: + {:ok, map()} + | {:error, term(), term()} + defp settle_with_hooks(facilitator, payment_payload, requirements, hooks) do + case facilitator_settle(facilitator, payment_payload, requirements, hooks) do + {:ok, settle_response} -> + case ensure_settle_success(settle_response) do + :ok -> {:ok, settle_response} + {:error, reason} -> {:error, reason, settle_response.body} + end + + {:error, reason} -> + {:error, reason, nil} + end + end + @spec claim_payment(ETSCache.server() | nil, String.t()) :: :ok | {:error, :already_exists} defp claim_payment(nil, _payment_id), do: :ok @@ -690,8 +701,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do end end - defp validate_v2_payload_structure(_payload), do: {:error, :invalid_payload} - # Equality on scheme, network, amount, asset, payTo — matches Go # FindMatchingRequirements. @spec find_matching_requirements([map()], map()) :: @@ -727,20 +736,20 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do @spec validate_upto_amount(map(), map()) :: :ok | {:error, term()} defp validate_upto_amount(payload, requirements) do - scheme = Utils.map_value(requirements, {"scheme", :scheme}) - - case scheme do - "upto" -> - with {:ok, max_amount} <- extract_max_amount(requirements), - {:ok, payment_value} <- extract_payment_value(payload) do - case Utils.compare_decimal(payment_value, max_amount) do - :gt -> {:error, {:invalid_upto_payment, :payment_value_exceeds_max_price}} - _comparison -> :ok - end - end + case Utils.map_value(requirements, {"scheme", :scheme}) do + "upto" -> validate_upto_max(payload, requirements) + _scheme -> :ok + end + end - _scheme -> - :ok + @spec validate_upto_max(map(), map()) :: :ok | {:error, term()} + defp validate_upto_max(payload, requirements) do + with {:ok, max_amount} <- extract_max_amount(requirements), + {:ok, payment_value} <- extract_payment_value(payload) do + case Utils.compare_decimal(payment_value, max_amount) do + :gt -> {:error, {:invalid_upto_payment, :payment_value_exceeds_max_price}} + _comparison -> :ok + end end end @@ -961,8 +970,6 @@ if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Conn) do end end - defp put_payment_response_header(conn, _body), do: conn - @spec payment_response_from_reason(term()) :: map() | nil defp payment_response_from_reason(body) when is_map(body), do: body diff --git a/test/x402/plug/payment_gate_test.exs b/test/x402/plug/payment_gate_test.exs index f2296d5..7252460 100644 --- a/test/x402/plug/payment_gate_test.exs +++ b/test/x402/plug/payment_gate_test.exs @@ -118,7 +118,7 @@ defmodule X402.Plug.PaymentGateTest do price: @amount, network: @network, asset: @asset, - pay_to: @receiver + pay_to: @receiver } @upto_route Map.put(@route, :scheme, "upto") @@ -450,7 +450,7 @@ defmodule X402.Plug.PaymentGateTest do price: @amount, network: @network, asset: @asset, - pay_to: @receiver + pay_to: @receiver }, @solana_accept ]