t402 1.9.1__py3-none-any.whl → 1.10.0__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.
Files changed (51) hide show
  1. t402/__init__.py +1 -1
  2. t402/a2a/__init__.py +73 -0
  3. t402/a2a/helpers.py +158 -0
  4. t402/a2a/types.py +145 -0
  5. t402/bridge/constants.py +1 -1
  6. t402/django/__init__.py +42 -0
  7. t402/django/middleware.py +596 -0
  8. t402/errors.py +213 -0
  9. t402/facilitator.py +125 -0
  10. t402/mcp/constants.py +3 -6
  11. t402/mcp/server.py +428 -44
  12. t402/mcp/web3_utils.py +493 -0
  13. t402/multisig/__init__.py +120 -0
  14. t402/multisig/constants.py +54 -0
  15. t402/multisig/safe.py +441 -0
  16. t402/multisig/signature.py +228 -0
  17. t402/multisig/transaction.py +238 -0
  18. t402/multisig/types.py +108 -0
  19. t402/multisig/utils.py +77 -0
  20. t402/schemes/__init__.py +19 -0
  21. t402/schemes/cosmos/__init__.py +114 -0
  22. t402/schemes/cosmos/constants.py +211 -0
  23. t402/schemes/cosmos/exact_direct/__init__.py +21 -0
  24. t402/schemes/cosmos/exact_direct/client.py +198 -0
  25. t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
  26. t402/schemes/cosmos/exact_direct/server.py +315 -0
  27. t402/schemes/cosmos/types.py +501 -0
  28. t402/schemes/evm/__init__.py +1 -1
  29. t402/schemes/evm/exact_legacy/server.py +1 -1
  30. t402/schemes/near/__init__.py +25 -0
  31. t402/schemes/near/upto/__init__.py +54 -0
  32. t402/schemes/near/upto/types.py +272 -0
  33. t402/schemes/svm/__init__.py +15 -0
  34. t402/schemes/svm/upto/__init__.py +23 -0
  35. t402/schemes/svm/upto/types.py +193 -0
  36. t402/schemes/ton/__init__.py +15 -0
  37. t402/schemes/ton/upto/__init__.py +31 -0
  38. t402/schemes/ton/upto/types.py +215 -0
  39. t402/schemes/tron/__init__.py +21 -4
  40. t402/schemes/tron/upto/__init__.py +30 -0
  41. t402/schemes/tron/upto/types.py +213 -0
  42. t402/starlette/__init__.py +38 -0
  43. t402/starlette/middleware.py +522 -0
  44. t402/ton.py +1 -1
  45. t402/ton_paywall_template.py +1 -1
  46. t402/types.py +100 -2
  47. t402/wdk/chains.py +1 -1
  48. {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/METADATA +3 -3
  49. {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/RECORD +51 -20
  50. {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
  51. {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,596 @@
1
+ """Django Middleware for T402 Payment Protocol.
2
+
3
+ This module provides middleware for integrating T402 payments
4
+ with Django applications, supporting both V1 and V2 protocols.
5
+
6
+ V1 Protocol:
7
+ - X-PAYMENT header for payment signature
8
+ - X-PAYMENT-RESPONSE header for settlement
9
+ - Response body contains PaymentRequired
10
+
11
+ V2 Protocol:
12
+ - PAYMENT-SIGNATURE header for payment signature
13
+ - PAYMENT-REQUIRED header for 402 responses
14
+ - PAYMENT-RESPONSE header for settlement
15
+
16
+ Usage:
17
+ ```python
18
+ # settings.py
19
+ MIDDLEWARE = [
20
+ ...
21
+ "t402.django.PaymentMiddleware",
22
+ ...
23
+ ]
24
+
25
+ T402_PAYMENT_CONFIGS = [
26
+ {
27
+ "path": "/api/premium/*",
28
+ "price": "$0.10",
29
+ "pay_to_address": "0x1234...",
30
+ "network": "eip155:8453",
31
+ },
32
+ ]
33
+
34
+ # Or configure programmatically:
35
+ from t402.django import PaymentMiddleware
36
+ PaymentMiddleware.configure([
37
+ PaymentConfig(
38
+ price="$0.10",
39
+ pay_to_address="0x...",
40
+ path="/api/*",
41
+ network="eip155:8453",
42
+ ),
43
+ ])
44
+ ```
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import logging
51
+ from typing import Any, Callable, Dict, List, Optional, Union, cast
52
+
53
+ from django.http import HttpRequest, HttpResponse, JsonResponse
54
+
55
+ from t402.common import (
56
+ process_price_to_atomic_amount,
57
+ find_matching_payment_requirements,
58
+ )
59
+ from t402.encoding import (
60
+ encode_payment_required_header,
61
+ encode_payment_response_header,
62
+ extract_payment_from_headers,
63
+ decode_payment_signature_header,
64
+ HEADER_PAYMENT_REQUIRED,
65
+ HEADER_PAYMENT_RESPONSE,
66
+ HEADER_X_PAYMENT_RESPONSE,
67
+ )
68
+ from t402.facilitator import FacilitatorClient, FacilitatorConfig
69
+ from t402.networks import get_all_supported_networks, SupportedNetworks
70
+ from t402.path import path_is_match
71
+ from t402.paywall import is_browser_request, get_paywall_html
72
+ from t402.types import (
73
+ PaymentPayload,
74
+ PaymentRequirements,
75
+ PaymentRequirementsV2,
76
+ PaymentRequiredV2,
77
+ ResourceInfo,
78
+ Price,
79
+ t402PaymentRequiredResponse,
80
+ PaywallConfig,
81
+ HTTPInputSchema,
82
+ T402_VERSION_V1,
83
+ T402_VERSION_V2,
84
+ VerifyResponse,
85
+ )
86
+
87
+ logger = logging.getLogger(__name__)
88
+
89
+
90
+ class PaymentDetails:
91
+ """Payment details stored in request after verification."""
92
+
93
+ def __init__(
94
+ self,
95
+ requirements: Union[PaymentRequirements, PaymentRequirementsV2],
96
+ verify_response: VerifyResponse,
97
+ protocol_version: int,
98
+ ):
99
+ self.requirements = requirements
100
+ self.verify_response = verify_response
101
+ self.protocol_version = protocol_version
102
+
103
+ @property
104
+ def is_verified(self) -> bool:
105
+ """Check if payment was verified."""
106
+ return self.verify_response.is_valid
107
+
108
+ @property
109
+ def payer_address(self) -> Optional[str]:
110
+ """Get payer address from verify response."""
111
+ return getattr(self.verify_response, "payer", None)
112
+
113
+
114
+ class PaymentConfig:
115
+ """Configuration for a payment-protected route."""
116
+
117
+ def __init__(
118
+ self,
119
+ price: Price,
120
+ pay_to_address: str,
121
+ path: Union[str, List[str]] = "*",
122
+ description: str = "",
123
+ mime_type: str = "",
124
+ max_timeout_seconds: int = 60,
125
+ input_schema: Optional[HTTPInputSchema] = None,
126
+ output_schema: Optional[Any] = None,
127
+ discoverable: bool = True,
128
+ facilitator_config: Optional[FacilitatorConfig] = None,
129
+ network: str = "eip155:8453",
130
+ resource: Optional[str] = None,
131
+ paywall_config: Optional[PaywallConfig] = None,
132
+ custom_paywall_html: Optional[str] = None,
133
+ protocol_version: int = T402_VERSION_V2,
134
+ ):
135
+ self.price = price
136
+ self.pay_to_address = pay_to_address
137
+ self.path = path
138
+ self.description = description
139
+ self.mime_type = mime_type
140
+ self.max_timeout_seconds = max_timeout_seconds
141
+ self.input_schema = input_schema
142
+ self.output_schema = output_schema
143
+ self.discoverable = discoverable
144
+ self.facilitator_config = facilitator_config
145
+ self.network = network
146
+ self.resource = resource
147
+ self.paywall_config = paywall_config
148
+ self.custom_paywall_html = custom_paywall_html
149
+ self.protocol_version = protocol_version
150
+
151
+ # Validate and process price
152
+ self._validate()
153
+
154
+ def _validate(self):
155
+ """Validate configuration."""
156
+ supported_networks = get_all_supported_networks()
157
+ if self.network not in supported_networks:
158
+ raise ValueError(
159
+ f"Unsupported network: {self.network}. Must be one of: {supported_networks}"
160
+ )
161
+
162
+ try:
163
+ self.max_amount_required, self.asset_address, self.eip712_domain = (
164
+ process_price_to_atomic_amount(self.price, self.network)
165
+ )
166
+ except Exception as e:
167
+ raise ValueError(f"Invalid price: {self.price}. Error: {e}")
168
+
169
+
170
+ class PaymentMiddleware:
171
+ """Django middleware for T402 payment requirements.
172
+
173
+ This class provides Django-compatible middleware for protecting routes
174
+ with T402 payment requirements. It supports multiple configurations
175
+ with different path patterns and settings.
176
+
177
+ Configuration via Django settings:
178
+ ```python
179
+ # settings.py
180
+ MIDDLEWARE = [
181
+ ...
182
+ "t402.django.PaymentMiddleware",
183
+ ...
184
+ ]
185
+
186
+ T402_PAYMENT_CONFIGS = [
187
+ {
188
+ "path": "/api/premium/*",
189
+ "price": "$0.10",
190
+ "pay_to_address": "0x1234...",
191
+ "network": "eip155:8453",
192
+ },
193
+ ]
194
+ ```
195
+
196
+ Programmatic configuration:
197
+ ```python
198
+ PaymentMiddleware.configure([
199
+ PaymentConfig(price="$0.10", pay_to_address="0x...", path="/api/*"),
200
+ ])
201
+ ```
202
+ """
203
+
204
+ # Class-level storage for payment configs
205
+ _configs: List[PaymentConfig] = []
206
+
207
+ def __init__(self, get_response: Callable):
208
+ """Initialize the Django middleware.
209
+
210
+ Args:
211
+ get_response: The next middleware or view callable in the chain
212
+ """
213
+ self.get_response = get_response
214
+
215
+ # Load configs from Django settings if no programmatic configs
216
+ if not PaymentMiddleware._configs:
217
+ self._load_from_settings()
218
+
219
+ def _load_from_settings(self):
220
+ """Load payment configs from Django settings."""
221
+ try:
222
+ from django.conf import settings
223
+
224
+ configs_data = getattr(settings, "T402_PAYMENT_CONFIGS", [])
225
+ for config_dict in configs_data:
226
+ config = PaymentConfig(**config_dict)
227
+ PaymentMiddleware._configs.append(config)
228
+ except Exception:
229
+ # Settings may not be configured in all environments
230
+ pass
231
+
232
+ @classmethod
233
+ def configure(cls, configs: List[PaymentConfig]) -> None:
234
+ """Configure payment middleware with a list of PaymentConfig objects.
235
+
236
+ Args:
237
+ configs: List of PaymentConfig objects
238
+ """
239
+ cls._configs = list(configs)
240
+
241
+ @classmethod
242
+ def add(
243
+ cls,
244
+ price: Price,
245
+ pay_to_address: str,
246
+ path: Union[str, List[str]] = "*",
247
+ description: str = "",
248
+ mime_type: str = "",
249
+ max_timeout_seconds: int = 60,
250
+ input_schema: Optional[HTTPInputSchema] = None,
251
+ output_schema: Optional[Any] = None,
252
+ discoverable: bool = True,
253
+ facilitator_config: Optional[FacilitatorConfig] = None,
254
+ network: str = "eip155:8453",
255
+ resource: Optional[str] = None,
256
+ paywall_config: Optional[PaywallConfig] = None,
257
+ custom_paywall_html: Optional[str] = None,
258
+ protocol_version: int = T402_VERSION_V2,
259
+ ) -> type:
260
+ """Add a payment requirement configuration.
261
+
262
+ Args:
263
+ price: Payment price (USD string or TokenAmount dict)
264
+ pay_to_address: Address to receive payment
265
+ path: Path pattern(s) to protect
266
+ description: Resource description
267
+ mime_type: Resource MIME type
268
+ max_timeout_seconds: Maximum payment timeout
269
+ input_schema: HTTP input schema
270
+ output_schema: Response schema
271
+ discoverable: Whether route is discoverable
272
+ facilitator_config: Facilitator configuration
273
+ network: Network identifier (CAIP-2 format)
274
+ resource: Explicit resource URL
275
+ paywall_config: Paywall UI configuration
276
+ custom_paywall_html: Custom paywall HTML
277
+ protocol_version: T402 protocol version (1 or 2)
278
+
279
+ Returns:
280
+ The class for chaining
281
+ """
282
+ config = PaymentConfig(
283
+ price=price,
284
+ pay_to_address=pay_to_address,
285
+ path=path,
286
+ description=description,
287
+ mime_type=mime_type,
288
+ max_timeout_seconds=max_timeout_seconds,
289
+ input_schema=input_schema,
290
+ output_schema=output_schema,
291
+ discoverable=discoverable,
292
+ facilitator_config=facilitator_config,
293
+ network=network,
294
+ resource=resource,
295
+ paywall_config=paywall_config,
296
+ custom_paywall_html=custom_paywall_html,
297
+ protocol_version=protocol_version,
298
+ )
299
+ cls._configs.append(config)
300
+ return cls
301
+
302
+ @classmethod
303
+ def reset(cls) -> None:
304
+ """Reset all payment configs. Useful for testing."""
305
+ cls._configs = []
306
+
307
+ def __call__(self, request: HttpRequest) -> HttpResponse:
308
+ """Process the request through payment middleware.
309
+
310
+ Args:
311
+ request: Incoming Django HttpRequest
312
+
313
+ Returns:
314
+ HttpResponse object
315
+ """
316
+ # Find matching config
317
+ config = self._find_matching_config(request.path)
318
+ if not config:
319
+ return self.get_response(request)
320
+
321
+ # Create facilitator client
322
+ facilitator = FacilitatorClient(config.facilitator_config)
323
+
324
+ # Get resource URL
325
+ resource_url = config.resource or request.build_absolute_uri()
326
+
327
+ # Build request headers dict for protocol detection
328
+ request_headers = self._get_headers_dict(request)
329
+
330
+ # Build payment requirements
331
+ requirements = self._build_requirements(config, request, resource_url)
332
+
333
+ # Create 402 response helper
334
+ def create_402_response(error: str) -> HttpResponse:
335
+ return self._create_402_response(
336
+ error=error,
337
+ requirements=[requirements],
338
+ request_headers=request_headers,
339
+ protocol_version=config.protocol_version,
340
+ paywall_config=config.paywall_config,
341
+ custom_paywall_html=config.custom_paywall_html,
342
+ resource_url=resource_url,
343
+ )
344
+
345
+ # Extract payment from headers
346
+ version, payment_header = extract_payment_from_headers(request_headers)
347
+
348
+ if not payment_header:
349
+ return create_402_response("No payment header provided")
350
+
351
+ # Decode payment
352
+ try:
353
+ payment_dict = decode_payment_signature_header(payment_header)
354
+ payment = PaymentPayload(**payment_dict)
355
+ except Exception as e:
356
+ client_ip = request.META.get("REMOTE_ADDR", "unknown")
357
+ logger.warning(f"Invalid payment header from {client_ip}: {e}")
358
+ return create_402_response("Invalid payment header format")
359
+
360
+ # Find matching requirements
361
+ selected_requirements = find_matching_payment_requirements(
362
+ [requirements], payment
363
+ )
364
+ if not selected_requirements:
365
+ return create_402_response("No matching payment requirements found")
366
+
367
+ # Verify payment (async in sync context)
368
+ try:
369
+ loop = asyncio.new_event_loop()
370
+ asyncio.set_event_loop(loop)
371
+ try:
372
+ verify_response = loop.run_until_complete(
373
+ facilitator.verify(payment, selected_requirements)
374
+ )
375
+ finally:
376
+ loop.close()
377
+ except Exception as e:
378
+ logger.error(f"Payment verification failed: {e}")
379
+ return create_402_response(f"Payment verification failed: {e}")
380
+
381
+ if not verify_response.is_valid:
382
+ error_reason = verify_response.invalid_reason or "Unknown error"
383
+ return create_402_response(f"Invalid payment: {error_reason}")
384
+
385
+ # Store payment details on the request
386
+ request.payment_details = PaymentDetails(
387
+ requirements=selected_requirements,
388
+ verify_response=verify_response,
389
+ protocol_version=version,
390
+ )
391
+ request.verify_response = verify_response
392
+
393
+ # Process request through the rest of the middleware chain / view
394
+ response = self.get_response(request)
395
+
396
+ # Skip settlement for non-2xx responses
397
+ if response.status_code < 200 or response.status_code >= 300:
398
+ return response
399
+
400
+ # Settle payment
401
+ try:
402
+ loop = asyncio.new_event_loop()
403
+ asyncio.set_event_loop(loop)
404
+ try:
405
+ settle_response = loop.run_until_complete(
406
+ facilitator.settle(payment, selected_requirements)
407
+ )
408
+ finally:
409
+ loop.close()
410
+
411
+ if settle_response.success:
412
+ # Add settlement header based on version
413
+ header_name = (
414
+ HEADER_PAYMENT_RESPONSE
415
+ if version == T402_VERSION_V2
416
+ else HEADER_X_PAYMENT_RESPONSE
417
+ )
418
+ header_value = encode_payment_response_header(settle_response)
419
+ response[header_name] = header_value
420
+ else:
421
+ return create_402_response(
422
+ f"Settlement failed: {settle_response.error_reason or 'Unknown error'}"
423
+ )
424
+ except Exception as e:
425
+ logger.error(f"Settlement failed: {e}")
426
+ return create_402_response(f"Settlement failed: {e}")
427
+
428
+ return response
429
+
430
+ def _find_matching_config(self, path: str) -> Optional[PaymentConfig]:
431
+ """Find a matching payment config for the given path.
432
+
433
+ Args:
434
+ path: Request path
435
+
436
+ Returns:
437
+ Matching PaymentConfig or None
438
+ """
439
+ for config in PaymentMiddleware._configs:
440
+ if path_is_match(config.path, path):
441
+ return config
442
+ return None
443
+
444
+ def _get_headers_dict(self, request: HttpRequest) -> Dict[str, str]:
445
+ """Extract headers from Django request into a flat dict.
446
+
447
+ Django stores headers in META with HTTP_ prefix and uppercase names.
448
+
449
+ Args:
450
+ request: Django HttpRequest
451
+
452
+ Returns:
453
+ Dictionary of header name to value
454
+ """
455
+ headers = {}
456
+ for key, value in request.META.items():
457
+ if key.startswith("HTTP_"):
458
+ # Convert HTTP_PAYMENT_SIGNATURE to payment-signature
459
+ header_name = key[5:].replace("_", "-").lower()
460
+ headers[header_name] = value
461
+ elif key == "CONTENT_TYPE":
462
+ headers["content-type"] = value
463
+ elif key == "CONTENT_LENGTH":
464
+ headers["content-length"] = value
465
+ return headers
466
+
467
+ def _build_requirements(
468
+ self,
469
+ config: PaymentConfig,
470
+ request: HttpRequest,
471
+ resource_url: str,
472
+ ) -> PaymentRequirements:
473
+ """Build payment requirements from config.
474
+
475
+ Args:
476
+ config: Payment configuration
477
+ request: Incoming request
478
+ resource_url: Resource URL
479
+
480
+ Returns:
481
+ PaymentRequirements object
482
+ """
483
+ return PaymentRequirements(
484
+ scheme="exact",
485
+ network=cast(SupportedNetworks, config.network),
486
+ asset=config.asset_address,
487
+ max_amount_required=config.max_amount_required,
488
+ resource=resource_url,
489
+ description=config.description,
490
+ mime_type=config.mime_type,
491
+ pay_to=config.pay_to_address,
492
+ max_timeout_seconds=config.max_timeout_seconds,
493
+ output_schema={
494
+ "input": {
495
+ "type": "http",
496
+ "method": request.method.upper(),
497
+ "discoverable": config.discoverable,
498
+ **(config.input_schema.model_dump() if config.input_schema else {}),
499
+ },
500
+ "output": config.output_schema,
501
+ },
502
+ extra=config.eip712_domain,
503
+ )
504
+
505
+ def _create_402_response(
506
+ self,
507
+ error: str,
508
+ requirements: List[PaymentRequirements],
509
+ request_headers: Dict[str, str],
510
+ protocol_version: int,
511
+ paywall_config: Optional[PaywallConfig],
512
+ custom_paywall_html: Optional[str],
513
+ resource_url: str,
514
+ ) -> HttpResponse:
515
+ """Create a 402 Payment Required response.
516
+
517
+ Args:
518
+ error: Error message
519
+ requirements: Payment requirements
520
+ request_headers: Request headers
521
+ protocol_version: Protocol version
522
+ paywall_config: Paywall configuration
523
+ custom_paywall_html: Custom HTML
524
+ resource_url: Resource URL
525
+
526
+ Returns:
527
+ 402 HttpResponse
528
+ """
529
+ status_code = 402
530
+
531
+ # Browser request - return HTML paywall
532
+ if is_browser_request(request_headers):
533
+ html_content = custom_paywall_html or get_paywall_html(
534
+ error, requirements, paywall_config
535
+ )
536
+ response = HttpResponse(
537
+ content=html_content,
538
+ status=status_code,
539
+ content_type="text/html; charset=utf-8",
540
+ )
541
+ return response
542
+
543
+ # API request - return JSON with appropriate headers
544
+ if protocol_version == T402_VERSION_V2:
545
+ # V2: Use PAYMENT-REQUIRED header
546
+ resource_info = ResourceInfo(
547
+ url=resource_url,
548
+ description=requirements[0].description if requirements else "",
549
+ mime_type=requirements[0].mime_type if requirements else "",
550
+ )
551
+
552
+ # Convert V1 requirements to V2 format
553
+ accepts_v2 = []
554
+ for req in requirements:
555
+ accepts_v2.append(
556
+ PaymentRequirementsV2(
557
+ scheme=req.scheme,
558
+ network=req.network,
559
+ asset=req.asset,
560
+ amount=req.max_amount_required,
561
+ pay_to=req.pay_to,
562
+ max_timeout_seconds=req.max_timeout_seconds,
563
+ extra=req.extra or {},
564
+ )
565
+ )
566
+
567
+ payment_required = PaymentRequiredV2(
568
+ t402_version=T402_VERSION_V2,
569
+ resource=resource_info,
570
+ accepts=accepts_v2,
571
+ error=error,
572
+ )
573
+
574
+ header_value = encode_payment_required_header(payment_required)
575
+
576
+ response = JsonResponse(
577
+ data=payment_required.model_dump(by_alias=True),
578
+ status=status_code,
579
+ )
580
+ response["Content-Type"] = "application/json"
581
+ response[HEADER_PAYMENT_REQUIRED] = header_value
582
+ return response
583
+ else:
584
+ # V1: Return body only
585
+ response_data = t402PaymentRequiredResponse(
586
+ t402_version=T402_VERSION_V1,
587
+ accepts=requirements,
588
+ error=error,
589
+ ).model_dump(by_alias=True)
590
+
591
+ response = JsonResponse(
592
+ data=response_data,
593
+ status=status_code,
594
+ )
595
+ response["Content-Type"] = "application/json"
596
+ return response