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.
- t402/__init__.py +169 -2
- t402/common.py +37 -8
- t402/encoding.py +298 -1
- t402/facilitator.py +1 -1
- t402/fastapi/__init__.py +79 -0
- t402/fastapi/dependencies.py +398 -0
- t402/fastapi/middleware.py +665 -130
- t402/schemes/__init__.py +125 -0
- t402/schemes/evm/__init__.py +25 -0
- t402/schemes/evm/exact/__init__.py +29 -0
- t402/schemes/evm/exact/client.py +265 -0
- t402/schemes/evm/exact/server.py +181 -0
- t402/schemes/interfaces.py +401 -0
- t402/schemes/registry.py +477 -0
- t402/schemes/ton/__init__.py +22 -0
- t402/schemes/ton/exact/__init__.py +27 -0
- t402/schemes/ton/exact/client.py +343 -0
- t402/schemes/ton/exact/server.py +201 -0
- t402/schemes/tron/__init__.py +22 -0
- t402/schemes/tron/exact/__init__.py +27 -0
- t402/schemes/tron/exact/client.py +260 -0
- t402/schemes/tron/exact/server.py +192 -0
- t402/types.py +178 -8
- {t402-1.6.1.dist-info → t402-1.7.1.dist-info}/METADATA +1 -1
- {t402-1.6.1.dist-info → t402-1.7.1.dist-info}/RECORD +27 -11
- {t402-1.6.1.dist-info → t402-1.7.1.dist-info}/WHEEL +0 -0
- {t402-1.6.1.dist-info → t402-1.7.1.dist-info}/entry_points.txt +0 -0
t402/fastapi/middleware.py
CHANGED
|
@@ -1,18 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
532
|
+
path: Union[str, List[str]] = "*",
|
|
37
533
|
description: str = "",
|
|
38
534
|
mime_type: str = "",
|
|
39
|
-
|
|
535
|
+
max_timeout_seconds: int = 60,
|
|
40
536
|
input_schema: Optional[HTTPInputSchema] = None,
|
|
41
537
|
output_schema: Optional[Any] = None,
|
|
42
|
-
discoverable:
|
|
538
|
+
discoverable: bool = True,
|
|
43
539
|
facilitator_config: Optional[FacilitatorConfig] = None,
|
|
44
|
-
network: str = "
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
73
|
-
"""
|
|
569
|
+
FastAPI middleware function
|
|
74
570
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
97
|
-
resource_url = resource or str(request.url)
|
|
98
|
-
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
635
|
+
"output": config.output_schema,
|
|
636
|
+
},
|
|
637
|
+
extra=config.eip712_domain,
|
|
638
|
+
)
|
|
126
639
|
|
|
127
|
-
def
|
|
128
|
-
"""Create a 402 response
|
|
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
|
-
|
|
134
|
-
error,
|
|
645
|
+
html = config.custom_paywall_html or get_paywall_html(
|
|
646
|
+
error, [requirements], config.paywall_config
|
|
135
647
|
)
|
|
136
|
-
|
|
648
|
+
return HTMLResponse(content=html, status_code=status_code)
|
|
137
649
|
|
|
138
|
-
|
|
139
|
-
|
|
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=
|
|
676
|
+
headers={HEADER_PAYMENT_REQUIRED: header_value},
|
|
142
677
|
)
|
|
143
678
|
else:
|
|
144
679
|
response_data = t402PaymentRequiredResponse(
|
|
145
|
-
t402_version=
|
|
146
|
-
accepts=
|
|
680
|
+
t402_version=T402_VERSION_V1,
|
|
681
|
+
accepts=[requirements],
|
|
147
682
|
error=error,
|
|
148
683
|
).model_dump(by_alias=True)
|
|
149
|
-
|
|
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
|
-
#
|
|
158
|
-
payment_header =
|
|
686
|
+
# Extract payment header
|
|
687
|
+
version, payment_header = extract_payment_from_headers(request_headers)
|
|
159
688
|
|
|
160
|
-
if payment_header
|
|
161
|
-
return
|
|
689
|
+
if not payment_header:
|
|
690
|
+
return create_402_response("No payment header provided")
|
|
162
691
|
|
|
163
|
-
# Decode payment
|
|
692
|
+
# Decode payment
|
|
164
693
|
try:
|
|
165
|
-
payment_dict =
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
payment,
|
|
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
|
-
|
|
188
|
-
|
|
713
|
+
return create_402_response(
|
|
714
|
+
f"Invalid payment: {verify_response.invalid_reason or 'Unknown'}"
|
|
715
|
+
)
|
|
189
716
|
|
|
190
|
-
request
|
|
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
|
-
#
|
|
725
|
+
# Call next
|
|
194
726
|
response = await call_next(request)
|
|
195
727
|
|
|
196
|
-
#
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
211
|
-
"
|
|
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
|
-
|
|
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
|
|