uvd-x402-sdk 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
uvd_x402_sdk/client.py CHANGED
@@ -1,527 +1,527 @@
1
- """
2
- Main x402 client for payment processing.
3
-
4
- This module provides the X402Client class which handles:
5
- - Parsing X-PAYMENT headers
6
- - Verifying payments with the facilitator
7
- - Settling payments on-chain
8
- - Error handling with clear messages
9
- """
10
-
11
- import base64
12
- import json
13
- import logging
14
- from decimal import Decimal
15
- from typing import Optional, Tuple, Dict, Any
16
-
17
- import httpx
18
-
19
- from uvd_x402_sdk.config import X402Config
20
- from uvd_x402_sdk.exceptions import (
21
- InvalidPayloadError,
22
- PaymentVerificationError,
23
- PaymentSettlementError,
24
- UnsupportedNetworkError,
25
- FacilitatorError,
26
- TimeoutError as X402TimeoutError,
27
- )
28
- from uvd_x402_sdk.models import (
29
- PaymentPayload,
30
- PaymentRequirements,
31
- PaymentResult,
32
- VerifyResponse,
33
- SettleResponse,
34
- )
35
- from uvd_x402_sdk.networks import (
36
- get_network,
37
- NetworkType,
38
- get_supported_network_names,
39
- normalize_network,
40
- is_caip2_format,
41
- )
42
-
43
- logger = logging.getLogger(__name__)
44
-
45
-
46
- class X402Client:
47
- """
48
- Client for processing x402 payments via the Ultravioleta facilitator.
49
-
50
- The client handles the two-step payment flow:
51
- 1. Verify: Validate the payment signature/authorization
52
- 2. Settle: Execute the payment on-chain
53
-
54
- Example:
55
- >>> client = X402Client(
56
- ... recipient_address="0xYourWallet...",
57
- ... facilitator_url="https://facilitator.ultravioletadao.xyz"
58
- ... )
59
- >>> result = client.process_payment(
60
- ... x_payment_header=request.headers.get("X-PAYMENT"),
61
- ... expected_amount_usd=Decimal("10.00")
62
- ... )
63
- >>> print(f"Paid by {result.payer_address}, tx: {result.transaction_hash}")
64
- """
65
-
66
- def __init__(
67
- self,
68
- recipient_address: Optional[str] = None,
69
- facilitator_url: str = "https://facilitator.ultravioletadao.xyz",
70
- config: Optional[X402Config] = None,
71
- **kwargs: Any,
72
- ) -> None:
73
- """
74
- Initialize the x402 client.
75
-
76
- Args:
77
- recipient_address: Default recipient for EVM chains (convenience arg)
78
- facilitator_url: URL of the facilitator service
79
- config: Full X402Config object (overrides other args)
80
- **kwargs: Additional config parameters passed to X402Config
81
-
82
- Raises:
83
- ValueError: If no recipient address is configured
84
- """
85
- if config:
86
- self.config = config
87
- else:
88
- # Build config from individual args
89
- config_kwargs = {
90
- "facilitator_url": facilitator_url,
91
- "recipient_evm": recipient_address or kwargs.get("recipient_evm", ""),
92
- **kwargs,
93
- }
94
- # Remove None values
95
- config_kwargs = {k: v for k, v in config_kwargs.items() if v is not None}
96
- self.config = X402Config(**config_kwargs)
97
-
98
- # HTTP client for facilitator requests
99
- self._http_client: Optional[httpx.Client] = None
100
-
101
- def _get_http_client(self) -> httpx.Client:
102
- """Get or create HTTP client."""
103
- if self._http_client is None or self._http_client.is_closed:
104
- self._http_client = httpx.Client(
105
- timeout=httpx.Timeout(
106
- connect=10.0,
107
- read=self.config.settle_timeout,
108
- write=10.0,
109
- pool=10.0,
110
- )
111
- )
112
- return self._http_client
113
-
114
- def close(self) -> None:
115
- """Close the HTTP client."""
116
- if self._http_client:
117
- self._http_client.close()
118
- self._http_client = None
119
-
120
- def __enter__(self) -> "X402Client":
121
- return self
122
-
123
- def __exit__(self, *args: Any) -> None:
124
- self.close()
125
-
126
- # =========================================================================
127
- # Payload Parsing
128
- # =========================================================================
129
-
130
- def extract_payload(self, x_payment_header: str) -> PaymentPayload:
131
- """
132
- Extract and validate payment payload from X-PAYMENT header.
133
-
134
- Args:
135
- x_payment_header: Base64-encoded JSON payload
136
-
137
- Returns:
138
- Parsed PaymentPayload object
139
-
140
- Raises:
141
- InvalidPayloadError: If payload is invalid
142
- """
143
- if not x_payment_header:
144
- raise InvalidPayloadError("Missing X-PAYMENT header")
145
-
146
- try:
147
- # Decode base64
148
- json_bytes = base64.b64decode(x_payment_header)
149
- json_str = json_bytes.decode("utf-8")
150
-
151
- # Parse JSON
152
- data = json.loads(json_str)
153
-
154
- # Validate and parse with Pydantic
155
- payload = PaymentPayload(**data)
156
-
157
- logger.debug(f"Extracted payload for network: {payload.network}")
158
- return payload
159
-
160
- except base64.binascii.Error as e:
161
- raise InvalidPayloadError(f"Invalid base64 encoding: {e}")
162
- except json.JSONDecodeError as e:
163
- raise InvalidPayloadError(f"Invalid JSON in payload: {e}")
164
- except Exception as e:
165
- raise InvalidPayloadError(f"Failed to parse payload: {e}")
166
-
167
- # =========================================================================
168
- # Network Validation
169
- # =========================================================================
170
-
171
- def validate_network(self, network: str) -> str:
172
- """
173
- Validate that a network is supported and enabled.
174
-
175
- Handles both v1 ("base") and v2 CAIP-2 ("eip155:8453") formats.
176
-
177
- Args:
178
- network: Network identifier (v1 or CAIP-2)
179
-
180
- Returns:
181
- Normalized network name
182
-
183
- Raises:
184
- UnsupportedNetworkError: If network is not supported
185
- """
186
- # Normalize CAIP-2 to network name
187
- try:
188
- normalized = normalize_network(network)
189
- except ValueError:
190
- raise UnsupportedNetworkError(
191
- network=network,
192
- supported_networks=get_supported_network_names(),
193
- )
194
-
195
- network_config = get_network(normalized)
196
- if not network_config:
197
- raise UnsupportedNetworkError(
198
- network=network,
199
- supported_networks=get_supported_network_names(),
200
- )
201
-
202
- if not network_config.enabled:
203
- raise UnsupportedNetworkError(
204
- network=network,
205
- supported_networks=[n for n in get_supported_network_names()
206
- if get_network(n) and get_network(n).enabled],
207
- )
208
-
209
- if not self.config.is_network_enabled(normalized):
210
- raise UnsupportedNetworkError(
211
- network=network,
212
- supported_networks=self.config.supported_networks,
213
- )
214
-
215
- return normalized
216
-
217
- # =========================================================================
218
- # Payment Requirements Building
219
- # =========================================================================
220
-
221
- def _build_payment_requirements(
222
- self,
223
- payload: PaymentPayload,
224
- expected_amount_usd: Decimal,
225
- ) -> PaymentRequirements:
226
- """
227
- Build payment requirements for facilitator request.
228
-
229
- Args:
230
- payload: Parsed payment payload
231
- expected_amount_usd: Expected payment amount in USD
232
-
233
- Returns:
234
- PaymentRequirements object
235
- """
236
- # Normalize network name (handles CAIP-2 format)
237
- normalized_network = payload.get_normalized_network()
238
-
239
- network_config = get_network(normalized_network)
240
- if not network_config:
241
- raise UnsupportedNetworkError(
242
- network=payload.network,
243
- supported_networks=get_supported_network_names(),
244
- )
245
-
246
- # Convert USD to token amount
247
- expected_amount_wei = network_config.get_token_amount(float(expected_amount_usd))
248
-
249
- # Get recipient for this network
250
- recipient = self.config.get_recipient(normalized_network)
251
-
252
- # Build base requirements
253
- # Use original network format (v1 or v2) for facilitator
254
- requirements = PaymentRequirements(
255
- scheme="exact",
256
- network=payload.network, # Preserve original format
257
- maxAmountRequired=str(expected_amount_wei),
258
- resource=self.config.resource_url or f"https://api.example.com/payment",
259
- description=self.config.description,
260
- mimeType="application/json",
261
- payTo=recipient,
262
- maxTimeoutSeconds=60,
263
- asset=network_config.usdc_address,
264
- )
265
-
266
- # Add EIP-712 domain params for EVM chains
267
- if network_config.network_type == NetworkType.EVM:
268
- requirements.extra = {
269
- "name": network_config.usdc_domain_name,
270
- "version": network_config.usdc_domain_version,
271
- }
272
-
273
- return requirements
274
-
275
- # =========================================================================
276
- # Facilitator Communication
277
- # =========================================================================
278
-
279
- def verify_payment(
280
- self,
281
- payload: PaymentPayload,
282
- expected_amount_usd: Decimal,
283
- ) -> VerifyResponse:
284
- """
285
- Verify payment with the facilitator.
286
-
287
- This validates the signature/authorization without settling on-chain.
288
-
289
- Args:
290
- payload: Parsed payment payload
291
- expected_amount_usd: Expected payment amount in USD
292
-
293
- Returns:
294
- VerifyResponse from facilitator
295
-
296
- Raises:
297
- PaymentVerificationError: If verification fails
298
- FacilitatorError: If facilitator returns an error
299
- TimeoutError: If request times out
300
- """
301
- normalized_network = self.validate_network(payload.network)
302
- requirements = self._build_payment_requirements(payload, expected_amount_usd)
303
-
304
- verify_request = {
305
- "x402Version": 1,
306
- "paymentPayload": payload.model_dump(by_alias=True),
307
- "paymentRequirements": requirements.model_dump(by_alias=True, exclude_none=True),
308
- }
309
-
310
- logger.info(f"Verifying payment on {payload.network} for ${expected_amount_usd}")
311
- logger.debug(f"Verify request: {json.dumps(verify_request, indent=2)}")
312
-
313
- try:
314
- client = self._get_http_client()
315
- response = client.post(
316
- f"{self.config.facilitator_url}/verify",
317
- json=verify_request,
318
- headers={"Content-Type": "application/json"},
319
- timeout=self.config.verify_timeout,
320
- )
321
-
322
- if response.status_code != 200:
323
- raise FacilitatorError(
324
- message=f"Facilitator verify failed with status {response.status_code}",
325
- status_code=response.status_code,
326
- response_body=response.text,
327
- )
328
-
329
- data = response.json()
330
- verify_response = VerifyResponse(**data)
331
-
332
- if not verify_response.isValid:
333
- raise PaymentVerificationError(
334
- message=f"Payment verification failed: {verify_response.message}",
335
- reason=verify_response.invalidReason,
336
- errors=verify_response.errors,
337
- )
338
-
339
- logger.info(f"Payment verified! Payer: {verify_response.payer}")
340
- return verify_response
341
-
342
- except httpx.TimeoutException:
343
- raise X402TimeoutError(operation="verify", timeout_seconds=self.config.verify_timeout)
344
- except httpx.RequestError as e:
345
- raise FacilitatorError(message=f"Facilitator request failed: {e}")
346
-
347
- def settle_payment(
348
- self,
349
- payload: PaymentPayload,
350
- expected_amount_usd: Decimal,
351
- ) -> SettleResponse:
352
- """
353
- Settle payment on-chain via the facilitator.
354
-
355
- This executes the actual on-chain transfer.
356
-
357
- Args:
358
- payload: Parsed payment payload
359
- expected_amount_usd: Expected payment amount in USD
360
-
361
- Returns:
362
- SettleResponse from facilitator
363
-
364
- Raises:
365
- PaymentSettlementError: If settlement fails
366
- FacilitatorError: If facilitator returns an error
367
- TimeoutError: If request times out
368
- """
369
- normalized_network = self.validate_network(payload.network)
370
- requirements = self._build_payment_requirements(payload, expected_amount_usd)
371
-
372
- settle_request = {
373
- "x402Version": 1,
374
- "paymentPayload": payload.model_dump(by_alias=True),
375
- "paymentRequirements": requirements.model_dump(by_alias=True, exclude_none=True),
376
- }
377
-
378
- logger.info(f"Settling payment on {payload.network} for ${expected_amount_usd}")
379
- logger.debug(f"Settle request: {json.dumps(settle_request, indent=2)}")
380
-
381
- try:
382
- client = self._get_http_client()
383
- response = client.post(
384
- f"{self.config.facilitator_url}/settle",
385
- json=settle_request,
386
- headers={"Content-Type": "application/json"},
387
- timeout=self.config.settle_timeout,
388
- )
389
-
390
- if response.status_code != 200:
391
- raise FacilitatorError(
392
- message=f"Facilitator settle failed with status {response.status_code}",
393
- status_code=response.status_code,
394
- response_body=response.text,
395
- )
396
-
397
- data = response.json()
398
- settle_response = SettleResponse(**data)
399
-
400
- if not settle_response.success:
401
- raise PaymentSettlementError(
402
- message=f"Payment settlement failed: {settle_response.message}",
403
- network=payload.network,
404
- reason=settle_response.message,
405
- )
406
-
407
- tx_hash = settle_response.get_transaction_hash()
408
- logger.info(f"Payment settled! TX: {tx_hash}, Payer: {settle_response.payer}")
409
- return settle_response
410
-
411
- except httpx.TimeoutException:
412
- raise X402TimeoutError(operation="settle", timeout_seconds=self.config.settle_timeout)
413
- except httpx.RequestError as e:
414
- raise FacilitatorError(message=f"Facilitator request failed: {e}")
415
-
416
- # =========================================================================
417
- # Main Processing Method
418
- # =========================================================================
419
-
420
- def process_payment(
421
- self,
422
- x_payment_header: str,
423
- expected_amount_usd: Decimal,
424
- ) -> PaymentResult:
425
- """
426
- Process a complete x402 payment (verify + settle).
427
-
428
- This is the main method for handling payments. It:
429
- 1. Extracts and validates the payment payload
430
- 2. Verifies the payment signature with the facilitator
431
- 3. Settles the payment on-chain
432
- 4. Returns the payment result
433
-
434
- Args:
435
- x_payment_header: X-PAYMENT header value (base64-encoded JSON)
436
- expected_amount_usd: Expected payment amount in USD
437
-
438
- Returns:
439
- PaymentResult with payer address, transaction hash, etc.
440
-
441
- Raises:
442
- InvalidPayloadError: If payload is invalid
443
- UnsupportedNetworkError: If network is not supported
444
- PaymentVerificationError: If verification fails
445
- PaymentSettlementError: If settlement fails
446
- FacilitatorError: If facilitator returns an error
447
- TimeoutError: If request times out
448
- """
449
- # Extract payload
450
- payload = self.extract_payload(x_payment_header)
451
- logger.info(f"Processing payment: network={payload.network}, amount=${expected_amount_usd}")
452
-
453
- # Verify payment
454
- verify_response = self.verify_payment(payload, expected_amount_usd)
455
-
456
- # Settle payment
457
- settle_response = self.settle_payment(payload, expected_amount_usd)
458
-
459
- # Build result
460
- return PaymentResult(
461
- success=True,
462
- payer_address=settle_response.payer or verify_response.payer or "",
463
- transaction_hash=settle_response.get_transaction_hash(),
464
- network=payload.network,
465
- amount_usd=expected_amount_usd,
466
- )
467
-
468
- # =========================================================================
469
- # Convenience Methods
470
- # =========================================================================
471
-
472
- def get_payer_address(self, x_payment_header: str) -> Tuple[str, str]:
473
- """
474
- Extract payer address from payment header without processing.
475
-
476
- Useful for logging or pre-validation.
477
-
478
- Args:
479
- x_payment_header: X-PAYMENT header value
480
-
481
- Returns:
482
- Tuple of (payer_address, network)
483
- """
484
- payload = self.extract_payload(x_payment_header)
485
-
486
- # Normalize network name
487
- normalized_network = payload.get_normalized_network()
488
-
489
- # Extract payer based on network type
490
- network_config = get_network(normalized_network)
491
- if not network_config:
492
- raise UnsupportedNetworkError(
493
- network=payload.network,
494
- supported_networks=get_supported_network_names(),
495
- )
496
-
497
- payer = ""
498
- if network_config.network_type == NetworkType.EVM:
499
- evm_payload = payload.get_evm_payload()
500
- payer = evm_payload.authorization.from_address
501
- elif network_config.network_type == NetworkType.STELLAR:
502
- stellar_payload = payload.get_stellar_payload()
503
- payer = stellar_payload.from_address
504
- # For SVM/NEAR, payer is determined during verification
505
-
506
- return payer, normalized_network
507
-
508
- def verify_only(
509
- self,
510
- x_payment_header: str,
511
- expected_amount_usd: Decimal,
512
- ) -> Tuple[bool, str]:
513
- """
514
- Verify payment without settling.
515
-
516
- Useful for checking payment validity before committing to settlement.
517
-
518
- Args:
519
- x_payment_header: X-PAYMENT header value
520
- expected_amount_usd: Expected payment amount
521
-
522
- Returns:
523
- Tuple of (is_valid, payer_address)
524
- """
525
- payload = self.extract_payload(x_payment_header)
526
- verify_response = self.verify_payment(payload, expected_amount_usd)
527
- return verify_response.isValid, verify_response.payer or ""
1
+ """
2
+ Main x402 client for payment processing.
3
+
4
+ This module provides the X402Client class which handles:
5
+ - Parsing X-PAYMENT headers
6
+ - Verifying payments with the facilitator
7
+ - Settling payments on-chain
8
+ - Error handling with clear messages
9
+ """
10
+
11
+ import base64
12
+ import json
13
+ import logging
14
+ from decimal import Decimal
15
+ from typing import Optional, Tuple, Dict, Any
16
+
17
+ import httpx
18
+
19
+ from uvd_x402_sdk.config import X402Config
20
+ from uvd_x402_sdk.exceptions import (
21
+ InvalidPayloadError,
22
+ PaymentVerificationError,
23
+ PaymentSettlementError,
24
+ UnsupportedNetworkError,
25
+ FacilitatorError,
26
+ TimeoutError as X402TimeoutError,
27
+ )
28
+ from uvd_x402_sdk.models import (
29
+ PaymentPayload,
30
+ PaymentRequirements,
31
+ PaymentResult,
32
+ VerifyResponse,
33
+ SettleResponse,
34
+ )
35
+ from uvd_x402_sdk.networks import (
36
+ get_network,
37
+ NetworkType,
38
+ get_supported_network_names,
39
+ normalize_network,
40
+ is_caip2_format,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class X402Client:
47
+ """
48
+ Client for processing x402 payments via the Ultravioleta facilitator.
49
+
50
+ The client handles the two-step payment flow:
51
+ 1. Verify: Validate the payment signature/authorization
52
+ 2. Settle: Execute the payment on-chain
53
+
54
+ Example:
55
+ >>> client = X402Client(
56
+ ... recipient_address="0xYourWallet...",
57
+ ... facilitator_url="https://facilitator.ultravioletadao.xyz"
58
+ ... )
59
+ >>> result = client.process_payment(
60
+ ... x_payment_header=request.headers.get("X-PAYMENT"),
61
+ ... expected_amount_usd=Decimal("10.00")
62
+ ... )
63
+ >>> print(f"Paid by {result.payer_address}, tx: {result.transaction_hash}")
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ recipient_address: Optional[str] = None,
69
+ facilitator_url: str = "https://facilitator.ultravioletadao.xyz",
70
+ config: Optional[X402Config] = None,
71
+ **kwargs: Any,
72
+ ) -> None:
73
+ """
74
+ Initialize the x402 client.
75
+
76
+ Args:
77
+ recipient_address: Default recipient for EVM chains (convenience arg)
78
+ facilitator_url: URL of the facilitator service
79
+ config: Full X402Config object (overrides other args)
80
+ **kwargs: Additional config parameters passed to X402Config
81
+
82
+ Raises:
83
+ ValueError: If no recipient address is configured
84
+ """
85
+ if config:
86
+ self.config = config
87
+ else:
88
+ # Build config from individual args
89
+ config_kwargs = {
90
+ "facilitator_url": facilitator_url,
91
+ "recipient_evm": recipient_address or kwargs.get("recipient_evm", ""),
92
+ **kwargs,
93
+ }
94
+ # Remove None values
95
+ config_kwargs = {k: v for k, v in config_kwargs.items() if v is not None}
96
+ self.config = X402Config(**config_kwargs)
97
+
98
+ # HTTP client for facilitator requests
99
+ self._http_client: Optional[httpx.Client] = None
100
+
101
+ def _get_http_client(self) -> httpx.Client:
102
+ """Get or create HTTP client."""
103
+ if self._http_client is None or self._http_client.is_closed:
104
+ self._http_client = httpx.Client(
105
+ timeout=httpx.Timeout(
106
+ connect=10.0,
107
+ read=self.config.settle_timeout,
108
+ write=10.0,
109
+ pool=10.0,
110
+ )
111
+ )
112
+ return self._http_client
113
+
114
+ def close(self) -> None:
115
+ """Close the HTTP client."""
116
+ if self._http_client:
117
+ self._http_client.close()
118
+ self._http_client = None
119
+
120
+ def __enter__(self) -> "X402Client":
121
+ return self
122
+
123
+ def __exit__(self, *args: Any) -> None:
124
+ self.close()
125
+
126
+ # =========================================================================
127
+ # Payload Parsing
128
+ # =========================================================================
129
+
130
+ def extract_payload(self, x_payment_header: str) -> PaymentPayload:
131
+ """
132
+ Extract and validate payment payload from X-PAYMENT header.
133
+
134
+ Args:
135
+ x_payment_header: Base64-encoded JSON payload
136
+
137
+ Returns:
138
+ Parsed PaymentPayload object
139
+
140
+ Raises:
141
+ InvalidPayloadError: If payload is invalid
142
+ """
143
+ if not x_payment_header:
144
+ raise InvalidPayloadError("Missing X-PAYMENT header")
145
+
146
+ try:
147
+ # Decode base64
148
+ json_bytes = base64.b64decode(x_payment_header)
149
+ json_str = json_bytes.decode("utf-8")
150
+
151
+ # Parse JSON
152
+ data = json.loads(json_str)
153
+
154
+ # Validate and parse with Pydantic
155
+ payload = PaymentPayload(**data)
156
+
157
+ logger.debug(f"Extracted payload for network: {payload.network}")
158
+ return payload
159
+
160
+ except base64.binascii.Error as e:
161
+ raise InvalidPayloadError(f"Invalid base64 encoding: {e}")
162
+ except json.JSONDecodeError as e:
163
+ raise InvalidPayloadError(f"Invalid JSON in payload: {e}")
164
+ except Exception as e:
165
+ raise InvalidPayloadError(f"Failed to parse payload: {e}")
166
+
167
+ # =========================================================================
168
+ # Network Validation
169
+ # =========================================================================
170
+
171
+ def validate_network(self, network: str) -> str:
172
+ """
173
+ Validate that a network is supported and enabled.
174
+
175
+ Handles both v1 ("base") and v2 CAIP-2 ("eip155:8453") formats.
176
+
177
+ Args:
178
+ network: Network identifier (v1 or CAIP-2)
179
+
180
+ Returns:
181
+ Normalized network name
182
+
183
+ Raises:
184
+ UnsupportedNetworkError: If network is not supported
185
+ """
186
+ # Normalize CAIP-2 to network name
187
+ try:
188
+ normalized = normalize_network(network)
189
+ except ValueError:
190
+ raise UnsupportedNetworkError(
191
+ network=network,
192
+ supported_networks=get_supported_network_names(),
193
+ )
194
+
195
+ network_config = get_network(normalized)
196
+ if not network_config:
197
+ raise UnsupportedNetworkError(
198
+ network=network,
199
+ supported_networks=get_supported_network_names(),
200
+ )
201
+
202
+ if not network_config.enabled:
203
+ raise UnsupportedNetworkError(
204
+ network=network,
205
+ supported_networks=[n for n in get_supported_network_names()
206
+ if get_network(n) and get_network(n).enabled],
207
+ )
208
+
209
+ if not self.config.is_network_enabled(normalized):
210
+ raise UnsupportedNetworkError(
211
+ network=network,
212
+ supported_networks=self.config.supported_networks,
213
+ )
214
+
215
+ return normalized
216
+
217
+ # =========================================================================
218
+ # Payment Requirements Building
219
+ # =========================================================================
220
+
221
+ def _build_payment_requirements(
222
+ self,
223
+ payload: PaymentPayload,
224
+ expected_amount_usd: Decimal,
225
+ ) -> PaymentRequirements:
226
+ """
227
+ Build payment requirements for facilitator request.
228
+
229
+ Args:
230
+ payload: Parsed payment payload
231
+ expected_amount_usd: Expected payment amount in USD
232
+
233
+ Returns:
234
+ PaymentRequirements object
235
+ """
236
+ # Normalize network name (handles CAIP-2 format)
237
+ normalized_network = payload.get_normalized_network()
238
+
239
+ network_config = get_network(normalized_network)
240
+ if not network_config:
241
+ raise UnsupportedNetworkError(
242
+ network=payload.network,
243
+ supported_networks=get_supported_network_names(),
244
+ )
245
+
246
+ # Convert USD to token amount
247
+ expected_amount_wei = network_config.get_token_amount(float(expected_amount_usd))
248
+
249
+ # Get recipient for this network
250
+ recipient = self.config.get_recipient(normalized_network)
251
+
252
+ # Build base requirements
253
+ # Use original network format (v1 or v2) for facilitator
254
+ requirements = PaymentRequirements(
255
+ scheme="exact",
256
+ network=payload.network, # Preserve original format
257
+ maxAmountRequired=str(expected_amount_wei),
258
+ resource=self.config.resource_url or f"https://api.example.com/payment",
259
+ description=self.config.description,
260
+ mimeType="application/json",
261
+ payTo=recipient,
262
+ maxTimeoutSeconds=60,
263
+ asset=network_config.usdc_address,
264
+ )
265
+
266
+ # Add EIP-712 domain params for EVM chains
267
+ if network_config.network_type == NetworkType.EVM:
268
+ requirements.extra = {
269
+ "name": network_config.usdc_domain_name,
270
+ "version": network_config.usdc_domain_version,
271
+ }
272
+
273
+ return requirements
274
+
275
+ # =========================================================================
276
+ # Facilitator Communication
277
+ # =========================================================================
278
+
279
+ def verify_payment(
280
+ self,
281
+ payload: PaymentPayload,
282
+ expected_amount_usd: Decimal,
283
+ ) -> VerifyResponse:
284
+ """
285
+ Verify payment with the facilitator.
286
+
287
+ This validates the signature/authorization without settling on-chain.
288
+
289
+ Args:
290
+ payload: Parsed payment payload
291
+ expected_amount_usd: Expected payment amount in USD
292
+
293
+ Returns:
294
+ VerifyResponse from facilitator
295
+
296
+ Raises:
297
+ PaymentVerificationError: If verification fails
298
+ FacilitatorError: If facilitator returns an error
299
+ TimeoutError: If request times out
300
+ """
301
+ normalized_network = self.validate_network(payload.network)
302
+ requirements = self._build_payment_requirements(payload, expected_amount_usd)
303
+
304
+ verify_request = {
305
+ "x402Version": 1,
306
+ "paymentPayload": payload.model_dump(by_alias=True),
307
+ "paymentRequirements": requirements.model_dump(by_alias=True, exclude_none=True),
308
+ }
309
+
310
+ logger.info(f"Verifying payment on {payload.network} for ${expected_amount_usd}")
311
+ logger.debug(f"Verify request: {json.dumps(verify_request, indent=2)}")
312
+
313
+ try:
314
+ client = self._get_http_client()
315
+ response = client.post(
316
+ f"{self.config.facilitator_url}/verify",
317
+ json=verify_request,
318
+ headers={"Content-Type": "application/json"},
319
+ timeout=self.config.verify_timeout,
320
+ )
321
+
322
+ if response.status_code != 200:
323
+ raise FacilitatorError(
324
+ message=f"Facilitator verify failed with status {response.status_code}",
325
+ status_code=response.status_code,
326
+ response_body=response.text,
327
+ )
328
+
329
+ data = response.json()
330
+ verify_response = VerifyResponse(**data)
331
+
332
+ if not verify_response.isValid:
333
+ raise PaymentVerificationError(
334
+ message=f"Payment verification failed: {verify_response.message}",
335
+ reason=verify_response.invalidReason,
336
+ errors=verify_response.errors,
337
+ )
338
+
339
+ logger.info(f"Payment verified! Payer: {verify_response.payer}")
340
+ return verify_response
341
+
342
+ except httpx.TimeoutException:
343
+ raise X402TimeoutError(operation="verify", timeout_seconds=self.config.verify_timeout)
344
+ except httpx.RequestError as e:
345
+ raise FacilitatorError(message=f"Facilitator request failed: {e}")
346
+
347
+ def settle_payment(
348
+ self,
349
+ payload: PaymentPayload,
350
+ expected_amount_usd: Decimal,
351
+ ) -> SettleResponse:
352
+ """
353
+ Settle payment on-chain via the facilitator.
354
+
355
+ This executes the actual on-chain transfer.
356
+
357
+ Args:
358
+ payload: Parsed payment payload
359
+ expected_amount_usd: Expected payment amount in USD
360
+
361
+ Returns:
362
+ SettleResponse from facilitator
363
+
364
+ Raises:
365
+ PaymentSettlementError: If settlement fails
366
+ FacilitatorError: If facilitator returns an error
367
+ TimeoutError: If request times out
368
+ """
369
+ normalized_network = self.validate_network(payload.network)
370
+ requirements = self._build_payment_requirements(payload, expected_amount_usd)
371
+
372
+ settle_request = {
373
+ "x402Version": 1,
374
+ "paymentPayload": payload.model_dump(by_alias=True),
375
+ "paymentRequirements": requirements.model_dump(by_alias=True, exclude_none=True),
376
+ }
377
+
378
+ logger.info(f"Settling payment on {payload.network} for ${expected_amount_usd}")
379
+ logger.debug(f"Settle request: {json.dumps(settle_request, indent=2)}")
380
+
381
+ try:
382
+ client = self._get_http_client()
383
+ response = client.post(
384
+ f"{self.config.facilitator_url}/settle",
385
+ json=settle_request,
386
+ headers={"Content-Type": "application/json"},
387
+ timeout=self.config.settle_timeout,
388
+ )
389
+
390
+ if response.status_code != 200:
391
+ raise FacilitatorError(
392
+ message=f"Facilitator settle failed with status {response.status_code}",
393
+ status_code=response.status_code,
394
+ response_body=response.text,
395
+ )
396
+
397
+ data = response.json()
398
+ settle_response = SettleResponse(**data)
399
+
400
+ if not settle_response.success:
401
+ raise PaymentSettlementError(
402
+ message=f"Payment settlement failed: {settle_response.message}",
403
+ network=payload.network,
404
+ reason=settle_response.message,
405
+ )
406
+
407
+ tx_hash = settle_response.get_transaction_hash()
408
+ logger.info(f"Payment settled! TX: {tx_hash}, Payer: {settle_response.payer}")
409
+ return settle_response
410
+
411
+ except httpx.TimeoutException:
412
+ raise X402TimeoutError(operation="settle", timeout_seconds=self.config.settle_timeout)
413
+ except httpx.RequestError as e:
414
+ raise FacilitatorError(message=f"Facilitator request failed: {e}")
415
+
416
+ # =========================================================================
417
+ # Main Processing Method
418
+ # =========================================================================
419
+
420
+ def process_payment(
421
+ self,
422
+ x_payment_header: str,
423
+ expected_amount_usd: Decimal,
424
+ ) -> PaymentResult:
425
+ """
426
+ Process a complete x402 payment (verify + settle).
427
+
428
+ This is the main method for handling payments. It:
429
+ 1. Extracts and validates the payment payload
430
+ 2. Verifies the payment signature with the facilitator
431
+ 3. Settles the payment on-chain
432
+ 4. Returns the payment result
433
+
434
+ Args:
435
+ x_payment_header: X-PAYMENT header value (base64-encoded JSON)
436
+ expected_amount_usd: Expected payment amount in USD
437
+
438
+ Returns:
439
+ PaymentResult with payer address, transaction hash, etc.
440
+
441
+ Raises:
442
+ InvalidPayloadError: If payload is invalid
443
+ UnsupportedNetworkError: If network is not supported
444
+ PaymentVerificationError: If verification fails
445
+ PaymentSettlementError: If settlement fails
446
+ FacilitatorError: If facilitator returns an error
447
+ TimeoutError: If request times out
448
+ """
449
+ # Extract payload
450
+ payload = self.extract_payload(x_payment_header)
451
+ logger.info(f"Processing payment: network={payload.network}, amount=${expected_amount_usd}")
452
+
453
+ # Verify payment
454
+ verify_response = self.verify_payment(payload, expected_amount_usd)
455
+
456
+ # Settle payment
457
+ settle_response = self.settle_payment(payload, expected_amount_usd)
458
+
459
+ # Build result
460
+ return PaymentResult(
461
+ success=True,
462
+ payer_address=settle_response.payer or verify_response.payer or "",
463
+ transaction_hash=settle_response.get_transaction_hash(),
464
+ network=payload.network,
465
+ amount_usd=expected_amount_usd,
466
+ )
467
+
468
+ # =========================================================================
469
+ # Convenience Methods
470
+ # =========================================================================
471
+
472
+ def get_payer_address(self, x_payment_header: str) -> Tuple[str, str]:
473
+ """
474
+ Extract payer address from payment header without processing.
475
+
476
+ Useful for logging or pre-validation.
477
+
478
+ Args:
479
+ x_payment_header: X-PAYMENT header value
480
+
481
+ Returns:
482
+ Tuple of (payer_address, network)
483
+ """
484
+ payload = self.extract_payload(x_payment_header)
485
+
486
+ # Normalize network name
487
+ normalized_network = payload.get_normalized_network()
488
+
489
+ # Extract payer based on network type
490
+ network_config = get_network(normalized_network)
491
+ if not network_config:
492
+ raise UnsupportedNetworkError(
493
+ network=payload.network,
494
+ supported_networks=get_supported_network_names(),
495
+ )
496
+
497
+ payer = ""
498
+ if network_config.network_type == NetworkType.EVM:
499
+ evm_payload = payload.get_evm_payload()
500
+ payer = evm_payload.authorization.from_address
501
+ elif network_config.network_type == NetworkType.STELLAR:
502
+ stellar_payload = payload.get_stellar_payload()
503
+ payer = stellar_payload.from_address
504
+ # For SVM/NEAR, payer is determined during verification
505
+
506
+ return payer, normalized_network
507
+
508
+ def verify_only(
509
+ self,
510
+ x_payment_header: str,
511
+ expected_amount_usd: Decimal,
512
+ ) -> Tuple[bool, str]:
513
+ """
514
+ Verify payment without settling.
515
+
516
+ Useful for checking payment validity before committing to settlement.
517
+
518
+ Args:
519
+ x_payment_header: X-PAYMENT header value
520
+ expected_amount_usd: Expected payment amount
521
+
522
+ Returns:
523
+ Tuple of (is_valid, payer_address)
524
+ """
525
+ payload = self.extract_payload(x_payment_header)
526
+ verify_response = self.verify_payment(payload, expected_amount_usd)
527
+ return verify_response.isValid, verify_response.payer or ""