t402 1.6.1__py3-none-any.whl → 1.7.1__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.
@@ -1,18 +1,60 @@
1
- import base64
2
- import json
1
+ """FastAPI Middleware for T402 Payment Protocol.
2
+
3
+ This module provides middleware and utilities for integrating T402 payments
4
+ with FastAPI 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
+ from fastapi import FastAPI
19
+ from t402.fastapi import PaymentMiddleware, require_payment
20
+
21
+ app = FastAPI()
22
+
23
+ # Option 1: Use middleware class
24
+ payment = PaymentMiddleware(app)
25
+ payment.add(path="/api/*", price="$0.10", pay_to_address="0x...")
26
+
27
+ # Option 2: Use dependency injection (per-route)
28
+ @app.get("/premium")
29
+ async def premium_content(payment: PaymentDetails = Depends(require_payment("$0.10", "0x..."))):
30
+ return {"message": "Premium content"}
31
+ ```
32
+ """
33
+
34
+ from __future__ import annotations
35
+
3
36
  import logging
4
- from typing import Any, Callable, Optional, cast
37
+ from typing import Any, Callable, Dict, List, Optional, Union, cast
5
38
 
6
- from fastapi import Request
39
+ from fastapi import FastAPI, Request, Response
7
40
  from fastapi.responses import JSONResponse, HTMLResponse
41
+ from starlette.middleware.base import BaseHTTPMiddleware
8
42
  from pydantic import validate_call
9
43
 
10
44
  from t402.common import (
11
45
  process_price_to_atomic_amount,
12
- t402_VERSION,
13
46
  find_matching_payment_requirements,
14
47
  )
15
- from t402.encoding import safe_base64_decode
48
+ from t402.encoding import (
49
+ encode_payment_required_header,
50
+ encode_payment_response_header,
51
+ detect_protocol_version_from_headers,
52
+ extract_payment_from_headers,
53
+ decode_payment_signature_header,
54
+ HEADER_PAYMENT_REQUIRED,
55
+ HEADER_PAYMENT_RESPONSE,
56
+ HEADER_X_PAYMENT_RESPONSE,
57
+ )
16
58
  from t402.facilitator import FacilitatorClient, FacilitatorConfig
17
59
  from t402.networks import get_all_supported_networks, SupportedNetworks
18
60
  from t402.path import path_is_match
@@ -20,199 +62,692 @@ from t402.paywall import is_browser_request, get_paywall_html
20
62
  from t402.types import (
21
63
  PaymentPayload,
22
64
  PaymentRequirements,
65
+ PaymentRequirementsV2,
66
+ PaymentRequiredV2,
67
+ ResourceInfo,
23
68
  Price,
24
69
  t402PaymentRequiredResponse,
25
70
  PaywallConfig,
26
71
  HTTPInputSchema,
72
+ T402_VERSION_V1,
73
+ T402_VERSION_V2,
74
+ VerifyResponse,
27
75
  )
28
76
 
29
77
  logger = logging.getLogger(__name__)
30
78
 
31
79
 
80
+ class PaymentDetails:
81
+ """Payment details stored in request state after verification."""
82
+
83
+ def __init__(
84
+ self,
85
+ requirements: Union[PaymentRequirements, PaymentRequirementsV2],
86
+ verify_response: VerifyResponse,
87
+ protocol_version: int,
88
+ ):
89
+ self.requirements = requirements
90
+ self.verify_response = verify_response
91
+ self.protocol_version = protocol_version
92
+
93
+ @property
94
+ def is_verified(self) -> bool:
95
+ """Check if payment was verified."""
96
+ return self.verify_response.is_valid
97
+
98
+ @property
99
+ def payer_address(self) -> Optional[str]:
100
+ """Get payer address from verify response."""
101
+ return getattr(self.verify_response, "payer", None)
102
+
103
+
104
+ class PaymentConfig:
105
+ """Configuration for a payment-protected route."""
106
+
107
+ def __init__(
108
+ self,
109
+ price: Price,
110
+ pay_to_address: str,
111
+ path: Union[str, List[str]] = "*",
112
+ description: str = "",
113
+ mime_type: str = "",
114
+ max_timeout_seconds: int = 60,
115
+ input_schema: Optional[HTTPInputSchema] = None,
116
+ output_schema: Optional[Any] = None,
117
+ discoverable: bool = True,
118
+ facilitator_config: Optional[FacilitatorConfig] = None,
119
+ network: str = "eip155:8453",
120
+ resource: Optional[str] = None,
121
+ paywall_config: Optional[PaywallConfig] = None,
122
+ custom_paywall_html: Optional[str] = None,
123
+ protocol_version: int = T402_VERSION_V2,
124
+ ):
125
+ self.price = price
126
+ self.pay_to_address = pay_to_address
127
+ self.path = path
128
+ self.description = description
129
+ self.mime_type = mime_type
130
+ self.max_timeout_seconds = max_timeout_seconds
131
+ self.input_schema = input_schema
132
+ self.output_schema = output_schema
133
+ self.discoverable = discoverable
134
+ self.facilitator_config = facilitator_config
135
+ self.network = network
136
+ self.resource = resource
137
+ self.paywall_config = paywall_config
138
+ self.custom_paywall_html = custom_paywall_html
139
+ self.protocol_version = protocol_version
140
+
141
+ # Validate and process price
142
+ self._validate()
143
+
144
+ def _validate(self):
145
+ """Validate configuration."""
146
+ # Validate network is supported
147
+ supported_networks = get_all_supported_networks()
148
+ if self.network not in supported_networks:
149
+ raise ValueError(
150
+ f"Unsupported network: {self.network}. Must be one of: {supported_networks}"
151
+ )
152
+
153
+ # Process price
154
+ try:
155
+ self.max_amount_required, self.asset_address, self.eip712_domain = (
156
+ process_price_to_atomic_amount(self.price, self.network)
157
+ )
158
+ except Exception as e:
159
+ raise ValueError(f"Invalid price: {self.price}. Error: {e}")
160
+
161
+
162
+ class PaymentMiddleware:
163
+ """FastAPI middleware for T402 payment requirements.
164
+
165
+ This class provides a flexible way to add payment requirements to FastAPI routes.
166
+ It supports multiple configurations with different path patterns and settings.
167
+
168
+ Example:
169
+ ```python
170
+ app = FastAPI()
171
+ payment = PaymentMiddleware(app)
172
+
173
+ # Add payment requirement for a specific path
174
+ payment.add(
175
+ path="/api/premium/*",
176
+ price="$0.10",
177
+ pay_to_address="0x1234...",
178
+ network="eip155:8453",
179
+ )
180
+
181
+ # Add payment for another path with different config
182
+ payment.add(
183
+ path="/api/data",
184
+ price={"amount": "1000000", "asset": "0xUSDC..."},
185
+ pay_to_address="0x5678...",
186
+ )
187
+ ```
188
+ """
189
+
190
+ def __init__(self, app: FastAPI):
191
+ """Initialize the payment middleware.
192
+
193
+ Args:
194
+ app: FastAPI application instance
195
+ """
196
+ self.app = app
197
+ self.configs: List[PaymentConfig] = []
198
+ self._middleware_added = False
199
+
200
+ def add(
201
+ self,
202
+ price: Price,
203
+ pay_to_address: str,
204
+ path: Union[str, List[str]] = "*",
205
+ description: str = "",
206
+ mime_type: str = "",
207
+ max_timeout_seconds: int = 60,
208
+ input_schema: Optional[HTTPInputSchema] = None,
209
+ output_schema: Optional[Any] = None,
210
+ discoverable: bool = True,
211
+ facilitator_config: Optional[FacilitatorConfig] = None,
212
+ network: str = "eip155:8453",
213
+ resource: Optional[str] = None,
214
+ paywall_config: Optional[PaywallConfig] = None,
215
+ custom_paywall_html: Optional[str] = None,
216
+ protocol_version: int = T402_VERSION_V2,
217
+ ) -> "PaymentMiddleware":
218
+ """Add a payment requirement configuration.
219
+
220
+ Args:
221
+ price: Payment price (USD string or TokenAmount dict)
222
+ pay_to_address: Address to receive payment
223
+ path: Path pattern(s) to protect
224
+ description: Resource description
225
+ mime_type: Resource MIME type
226
+ max_timeout_seconds: Maximum payment timeout
227
+ input_schema: HTTP input schema
228
+ output_schema: Response schema
229
+ discoverable: Whether route is discoverable
230
+ facilitator_config: Facilitator configuration
231
+ network: Network identifier (CAIP-2 format)
232
+ resource: Explicit resource URL
233
+ paywall_config: Paywall UI configuration
234
+ custom_paywall_html: Custom paywall HTML
235
+ protocol_version: T402 protocol version (1 or 2)
236
+
237
+ Returns:
238
+ Self for chaining
239
+ """
240
+ config = PaymentConfig(
241
+ price=price,
242
+ pay_to_address=pay_to_address,
243
+ path=path,
244
+ description=description,
245
+ mime_type=mime_type,
246
+ max_timeout_seconds=max_timeout_seconds,
247
+ input_schema=input_schema,
248
+ output_schema=output_schema,
249
+ discoverable=discoverable,
250
+ facilitator_config=facilitator_config,
251
+ network=network,
252
+ resource=resource,
253
+ paywall_config=paywall_config,
254
+ custom_paywall_html=custom_paywall_html,
255
+ protocol_version=protocol_version,
256
+ )
257
+ self.configs.append(config)
258
+
259
+ # Add middleware if not already added
260
+ if not self._middleware_added:
261
+ self.app.add_middleware(
262
+ BaseHTTPMiddleware,
263
+ dispatch=self._dispatch,
264
+ )
265
+ self._middleware_added = True
266
+
267
+ return self
268
+
269
+ async def _dispatch(
270
+ self, request: Request, call_next: Callable
271
+ ) -> Response:
272
+ """Process request through payment middleware.
273
+
274
+ Args:
275
+ request: Incoming request
276
+ call_next: Next middleware/handler
277
+
278
+ Returns:
279
+ Response object
280
+ """
281
+ # Find matching config
282
+ config = self._find_matching_config(request.url.path)
283
+ if not config:
284
+ return await call_next(request)
285
+
286
+ # Create facilitator client
287
+ facilitator = FacilitatorClient(config.facilitator_config)
288
+
289
+ # Get resource URL
290
+ resource_url = config.resource or str(request.url)
291
+
292
+ # Detect protocol version from request headers
293
+ request_headers = dict(request.headers)
294
+ detect_protocol_version_from_headers(request_headers)
295
+
296
+ # Build payment requirements
297
+ requirements = self._build_requirements(config, request, resource_url)
298
+
299
+ # Create 402 response helper
300
+ def create_402_response(error: str) -> Response:
301
+ return self._create_402_response(
302
+ error=error,
303
+ requirements=[requirements],
304
+ request_headers=request_headers,
305
+ protocol_version=config.protocol_version,
306
+ paywall_config=config.paywall_config,
307
+ custom_paywall_html=config.custom_paywall_html,
308
+ resource_url=resource_url,
309
+ )
310
+
311
+ # Extract payment from headers
312
+ version, payment_header = extract_payment_from_headers(request_headers)
313
+
314
+ if not payment_header:
315
+ return create_402_response("No payment header provided")
316
+
317
+ # Decode payment
318
+ try:
319
+ payment_dict = decode_payment_signature_header(payment_header)
320
+ payment = PaymentPayload(**payment_dict)
321
+ except Exception as e:
322
+ logger.warning(
323
+ f"Invalid payment header from {request.client.host if request.client else 'unknown'}: {e}"
324
+ )
325
+ return create_402_response("Invalid payment header format")
326
+
327
+ # Find matching requirements
328
+ selected_requirements = find_matching_payment_requirements(
329
+ [requirements], payment
330
+ )
331
+ if not selected_requirements:
332
+ return create_402_response("No matching payment requirements found")
333
+
334
+ # Verify payment
335
+ try:
336
+ verify_response = await facilitator.verify(payment, selected_requirements)
337
+ except Exception as e:
338
+ logger.error(f"Payment verification failed: {e}")
339
+ return create_402_response(f"Payment verification failed: {e}")
340
+
341
+ if not verify_response.is_valid:
342
+ error_reason = verify_response.invalid_reason or "Unknown error"
343
+ return create_402_response(f"Invalid payment: {error_reason}")
344
+
345
+ # Store payment details in request state
346
+ request.state.payment_details = PaymentDetails(
347
+ requirements=selected_requirements,
348
+ verify_response=verify_response,
349
+ protocol_version=version,
350
+ )
351
+ request.state.verify_response = verify_response
352
+
353
+ # Process request
354
+ response = await call_next(request)
355
+
356
+ # Skip settlement for non-2xx responses
357
+ if response.status_code < 200 or response.status_code >= 300:
358
+ return response
359
+
360
+ # Settle payment
361
+ try:
362
+ settle_response = await facilitator.settle(payment, selected_requirements)
363
+ if settle_response.success:
364
+ # Add settlement header based on version
365
+ header_name = (
366
+ HEADER_PAYMENT_RESPONSE
367
+ if version == T402_VERSION_V2
368
+ else HEADER_X_PAYMENT_RESPONSE
369
+ )
370
+ header_value = encode_payment_response_header(settle_response)
371
+ response.headers[header_name] = header_value
372
+ else:
373
+ return create_402_response(
374
+ f"Settlement failed: {settle_response.error_reason or 'Unknown error'}"
375
+ )
376
+ except Exception as e:
377
+ logger.error(f"Settlement failed: {e}")
378
+ return create_402_response(f"Settlement failed: {e}")
379
+
380
+ return response
381
+
382
+ def _find_matching_config(self, path: str) -> Optional[PaymentConfig]:
383
+ """Find a matching payment config for the given path.
384
+
385
+ Args:
386
+ path: Request path
387
+
388
+ Returns:
389
+ Matching PaymentConfig or None
390
+ """
391
+ for config in self.configs:
392
+ if path_is_match(config.path, path):
393
+ return config
394
+ return None
395
+
396
+ def _build_requirements(
397
+ self,
398
+ config: PaymentConfig,
399
+ request: Request,
400
+ resource_url: str,
401
+ ) -> PaymentRequirements:
402
+ """Build payment requirements from config.
403
+
404
+ Args:
405
+ config: Payment configuration
406
+ request: Incoming request
407
+ resource_url: Resource URL
408
+
409
+ Returns:
410
+ PaymentRequirements object
411
+ """
412
+ return PaymentRequirements(
413
+ scheme="exact",
414
+ network=cast(SupportedNetworks, config.network),
415
+ asset=config.asset_address,
416
+ max_amount_required=config.max_amount_required,
417
+ resource=resource_url,
418
+ description=config.description,
419
+ mime_type=config.mime_type,
420
+ pay_to=config.pay_to_address,
421
+ max_timeout_seconds=config.max_timeout_seconds,
422
+ output_schema={
423
+ "input": {
424
+ "type": "http",
425
+ "method": request.method.upper(),
426
+ "discoverable": config.discoverable,
427
+ **(config.input_schema.model_dump() if config.input_schema else {}),
428
+ },
429
+ "output": config.output_schema,
430
+ },
431
+ extra=config.eip712_domain,
432
+ )
433
+
434
+ def _create_402_response(
435
+ self,
436
+ error: str,
437
+ requirements: List[PaymentRequirements],
438
+ request_headers: Dict[str, str],
439
+ protocol_version: int,
440
+ paywall_config: Optional[PaywallConfig],
441
+ custom_paywall_html: Optional[str],
442
+ resource_url: str,
443
+ ) -> Response:
444
+ """Create a 402 Payment Required response.
445
+
446
+ Args:
447
+ error: Error message
448
+ requirements: Payment requirements
449
+ request_headers: Request headers
450
+ protocol_version: Protocol version
451
+ paywall_config: Paywall configuration
452
+ custom_paywall_html: Custom HTML
453
+ resource_url: Resource URL
454
+
455
+ Returns:
456
+ 402 Response
457
+ """
458
+ status_code = 402
459
+
460
+ # Browser request - return HTML paywall
461
+ if is_browser_request(request_headers):
462
+ html_content = custom_paywall_html or get_paywall_html(
463
+ error, requirements, paywall_config
464
+ )
465
+ return HTMLResponse(
466
+ content=html_content,
467
+ status_code=status_code,
468
+ headers={"Content-Type": "text/html; charset=utf-8"},
469
+ )
470
+
471
+ # API request - return JSON with appropriate headers
472
+ if protocol_version == T402_VERSION_V2:
473
+ # V2: Use PAYMENT-REQUIRED header
474
+ resource_info = ResourceInfo(
475
+ url=resource_url,
476
+ description=requirements[0].description if requirements else "",
477
+ mime_type=requirements[0].mime_type if requirements else "",
478
+ )
479
+
480
+ # Convert V1 requirements to V2 format
481
+ accepts_v2 = []
482
+ for req in requirements:
483
+ accepts_v2.append(
484
+ PaymentRequirementsV2(
485
+ scheme=req.scheme,
486
+ network=req.network,
487
+ asset=req.asset,
488
+ amount=req.max_amount_required,
489
+ pay_to=req.pay_to,
490
+ max_timeout_seconds=req.max_timeout_seconds,
491
+ extra=req.extra or {},
492
+ )
493
+ )
494
+
495
+ payment_required = PaymentRequiredV2(
496
+ t402_version=T402_VERSION_V2,
497
+ resource=resource_info,
498
+ accepts=accepts_v2,
499
+ error=error,
500
+ )
501
+
502
+ header_value = encode_payment_required_header(payment_required)
503
+
504
+ # Return response with header and body
505
+ return JSONResponse(
506
+ content=payment_required.model_dump(by_alias=True),
507
+ status_code=status_code,
508
+ headers={
509
+ "Content-Type": "application/json",
510
+ HEADER_PAYMENT_REQUIRED: header_value,
511
+ },
512
+ )
513
+ else:
514
+ # V1: Return body only
515
+ response_data = t402PaymentRequiredResponse(
516
+ t402_version=T402_VERSION_V1,
517
+ accepts=requirements,
518
+ error=error,
519
+ ).model_dump(by_alias=True)
520
+
521
+ return JSONResponse(
522
+ content=response_data,
523
+ status_code=status_code,
524
+ headers={"Content-Type": "application/json"},
525
+ )
526
+
527
+
32
528
  @validate_call
33
529
  def require_payment(
34
530
  price: Price,
35
531
  pay_to_address: str,
36
- path: str | list[str] = "*",
532
+ path: Union[str, List[str]] = "*",
37
533
  description: str = "",
38
534
  mime_type: str = "",
39
- max_deadline_seconds: int = 60,
535
+ max_timeout_seconds: int = 60,
40
536
  input_schema: Optional[HTTPInputSchema] = None,
41
537
  output_schema: Optional[Any] = None,
42
- discoverable: Optional[bool] = True,
538
+ discoverable: bool = True,
43
539
  facilitator_config: Optional[FacilitatorConfig] = None,
44
- network: str = "base-sepolia",
540
+ network: str = "eip155:8453",
45
541
  resource: Optional[str] = None,
46
542
  paywall_config: Optional[PaywallConfig] = None,
47
543
  custom_paywall_html: Optional[str] = None,
544
+ protocol_version: int = T402_VERSION_V2,
48
545
  ):
49
546
  """Generate a FastAPI middleware that gates payments for an endpoint.
50
547
 
548
+ This is the functional middleware approach, useful when you want
549
+ fine-grained control over which endpoints require payment.
550
+
51
551
  Args:
52
- price (Price): Payment price. Can be:
53
- - Money: USD amount as string/int (e.g., "$3.10", 0.10, "0.001") - defaults to USDC
54
- - TokenAmount: Custom token amount with asset information
55
- pay_to_address (str): Ethereum address to receive the payment
56
- path (str | list[str], optional): Path to gate with payments. Defaults to "*" for all paths.
57
- description (str, optional): Description of what is being purchased. Defaults to "".
58
- mime_type (str, optional): MIME type of the resource. Defaults to "".
59
- max_deadline_seconds (int, optional): Maximum time allowed for payment. Defaults to 60.
60
- input_schema (Optional[HTTPInputSchema], optional): Schema for the request structure. Defaults to None.
61
- output_schema (Optional[Any], optional): Schema for the response. Defaults to None.
62
- discoverable (bool, optional): Whether the route is discoverable. Defaults to True.
63
- facilitator_config (Optional[Dict[str, Any]], optional): Configuration for the payment facilitator.
64
- If not provided, defaults to the public t402.org facilitator.
65
- network (str, optional): Ethereum network ID. Defaults to "base-sepolia" (Base Sepolia testnet).
66
- resource (Optional[str], optional): Resource URL. Defaults to None (uses request URL).
67
- paywall_config (Optional[PaywallConfig], optional): Configuration for paywall UI customization.
68
- Includes options like app_name, app_logo.
69
- custom_paywall_html (Optional[str], optional): Custom HTML to display for paywall instead of default.
552
+ price: Payment price (USD string like "$0.10" or TokenAmount dict)
553
+ pay_to_address: Address to receive the payment
554
+ path: Path pattern(s) to protect. Defaults to "*" for all.
555
+ description: Description of the resource
556
+ mime_type: MIME type of the resource
557
+ max_timeout_seconds: Maximum time allowed for payment
558
+ input_schema: Schema for request structure
559
+ output_schema: Schema for response structure
560
+ discoverable: Whether the route is discoverable
561
+ facilitator_config: Facilitator configuration
562
+ network: Network identifier (CAIP-2 format)
563
+ resource: Explicit resource URL
564
+ paywall_config: Paywall UI configuration
565
+ custom_paywall_html: Custom paywall HTML
566
+ protocol_version: T402 protocol version (1 or 2)
70
567
 
71
568
  Returns:
72
- Callable: FastAPI middleware function that checks for valid payment before processing requests
73
- """
569
+ FastAPI middleware function
74
570
 
75
- # Validate network is supported
76
- supported_networks = get_all_supported_networks()
77
- if network not in supported_networks:
78
- raise ValueError(
79
- f"Unsupported network: {network}. Must be one of: {supported_networks}"
80
- )
571
+ Example:
572
+ ```python
573
+ app = FastAPI()
81
574
 
82
- try:
83
- max_amount_required, asset_address, eip712_domain = (
84
- process_price_to_atomic_amount(price, network)
85
- )
86
- except Exception as e:
87
- raise ValueError(f"Invalid price: {price}. Error: {e}")
575
+ @app.middleware("http")
576
+ async def payment_middleware(request: Request, call_next):
577
+ middleware = require_payment(
578
+ price="$0.10",
579
+ pay_to_address="0x...",
580
+ path="/api/*",
581
+ )
582
+ return await middleware(request, call_next)
583
+ ```
584
+ """
585
+ config = PaymentConfig(
586
+ price=price,
587
+ pay_to_address=pay_to_address,
588
+ path=path,
589
+ description=description,
590
+ mime_type=mime_type,
591
+ max_timeout_seconds=max_timeout_seconds,
592
+ input_schema=input_schema,
593
+ output_schema=output_schema,
594
+ discoverable=discoverable,
595
+ facilitator_config=facilitator_config,
596
+ network=network,
597
+ resource=resource,
598
+ paywall_config=paywall_config,
599
+ custom_paywall_html=custom_paywall_html,
600
+ protocol_version=protocol_version,
601
+ )
88
602
 
89
603
  facilitator = FacilitatorClient(facilitator_config)
90
604
 
91
- async def middleware(request: Request, call_next: Callable):
92
- # Skip if the path is not the same as the path in the middleware
93
- if not path_is_match(path, request.url.path):
605
+ async def middleware(request: Request, call_next: Callable) -> Response:
606
+ # Skip if path doesn't match
607
+ if not path_is_match(config.path, request.url.path):
94
608
  return await call_next(request)
95
609
 
96
- # Get resource URL if not explicitly provided
97
- resource_url = resource or str(request.url)
98
-
99
- # Construct payment details
100
- payment_requirements = [
101
- PaymentRequirements(
102
- scheme="exact",
103
- network=cast(SupportedNetworks, network),
104
- asset=asset_address,
105
- max_amount_required=max_amount_required,
106
- resource=resource_url,
107
- description=description,
108
- mime_type=mime_type,
109
- pay_to=pay_to_address,
110
- max_timeout_seconds=max_deadline_seconds,
111
- # Contains both input and output schema (field name kept for backwards compatibility)
112
- output_schema={
113
- "input": {
114
- "type": "http",
115
- "method": request.method.upper(),
116
- "discoverable": discoverable
117
- if discoverable is not None
118
- else True,
119
- **(input_schema.model_dump() if input_schema else {}),
120
- },
121
- "output": output_schema,
610
+ # Get resource URL
611
+ resource_url = config.resource or str(request.url)
612
+
613
+ # Detect protocol version
614
+ request_headers = dict(request.headers)
615
+ detect_protocol_version_from_headers(request_headers)
616
+
617
+ # Build requirements
618
+ requirements = PaymentRequirements(
619
+ scheme="exact",
620
+ network=cast(SupportedNetworks, config.network),
621
+ asset=config.asset_address,
622
+ max_amount_required=config.max_amount_required,
623
+ resource=resource_url,
624
+ description=config.description,
625
+ mime_type=config.mime_type,
626
+ pay_to=config.pay_to_address,
627
+ max_timeout_seconds=config.max_timeout_seconds,
628
+ output_schema={
629
+ "input": {
630
+ "type": "http",
631
+ "method": request.method.upper(),
632
+ "discoverable": config.discoverable,
633
+ **(config.input_schema.model_dump() if config.input_schema else {}),
122
634
  },
123
- extra=eip712_domain,
124
- )
125
- ]
635
+ "output": config.output_schema,
636
+ },
637
+ extra=config.eip712_domain,
638
+ )
126
639
 
127
- def t402_response(error: str):
128
- """Create a 402 response with payment requirements."""
129
- request_headers = dict(request.headers)
640
+ def create_402_response(error: str) -> Response:
641
+ """Create a 402 response."""
130
642
  status_code = 402
131
643
 
132
644
  if is_browser_request(request_headers):
133
- html_content = custom_paywall_html or get_paywall_html(
134
- error, payment_requirements, paywall_config
645
+ html = config.custom_paywall_html or get_paywall_html(
646
+ error, [requirements], config.paywall_config
135
647
  )
136
- headers = {"Content-Type": "text/html; charset=utf-8"}
648
+ return HTMLResponse(content=html, status_code=status_code)
137
649
 
138
- return HTMLResponse(
139
- content=html_content,
650
+ if config.protocol_version == T402_VERSION_V2:
651
+ resource_info = ResourceInfo(
652
+ url=resource_url,
653
+ description=config.description,
654
+ mime_type=config.mime_type,
655
+ )
656
+ payment_required = PaymentRequiredV2(
657
+ t402_version=T402_VERSION_V2,
658
+ resource=resource_info,
659
+ accepts=[
660
+ PaymentRequirementsV2(
661
+ scheme=requirements.scheme,
662
+ network=requirements.network,
663
+ asset=requirements.asset,
664
+ amount=requirements.max_amount_required,
665
+ pay_to=requirements.pay_to,
666
+ max_timeout_seconds=requirements.max_timeout_seconds,
667
+ extra=requirements.extra or {},
668
+ )
669
+ ],
670
+ error=error,
671
+ )
672
+ header_value = encode_payment_required_header(payment_required)
673
+ return JSONResponse(
674
+ content=payment_required.model_dump(by_alias=True),
140
675
  status_code=status_code,
141
- headers=headers,
676
+ headers={HEADER_PAYMENT_REQUIRED: header_value},
142
677
  )
143
678
  else:
144
679
  response_data = t402PaymentRequiredResponse(
145
- t402_version=t402_VERSION,
146
- accepts=payment_requirements,
680
+ t402_version=T402_VERSION_V1,
681
+ accepts=[requirements],
147
682
  error=error,
148
683
  ).model_dump(by_alias=True)
149
- headers = {"Content-Type": "application/json"}
150
-
151
- return JSONResponse(
152
- content=response_data,
153
- status_code=status_code,
154
- headers=headers,
155
- )
684
+ return JSONResponse(content=response_data, status_code=status_code)
156
685
 
157
- # Check for payment header
158
- payment_header = request.headers.get("X-PAYMENT", "")
686
+ # Extract payment header
687
+ version, payment_header = extract_payment_from_headers(request_headers)
159
688
 
160
- if payment_header == "":
161
- return t402_response("No X-PAYMENT header provided")
689
+ if not payment_header:
690
+ return create_402_response("No payment header provided")
162
691
 
163
- # Decode payment header
692
+ # Decode payment
164
693
  try:
165
- payment_dict = json.loads(safe_base64_decode(payment_header))
694
+ payment_dict = decode_payment_signature_header(payment_header)
166
695
  payment = PaymentPayload(**payment_dict)
167
696
  except Exception as e:
168
- logger.warning(
169
- f"Invalid payment header format from {request.client.host if request.client else 'unknown'}: {str(e)}"
170
- )
171
- return t402_response("Invalid payment header format")
697
+ logger.warning(f"Invalid payment header: {e}")
698
+ return create_402_response("Invalid payment header format")
172
699
 
173
- # Find matching payment requirements
174
- selected_payment_requirements = find_matching_payment_requirements(
175
- payment_requirements, payment
176
- )
177
-
178
- if not selected_payment_requirements:
179
- return t402_response("No matching payment requirements found")
700
+ # Find matching requirements
701
+ selected = find_matching_payment_requirements([requirements], payment)
702
+ if not selected:
703
+ return create_402_response("No matching payment requirements found")
180
704
 
181
- # Verify payment
182
- verify_response = await facilitator.verify(
183
- payment, selected_payment_requirements
184
- )
705
+ # Verify
706
+ try:
707
+ verify_response = await facilitator.verify(payment, selected)
708
+ except Exception as e:
709
+ logger.error(f"Verification failed: {e}")
710
+ return create_402_response(f"Verification failed: {e}")
185
711
 
186
712
  if not verify_response.is_valid:
187
- error_reason = verify_response.invalid_reason or "Unknown error"
188
- return t402_response(f"Invalid payment: {error_reason}")
713
+ return create_402_response(
714
+ f"Invalid payment: {verify_response.invalid_reason or 'Unknown'}"
715
+ )
189
716
 
190
- request.state.payment_details = selected_payment_requirements
717
+ # Store in request state
718
+ request.state.payment_details = PaymentDetails(
719
+ requirements=selected,
720
+ verify_response=verify_response,
721
+ protocol_version=version,
722
+ )
191
723
  request.state.verify_response = verify_response
192
724
 
193
- # Process the request
725
+ # Call next
194
726
  response = await call_next(request)
195
727
 
196
- # Early return without settling if the response is not a 2xx
728
+ # Skip settlement for non-2xx
197
729
  if response.status_code < 200 or response.status_code >= 300:
198
730
  return response
199
731
 
200
- # Settle the payment
732
+ # Settle
201
733
  try:
202
- settle_response = await facilitator.settle(
203
- payment, selected_payment_requirements
204
- )
734
+ settle_response = await facilitator.settle(payment, selected)
205
735
  if settle_response.success:
206
- response.headers["X-PAYMENT-RESPONSE"] = base64.b64encode(
207
- settle_response.model_dump_json(by_alias=True).encode("utf-8")
208
- ).decode("utf-8")
736
+ header_name = (
737
+ HEADER_PAYMENT_RESPONSE
738
+ if version == T402_VERSION_V2
739
+ else HEADER_X_PAYMENT_RESPONSE
740
+ )
741
+ response.headers[header_name] = encode_payment_response_header(
742
+ settle_response
743
+ )
209
744
  else:
210
- return t402_response(
211
- "Settle failed: "
212
- + (settle_response.error_reason or "Unknown error")
745
+ return create_402_response(
746
+ f"Settlement failed: {settle_response.error_reason or 'Unknown'}"
213
747
  )
214
- except Exception:
215
- return t402_response("Settle failed")
748
+ except Exception as e:
749
+ logger.error(f"Settlement failed: {e}")
750
+ return create_402_response(f"Settlement failed: {e}")
216
751
 
217
752
  return response
218
753