uvd-x402-sdk 0.2.2__py3-none-any.whl → 0.3.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.
@@ -1,325 +1,325 @@
1
- """
2
- Decorators for protecting endpoints with x402 payments.
3
-
4
- These decorators provide a clean, declarative way to require payment
5
- for specific endpoints across different web frameworks.
6
- """
7
-
8
- from decimal import Decimal
9
- from functools import wraps
10
- from typing import Any, Callable, Optional, TypeVar, Union, Dict
11
-
12
- from uvd_x402_sdk.config import X402Config
13
- from uvd_x402_sdk.client import X402Client
14
- from uvd_x402_sdk.exceptions import (
15
- X402Error,
16
- PaymentRequiredError,
17
- InvalidPayloadError,
18
- )
19
- from uvd_x402_sdk.response import create_402_response, create_402_headers
20
-
21
- F = TypeVar("F", bound=Callable[..., Any])
22
-
23
- # Global client instance (set by configure_x402)
24
- _global_client: Optional[X402Client] = None
25
- _global_config: Optional[X402Config] = None
26
-
27
-
28
- def configure_x402(
29
- config: Optional[X402Config] = None,
30
- recipient_address: Optional[str] = None,
31
- facilitator_url: str = "https://facilitator.ultravioletadao.xyz",
32
- **kwargs: Any,
33
- ) -> None:
34
- """
35
- Configure the global x402 client for decorator usage.
36
-
37
- Call this once during application startup to set up the x402 client
38
- that decorators will use.
39
-
40
- Args:
41
- config: Full X402Config object
42
- recipient_address: Default recipient for EVM chains
43
- facilitator_url: Facilitator service URL
44
- **kwargs: Additional config parameters
45
-
46
- Example:
47
- >>> from uvd_x402_sdk import configure_x402
48
- >>> configure_x402(
49
- ... recipient_address="0xYourWallet...",
50
- ... facilitator_url="https://facilitator.ultravioletadao.xyz"
51
- ... )
52
- """
53
- global _global_client, _global_config
54
-
55
- if config:
56
- _global_config = config
57
- else:
58
- _global_config = X402Config(
59
- facilitator_url=facilitator_url,
60
- recipient_evm=recipient_address or "",
61
- **kwargs,
62
- )
63
-
64
- _global_client = X402Client(config=_global_config)
65
-
66
-
67
- def get_x402_client() -> X402Client:
68
- """Get the global x402 client instance."""
69
- if _global_client is None:
70
- raise RuntimeError(
71
- "x402 not configured. Call configure_x402() during application startup."
72
- )
73
- return _global_client
74
-
75
-
76
- def get_x402_config() -> X402Config:
77
- """Get the global x402 config instance."""
78
- if _global_config is None:
79
- raise RuntimeError(
80
- "x402 not configured. Call configure_x402() during application startup."
81
- )
82
- return _global_config
83
-
84
-
85
- def require_payment(
86
- amount_usd: Union[Decimal, float, str],
87
- amount_callback: Optional[Callable[..., Decimal]] = None,
88
- networks: Optional[list] = None,
89
- message: Optional[str] = None,
90
- inject_result: bool = True,
91
- header_name: str = "X-PAYMENT",
92
- ) -> Callable[[F], F]:
93
- """
94
- Decorator that requires x402 payment for an endpoint.
95
-
96
- This is the main decorator for protecting endpoints with payments.
97
- It handles the complete payment flow:
98
- 1. Check for X-PAYMENT header
99
- 2. If missing, return 402 Payment Required
100
- 3. If present, verify and settle payment
101
- 4. Optionally inject PaymentResult into function
102
-
103
- Args:
104
- amount_usd: Fixed payment amount in USD (or use amount_callback)
105
- amount_callback: Callable that returns dynamic amount based on request
106
- networks: Limit to specific networks (default: all enabled)
107
- message: Custom message for 402 response
108
- inject_result: Whether to inject PaymentResult as 'payment_result' kwarg
109
- header_name: Header name for payment (default: X-PAYMENT)
110
-
111
- Returns:
112
- Decorated function
113
-
114
- Example (Flask):
115
- >>> @app.route("/api/premium")
116
- >>> @require_payment(amount_usd=Decimal("1.00"))
117
- >>> def premium_endpoint(payment_result=None):
118
- ... return {"message": f"Paid by {payment_result.payer_address}"}
119
-
120
- Example (Dynamic pricing):
121
- >>> def calculate_price(request):
122
- ... items = request.json.get("items", 1)
123
- ... return Decimal(str(items * 0.10))
124
- >>>
125
- >>> @require_payment(amount_callback=calculate_price)
126
- >>> def dynamic_endpoint(payment_result=None):
127
- ... return {"message": "Success"}
128
- """
129
-
130
- def decorator(func: F) -> F:
131
- @wraps(func)
132
- def wrapper(*args: Any, **kwargs: Any) -> Any:
133
- # Get client and config
134
- client = get_x402_client()
135
- config = get_x402_config()
136
-
137
- # Framework-agnostic request extraction
138
- # This will be overridden by framework-specific integrations
139
- request = _extract_request(*args, **kwargs)
140
- payment_header = _get_header(request, header_name)
141
-
142
- # Determine amount
143
- if amount_callback:
144
- required_amount = Decimal(str(amount_callback(request)))
145
- else:
146
- required_amount = Decimal(str(amount_usd))
147
-
148
- # No payment header - return 402
149
- if not payment_header:
150
- response_body = create_402_response(
151
- amount_usd=required_amount,
152
- config=config,
153
- message=message,
154
- )
155
- return _create_402_response(response_body)
156
-
157
- # Process payment
158
- try:
159
- result = client.process_payment(
160
- x_payment_header=payment_header,
161
- expected_amount_usd=required_amount,
162
- )
163
-
164
- # Inject result if requested
165
- if inject_result:
166
- kwargs["payment_result"] = result
167
-
168
- return func(*args, **kwargs)
169
-
170
- except X402Error as e:
171
- # Return appropriate error response
172
- return _create_error_response(e, required_amount, config)
173
-
174
- return wrapper # type: ignore
175
-
176
- return decorator
177
-
178
-
179
- # Alias for backward compatibility and preference
180
- x402_required = require_payment
181
-
182
-
183
- def _extract_request(*args: Any, **kwargs: Any) -> Any:
184
- """
185
- Extract request object from function arguments.
186
-
187
- This is a framework-agnostic implementation that works with:
188
- - Flask (request from flask.globals)
189
- - FastAPI/Starlette (request in kwargs)
190
- - Django (request as first arg)
191
- - AWS Lambda (event dict as first arg)
192
- """
193
- # Check kwargs first
194
- if "request" in kwargs:
195
- return kwargs["request"]
196
-
197
- # Check first positional arg
198
- if args:
199
- return args[0]
200
-
201
- # Try Flask global
202
- try:
203
- from flask import request
204
- return request
205
- except ImportError:
206
- pass
207
-
208
- # No request found
209
- return None
210
-
211
-
212
- def _get_header(request: Any, header_name: str) -> Optional[str]:
213
- """
214
- Get header value from request object.
215
-
216
- Handles different frameworks' request objects.
217
- """
218
- if request is None:
219
- return None
220
-
221
- # Dict-like (AWS Lambda event)
222
- if isinstance(request, dict):
223
- headers = request.get("headers", {})
224
- # Lambda headers can be case-sensitive or not
225
- return headers.get(header_name) or headers.get(header_name.lower())
226
-
227
- # Flask/Werkzeug
228
- if hasattr(request, "headers"):
229
- headers = request.headers
230
- if hasattr(headers, "get"):
231
- return headers.get(header_name)
232
-
233
- # Django
234
- if hasattr(request, "META"):
235
- # Django uses HTTP_X_PAYMENT format
236
- django_header = f"HTTP_{header_name.replace('-', '_').upper()}"
237
- return request.META.get(django_header)
238
-
239
- return None
240
-
241
-
242
- def _create_402_response(body: Dict[str, Any]) -> Any:
243
- """
244
- Create a 402 response appropriate for the current framework.
245
-
246
- This is a fallback that returns a tuple. Framework-specific
247
- integrations override this.
248
- """
249
- # Try Flask
250
- try:
251
- from flask import jsonify, make_response
252
- response = make_response(jsonify(body), 402)
253
- for key, value in create_402_headers().items():
254
- response.headers[key] = value
255
- return response
256
- except ImportError:
257
- pass
258
-
259
- # Try FastAPI/Starlette
260
- try:
261
- from starlette.responses import JSONResponse
262
- return JSONResponse(
263
- status_code=402,
264
- content=body,
265
- headers=create_402_headers(),
266
- )
267
- except ImportError:
268
- pass
269
-
270
- # Try Django
271
- try:
272
- from django.http import JsonResponse
273
- response = JsonResponse(body, status=402)
274
- for key, value in create_402_headers().items():
275
- response[key] = value
276
- return response
277
- except ImportError:
278
- pass
279
-
280
- # Fallback: return dict (for AWS Lambda or raw WSGI)
281
- return {
282
- "statusCode": 402,
283
- "headers": {
284
- "Content-Type": "application/json",
285
- **create_402_headers(),
286
- },
287
- "body": body,
288
- }
289
-
290
-
291
- def _create_error_response(
292
- error: X402Error,
293
- amount: Decimal,
294
- config: X402Config,
295
- ) -> Any:
296
- """Create an error response for x402 errors."""
297
- # Payment verification/settlement failed - return 402
298
- if isinstance(error, (PaymentRequiredError, InvalidPayloadError)):
299
- body = create_402_response(amount_usd=amount, config=config)
300
- body["error"] = error.message
301
- body["details"] = error.details
302
- return _create_402_response(body)
303
-
304
- # Other errors - return 400
305
- body = error.to_dict()
306
-
307
- try:
308
- from flask import jsonify, make_response
309
- return make_response(jsonify(body), 400)
310
- except ImportError:
311
- pass
312
-
313
- try:
314
- from starlette.responses import JSONResponse
315
- return JSONResponse(status_code=400, content=body)
316
- except ImportError:
317
- pass
318
-
319
- try:
320
- from django.http import JsonResponse
321
- return JsonResponse(body, status=400)
322
- except ImportError:
323
- pass
324
-
325
- return {"statusCode": 400, "body": body}
1
+ """
2
+ Decorators for protecting endpoints with x402 payments.
3
+
4
+ These decorators provide a clean, declarative way to require payment
5
+ for specific endpoints across different web frameworks.
6
+ """
7
+
8
+ from decimal import Decimal
9
+ from functools import wraps
10
+ from typing import Any, Callable, Optional, TypeVar, Union, Dict
11
+
12
+ from uvd_x402_sdk.config import X402Config
13
+ from uvd_x402_sdk.client import X402Client
14
+ from uvd_x402_sdk.exceptions import (
15
+ X402Error,
16
+ PaymentRequiredError,
17
+ InvalidPayloadError,
18
+ )
19
+ from uvd_x402_sdk.response import create_402_response, create_402_headers
20
+
21
+ F = TypeVar("F", bound=Callable[..., Any])
22
+
23
+ # Global client instance (set by configure_x402)
24
+ _global_client: Optional[X402Client] = None
25
+ _global_config: Optional[X402Config] = None
26
+
27
+
28
+ def configure_x402(
29
+ config: Optional[X402Config] = None,
30
+ recipient_address: Optional[str] = None,
31
+ facilitator_url: str = "https://facilitator.ultravioletadao.xyz",
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ """
35
+ Configure the global x402 client for decorator usage.
36
+
37
+ Call this once during application startup to set up the x402 client
38
+ that decorators will use.
39
+
40
+ Args:
41
+ config: Full X402Config object
42
+ recipient_address: Default recipient for EVM chains
43
+ facilitator_url: Facilitator service URL
44
+ **kwargs: Additional config parameters
45
+
46
+ Example:
47
+ >>> from uvd_x402_sdk import configure_x402
48
+ >>> configure_x402(
49
+ ... recipient_address="0xYourWallet...",
50
+ ... facilitator_url="https://facilitator.ultravioletadao.xyz"
51
+ ... )
52
+ """
53
+ global _global_client, _global_config
54
+
55
+ if config:
56
+ _global_config = config
57
+ else:
58
+ _global_config = X402Config(
59
+ facilitator_url=facilitator_url,
60
+ recipient_evm=recipient_address or "",
61
+ **kwargs,
62
+ )
63
+
64
+ _global_client = X402Client(config=_global_config)
65
+
66
+
67
+ def get_x402_client() -> X402Client:
68
+ """Get the global x402 client instance."""
69
+ if _global_client is None:
70
+ raise RuntimeError(
71
+ "x402 not configured. Call configure_x402() during application startup."
72
+ )
73
+ return _global_client
74
+
75
+
76
+ def get_x402_config() -> X402Config:
77
+ """Get the global x402 config instance."""
78
+ if _global_config is None:
79
+ raise RuntimeError(
80
+ "x402 not configured. Call configure_x402() during application startup."
81
+ )
82
+ return _global_config
83
+
84
+
85
+ def require_payment(
86
+ amount_usd: Union[Decimal, float, str],
87
+ amount_callback: Optional[Callable[..., Decimal]] = None,
88
+ networks: Optional[list] = None,
89
+ message: Optional[str] = None,
90
+ inject_result: bool = True,
91
+ header_name: str = "X-PAYMENT",
92
+ ) -> Callable[[F], F]:
93
+ """
94
+ Decorator that requires x402 payment for an endpoint.
95
+
96
+ This is the main decorator for protecting endpoints with payments.
97
+ It handles the complete payment flow:
98
+ 1. Check for X-PAYMENT header
99
+ 2. If missing, return 402 Payment Required
100
+ 3. If present, verify and settle payment
101
+ 4. Optionally inject PaymentResult into function
102
+
103
+ Args:
104
+ amount_usd: Fixed payment amount in USD (or use amount_callback)
105
+ amount_callback: Callable that returns dynamic amount based on request
106
+ networks: Limit to specific networks (default: all enabled)
107
+ message: Custom message for 402 response
108
+ inject_result: Whether to inject PaymentResult as 'payment_result' kwarg
109
+ header_name: Header name for payment (default: X-PAYMENT)
110
+
111
+ Returns:
112
+ Decorated function
113
+
114
+ Example (Flask):
115
+ >>> @app.route("/api/premium")
116
+ >>> @require_payment(amount_usd=Decimal("1.00"))
117
+ >>> def premium_endpoint(payment_result=None):
118
+ ... return {"message": f"Paid by {payment_result.payer_address}"}
119
+
120
+ Example (Dynamic pricing):
121
+ >>> def calculate_price(request):
122
+ ... items = request.json.get("items", 1)
123
+ ... return Decimal(str(items * 0.10))
124
+ >>>
125
+ >>> @require_payment(amount_callback=calculate_price)
126
+ >>> def dynamic_endpoint(payment_result=None):
127
+ ... return {"message": "Success"}
128
+ """
129
+
130
+ def decorator(func: F) -> F:
131
+ @wraps(func)
132
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
133
+ # Get client and config
134
+ client = get_x402_client()
135
+ config = get_x402_config()
136
+
137
+ # Framework-agnostic request extraction
138
+ # This will be overridden by framework-specific integrations
139
+ request = _extract_request(*args, **kwargs)
140
+ payment_header = _get_header(request, header_name)
141
+
142
+ # Determine amount
143
+ if amount_callback:
144
+ required_amount = Decimal(str(amount_callback(request)))
145
+ else:
146
+ required_amount = Decimal(str(amount_usd))
147
+
148
+ # No payment header - return 402
149
+ if not payment_header:
150
+ response_body = create_402_response(
151
+ amount_usd=required_amount,
152
+ config=config,
153
+ message=message,
154
+ )
155
+ return _create_402_response(response_body)
156
+
157
+ # Process payment
158
+ try:
159
+ result = client.process_payment(
160
+ x_payment_header=payment_header,
161
+ expected_amount_usd=required_amount,
162
+ )
163
+
164
+ # Inject result if requested
165
+ if inject_result:
166
+ kwargs["payment_result"] = result
167
+
168
+ return func(*args, **kwargs)
169
+
170
+ except X402Error as e:
171
+ # Return appropriate error response
172
+ return _create_error_response(e, required_amount, config)
173
+
174
+ return wrapper # type: ignore
175
+
176
+ return decorator
177
+
178
+
179
+ # Alias for backward compatibility and preference
180
+ x402_required = require_payment
181
+
182
+
183
+ def _extract_request(*args: Any, **kwargs: Any) -> Any:
184
+ """
185
+ Extract request object from function arguments.
186
+
187
+ This is a framework-agnostic implementation that works with:
188
+ - Flask (request from flask.globals)
189
+ - FastAPI/Starlette (request in kwargs)
190
+ - Django (request as first arg)
191
+ - AWS Lambda (event dict as first arg)
192
+ """
193
+ # Check kwargs first
194
+ if "request" in kwargs:
195
+ return kwargs["request"]
196
+
197
+ # Check first positional arg
198
+ if args:
199
+ return args[0]
200
+
201
+ # Try Flask global
202
+ try:
203
+ from flask import request
204
+ return request
205
+ except ImportError:
206
+ pass
207
+
208
+ # No request found
209
+ return None
210
+
211
+
212
+ def _get_header(request: Any, header_name: str) -> Optional[str]:
213
+ """
214
+ Get header value from request object.
215
+
216
+ Handles different frameworks' request objects.
217
+ """
218
+ if request is None:
219
+ return None
220
+
221
+ # Dict-like (AWS Lambda event)
222
+ if isinstance(request, dict):
223
+ headers = request.get("headers", {})
224
+ # Lambda headers can be case-sensitive or not
225
+ return headers.get(header_name) or headers.get(header_name.lower())
226
+
227
+ # Flask/Werkzeug
228
+ if hasattr(request, "headers"):
229
+ headers = request.headers
230
+ if hasattr(headers, "get"):
231
+ return headers.get(header_name)
232
+
233
+ # Django
234
+ if hasattr(request, "META"):
235
+ # Django uses HTTP_X_PAYMENT format
236
+ django_header = f"HTTP_{header_name.replace('-', '_').upper()}"
237
+ return request.META.get(django_header)
238
+
239
+ return None
240
+
241
+
242
+ def _create_402_response(body: Dict[str, Any]) -> Any:
243
+ """
244
+ Create a 402 response appropriate for the current framework.
245
+
246
+ This is a fallback that returns a tuple. Framework-specific
247
+ integrations override this.
248
+ """
249
+ # Try Flask
250
+ try:
251
+ from flask import jsonify, make_response
252
+ response = make_response(jsonify(body), 402)
253
+ for key, value in create_402_headers().items():
254
+ response.headers[key] = value
255
+ return response
256
+ except ImportError:
257
+ pass
258
+
259
+ # Try FastAPI/Starlette
260
+ try:
261
+ from starlette.responses import JSONResponse
262
+ return JSONResponse(
263
+ status_code=402,
264
+ content=body,
265
+ headers=create_402_headers(),
266
+ )
267
+ except ImportError:
268
+ pass
269
+
270
+ # Try Django
271
+ try:
272
+ from django.http import JsonResponse
273
+ response = JsonResponse(body, status=402)
274
+ for key, value in create_402_headers().items():
275
+ response[key] = value
276
+ return response
277
+ except ImportError:
278
+ pass
279
+
280
+ # Fallback: return dict (for AWS Lambda or raw WSGI)
281
+ return {
282
+ "statusCode": 402,
283
+ "headers": {
284
+ "Content-Type": "application/json",
285
+ **create_402_headers(),
286
+ },
287
+ "body": body,
288
+ }
289
+
290
+
291
+ def _create_error_response(
292
+ error: X402Error,
293
+ amount: Decimal,
294
+ config: X402Config,
295
+ ) -> Any:
296
+ """Create an error response for x402 errors."""
297
+ # Payment verification/settlement failed - return 402
298
+ if isinstance(error, (PaymentRequiredError, InvalidPayloadError)):
299
+ body = create_402_response(amount_usd=amount, config=config)
300
+ body["error"] = error.message
301
+ body["details"] = error.details
302
+ return _create_402_response(body)
303
+
304
+ # Other errors - return 400
305
+ body = error.to_dict()
306
+
307
+ try:
308
+ from flask import jsonify, make_response
309
+ return make_response(jsonify(body), 400)
310
+ except ImportError:
311
+ pass
312
+
313
+ try:
314
+ from starlette.responses import JSONResponse
315
+ return JSONResponse(status_code=400, content=body)
316
+ except ImportError:
317
+ pass
318
+
319
+ try:
320
+ from django.http import JsonResponse
321
+ return JsonResponse(body, status=400)
322
+ except ImportError:
323
+ pass
324
+
325
+ return {"statusCode": 400, "body": body}