traia-iatp 0.1.2__py3-none-any.whl → 0.1.67__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 (95) hide show
  1. traia_iatp/__init__.py +105 -8
  2. traia_iatp/cli/main.py +85 -1
  3. traia_iatp/client/__init__.py +28 -3
  4. traia_iatp/client/crewai_a2a_tools.py +32 -12
  5. traia_iatp/client/d402_a2a_client.py +348 -0
  6. traia_iatp/contracts/__init__.py +11 -0
  7. traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
  8. traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
  9. traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
  10. traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
  11. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  12. traia_iatp/contracts/wallet_creator.py +369 -0
  13. traia_iatp/core/models.py +17 -3
  14. traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
  15. traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
  16. traia_iatp/d402/README.md +489 -0
  17. traia_iatp/d402/__init__.py +54 -0
  18. traia_iatp/d402/asgi_wrapper.py +469 -0
  19. traia_iatp/d402/chains.py +102 -0
  20. traia_iatp/d402/client.py +150 -0
  21. traia_iatp/d402/clients/__init__.py +7 -0
  22. traia_iatp/d402/clients/base.py +218 -0
  23. traia_iatp/d402/clients/httpx.py +266 -0
  24. traia_iatp/d402/common.py +114 -0
  25. traia_iatp/d402/encoding.py +28 -0
  26. traia_iatp/d402/examples/client_example.py +197 -0
  27. traia_iatp/d402/examples/server_example.py +171 -0
  28. traia_iatp/d402/facilitator.py +481 -0
  29. traia_iatp/d402/mcp_middleware.py +296 -0
  30. traia_iatp/d402/models.py +116 -0
  31. traia_iatp/d402/networks.py +98 -0
  32. traia_iatp/d402/path.py +43 -0
  33. traia_iatp/d402/payment_introspection.py +126 -0
  34. traia_iatp/d402/payment_signing.py +183 -0
  35. traia_iatp/d402/price_builder.py +164 -0
  36. traia_iatp/d402/servers/__init__.py +61 -0
  37. traia_iatp/d402/servers/base.py +139 -0
  38. traia_iatp/d402/servers/example_general_server.py +140 -0
  39. traia_iatp/d402/servers/fastapi.py +253 -0
  40. traia_iatp/d402/servers/mcp.py +304 -0
  41. traia_iatp/d402/servers/starlette.py +878 -0
  42. traia_iatp/d402/starlette_middleware.py +529 -0
  43. traia_iatp/d402/types.py +300 -0
  44. traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
  45. traia_iatp/mcp/__init__.py +3 -0
  46. traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
  47. traia_iatp/mcp/mcp_agent_template.py +78 -13
  48. traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
  49. traia_iatp/mcp/templates/README.md.j2 +104 -8
  50. traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
  51. traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
  52. traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
  53. traia_iatp/mcp/templates/env.example.j2 +60 -0
  54. traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
  55. traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
  56. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  57. traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
  58. traia_iatp/mcp/templates/server.py.j2 +174 -197
  59. traia_iatp/mcp/traia_mcp_adapter.py +182 -20
  60. traia_iatp/registry/__init__.py +47 -12
  61. traia_iatp/registry/atlas_search_indexes.json +108 -54
  62. traia_iatp/registry/iatp_search_api.py +169 -39
  63. traia_iatp/registry/mongodb_registry.py +241 -69
  64. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
  65. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
  66. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
  67. traia_iatp/registry/readmes/README.md +3 -3
  68. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
  69. traia_iatp/scripts/__init__.py +2 -0
  70. traia_iatp/scripts/create_wallet.py +244 -0
  71. traia_iatp/server/a2a_server.py +22 -7
  72. traia_iatp/server/iatp_server_template_generator.py +23 -0
  73. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  74. traia_iatp/server/templates/Dockerfile.j2 +23 -1
  75. traia_iatp/server/templates/README.md +2 -2
  76. traia_iatp/server/templates/README.md.j2 +5 -5
  77. traia_iatp/server/templates/__main__.py.j2 +374 -66
  78. traia_iatp/server/templates/agent.py.j2 +12 -11
  79. traia_iatp/server/templates/agent_config.json.j2 +3 -3
  80. traia_iatp/server/templates/agent_executor.py.j2 +45 -27
  81. traia_iatp/server/templates/env.example.j2 +32 -4
  82. traia_iatp/server/templates/gitignore.j2 +7 -0
  83. traia_iatp/server/templates/pyproject.toml.j2 +13 -12
  84. traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
  85. traia_iatp/server/templates/server.py.j2 +197 -10
  86. traia_iatp/special_agencies/registry_search_agency.py +1 -1
  87. traia_iatp/utils/iatp_utils.py +6 -6
  88. traia_iatp-0.1.67.dist-info/METADATA +320 -0
  89. traia_iatp-0.1.67.dist-info/RECORD +117 -0
  90. traia_iatp-0.1.2.dist-info/METADATA +0 -414
  91. traia_iatp-0.1.2.dist-info/RECORD +0 -72
  92. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
  93. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
  94. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
  95. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,878 @@
1
+ """
2
+ Starlette middleware for d402 payment protocol.
3
+
4
+ This middleware works with Starlette apps (including FastMCP's streamable_http_app())
5
+ to provide HTTP 402 payment support and authentication.
6
+
7
+ Can be used for:
8
+ - Starlette applications
9
+ - FastAPI applications (FastAPI is built on Starlette)
10
+ - FastMCP servers (FastMCP uses Starlette for HTTP transport)
11
+ - A2A servers (Agent-to-Agent protocol)
12
+ - Any ASGI application using Starlette middleware
13
+
14
+ Pattern:
15
+ 1. Use @require_payment decorator to mark endpoints
16
+ 2. Extract payment configs with extract_payment_configs()
17
+ 3. Add middleware with extracted configs
18
+ """
19
+
20
+ import logging
21
+ import json
22
+ import os
23
+ from typing import Dict, Any, Optional, Callable
24
+ from functools import wraps
25
+
26
+ from starlette.middleware.base import BaseHTTPMiddleware
27
+ from starlette.requests import Request
28
+ from starlette.responses import JSONResponse
29
+
30
+ from ..types import PaymentRequirements, d402PaymentRequiredResponse, PaymentPayload, TokenAmount
31
+ from ..common import d402_VERSION
32
+ from ..facilitator import IATPSettlementFacilitator
33
+ from ..encoding import safe_base64_decode
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ============================================================================
39
+ # URL EXTRACTION HELPERS
40
+ # ============================================================================
41
+
42
+ def extract_server_url_from_request(request: Request) -> str:
43
+ """
44
+ Extract the server's own URL from the HTTP request headers.
45
+
46
+ This is used for tracking in the facilitator which server a payment came from.
47
+ Works for both local and remote (Cloud Run) deployments.
48
+
49
+ Args:
50
+ request: Starlette Request object
51
+
52
+ Returns:
53
+ Full server URL (e.g., 'https://my-server.cloudrun.app' or 'http://localhost:8000')
54
+ """
55
+ # Check X-Forwarded headers first (set by proxies/load balancers like Cloud Run)
56
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
57
+ forwarded_host = request.headers.get("X-Forwarded-Host")
58
+
59
+ if forwarded_host:
60
+ # Cloud Run / proxy scenario
61
+ server_url = f"{forwarded_proto}://{forwarded_host}"
62
+ logger.debug(f"Server URL from X-Forwarded headers: {server_url}")
63
+ return server_url
64
+
65
+ # Fallback to Host header (for local/direct access)
66
+ host = request.headers.get("Host", "localhost:8000")
67
+
68
+ # Determine protocol (https if forwarded, otherwise check if local)
69
+ if "localhost" in host or "127.0.0.1" in host or "host.docker.internal" in host:
70
+ proto = "http"
71
+ else:
72
+ # Remote host without X-Forwarded-Proto, assume https
73
+ proto = "https"
74
+
75
+ server_url = f"{proto}://{host}"
76
+ logger.debug(f"Server URL from Host header: {server_url}")
77
+ return server_url
78
+
79
+
80
+ # ============================================================================
81
+ # DECORATORS - Generic payment requirement markers
82
+ # ============================================================================
83
+
84
+ def require_payment(
85
+ price: TokenAmount,
86
+ endpoint_path: str = "",
87
+ description: str = ""
88
+ ) -> Callable:
89
+ """
90
+ Decorator to mark an endpoint as requiring D402 payment.
91
+
92
+ Works with any function/endpoint - not specific to MCP or A2A.
93
+
94
+ Usage with FastAPI/Starlette:
95
+ @app.post("/analyze")
96
+ @require_payment(
97
+ price=TokenAmount(
98
+ amount="10000",
99
+ asset=TokenAsset(address="0x...", decimals=6, network="sepolia")
100
+ ),
101
+ endpoint_path="/analyze",
102
+ description="Analyze text"
103
+ )
104
+ async def analyze(request: Request):
105
+ ...
106
+
107
+ Usage with A2A handlers:
108
+ @require_payment(
109
+ price=TokenAmount(...),
110
+ endpoint_path="/",
111
+ description="A2A JSON-RPC request"
112
+ )
113
+ async def handle_a2a_request(request: Request):
114
+ ...
115
+
116
+ Args:
117
+ price: TokenAmount specifying the payment amount and token
118
+ endpoint_path: The endpoint path (e.g., "/analyze", "/"). Auto-detected if empty.
119
+ description: Human-readable description of what the endpoint does
120
+
121
+ Returns:
122
+ Decorator function that adds payment metadata to the function
123
+ """
124
+ def decorator(func: Callable) -> Callable:
125
+ # Store payment metadata on the function
126
+ if not hasattr(func, '_d402_payment_info'):
127
+ func._d402_payment_info = {}
128
+
129
+ func._d402_payment_info = {
130
+ 'price': price,
131
+ 'endpoint_path': endpoint_path,
132
+ 'description': description,
133
+ 'requires_payment': True
134
+ }
135
+
136
+ @wraps(func)
137
+ async def wrapper(*args, **kwargs):
138
+ return await func(*args, **kwargs)
139
+
140
+ # Preserve metadata on wrapper
141
+ wrapper._d402_payment_info = func._d402_payment_info
142
+
143
+ return wrapper
144
+
145
+ return decorator
146
+
147
+
148
+ def extract_payment_configs(
149
+ app: Any,
150
+ server_address: str,
151
+ endpoint_mapping: Optional[Dict[str, str]] = None
152
+ ) -> Dict[str, Dict[str, Any]]:
153
+ """
154
+ Extract payment configs from decorated endpoints in any app.
155
+
156
+ Works with:
157
+ - FastAPI apps
158
+ - Starlette apps
159
+ - A2A handlers
160
+ - Custom routers
161
+
162
+ Args:
163
+ app: The application or handler object to extract from
164
+ server_address: Payment destination address
165
+ endpoint_mapping: Optional mapping of function names to endpoint paths
166
+ (useful when path can't be auto-detected)
167
+
168
+ Returns:
169
+ Dict mapping endpoint paths to payment configs
170
+
171
+ Example:
172
+ # Extract from FastAPI app
173
+ configs = extract_payment_configs(app, SERVER_ADDRESS)
174
+
175
+ # Extract from A2A handlers (with manual mapping)
176
+ configs = extract_payment_configs(
177
+ a2a_handlers,
178
+ SERVER_ADDRESS,
179
+ endpoint_mapping={"handle_request": "/"}
180
+ )
181
+ """
182
+ configs = {}
183
+ endpoint_mapping = endpoint_mapping or {}
184
+
185
+ # Try to extract from FastAPI/Starlette routes
186
+ if hasattr(app, 'routes'):
187
+ for route in app.routes:
188
+ if hasattr(route, 'endpoint'):
189
+ endpoint_func = route.endpoint
190
+ if hasattr(endpoint_func, '_d402_payment_info'):
191
+ info = endpoint_func._d402_payment_info
192
+ path = info.get('endpoint_path') or getattr(route, 'path', '/')
193
+
194
+ configs[path] = _build_payment_config(
195
+ price=info['price'],
196
+ server_address=server_address,
197
+ description=info.get('description', f"Request to {path}")
198
+ )
199
+
200
+ # Try to extract from dict/module of handlers
201
+ elif hasattr(app, '__dict__'):
202
+ for name, func in app.__dict__.items():
203
+ if hasattr(func, '_d402_payment_info'):
204
+ info = func._d402_payment_info
205
+ path = info.get('endpoint_path') or endpoint_mapping.get(name, f"/{name}")
206
+
207
+ configs[path] = build_payment_config(
208
+ price=info['price'],
209
+ server_address=server_address,
210
+ description=info.get('description', f"Request to {path}")
211
+ )
212
+
213
+ # Handle direct function (single handler case)
214
+ elif hasattr(app, '_d402_payment_info'):
215
+ info = app._d402_payment_info
216
+ path = info.get('endpoint_path') or endpoint_mapping.get('default', '/')
217
+
218
+ configs[path] = build_payment_config(
219
+ price=info['price'],
220
+ server_address=server_address,
221
+ description=info.get('description', f"Request to {path}")
222
+ )
223
+
224
+ return configs
225
+
226
+
227
+ def build_payment_config(
228
+ price: TokenAmount,
229
+ server_address: str,
230
+ description: str
231
+ ) -> Dict[str, Any]:
232
+ """
233
+ Build payment config dict from TokenAmount.
234
+
235
+ Converts a TokenAmount object into the standard payment config format
236
+ used by D402PaymentMiddleware.
237
+
238
+ Args:
239
+ price: TokenAmount with payment amount and token details
240
+ server_address: Payment destination address
241
+ description: Human-readable description of the endpoint
242
+
243
+ Returns:
244
+ Dict with payment configuration
245
+
246
+ Example:
247
+ from traia_iatp.d402 import D402PriceBuilder
248
+ from traia_iatp.d402.servers import build_payment_config
249
+
250
+ builder = D402PriceBuilder(...)
251
+ price = builder.create_price(0.01)
252
+
253
+ config = build_payment_config(
254
+ price=price,
255
+ server_address="0x...",
256
+ description="API request"
257
+ )
258
+ # Returns: {"price_wei": "10000", "token_address": "0x...", ...}
259
+ """
260
+ return {
261
+ "price_wei": price.amount,
262
+ "price_float": float(price.amount) / (10 ** price.asset.decimals),
263
+ "token_address": price.asset.address,
264
+ "token_symbol": getattr(price.asset, 'symbol', 'TOKEN'),
265
+ "token_decimals": price.asset.decimals,
266
+ "network": price.asset.network,
267
+ "server_address": server_address,
268
+ "description": description,
269
+ "eip712_domain": {
270
+ "name": price.asset.eip712.name,
271
+ "version": price.asset.eip712.version
272
+ }
273
+ }
274
+
275
+
276
+ # ============================================================================
277
+ # MIDDLEWARE - D402 Payment Enforcement
278
+ # ============================================================================
279
+
280
+
281
+ class D402PaymentMiddleware(BaseHTTPMiddleware):
282
+ """
283
+ Universal D402 payment middleware for any Starlette-based server.
284
+
285
+ This middleware:
286
+ 1. Extracts API key if present → stores in request.state
287
+ 2. Checks if endpoint requires payment
288
+ 3. Returns HTTP 402 if no payment (and no API key if auth enabled)
289
+ 4. Validates payment with facilitator
290
+ 5. Settles payment after successful response
291
+
292
+ Supports:
293
+ - MCP servers (POST /mcp with tools/call)
294
+ - A2A servers (POST / with JSON-RPC)
295
+ - FastAPI/Starlette servers (any POST endpoint)
296
+ - Multiple endpoints with different prices
297
+
298
+ Usage Pattern 1 - MCP Servers (with decorators):
299
+ from traia_iatp.d402.starlette_middleware import D402PaymentMiddleware
300
+ from traia_iatp.d402.payment_introspection import extract_payment_configs_from_mcp
301
+
302
+ configs = extract_payment_configs_from_mcp(mcp, SERVER_ADDRESS)
303
+ app.add_middleware(
304
+ D402PaymentMiddleware,
305
+ server_address=SERVER_ADDRESS,
306
+ tool_payment_configs=configs,
307
+ requires_auth=True,
308
+ internal_api_key=API_KEY
309
+ )
310
+
311
+ Usage Pattern 2 - A2A Servers (manual config):
312
+ from traia_iatp.d402.servers import D402PaymentMiddleware
313
+ from traia_iatp.d402.servers.starlette import _build_payment_config
314
+
315
+ configs = {
316
+ "/": _build_payment_config(
317
+ price=TokenAmount(...),
318
+ server_address=SERVER_ADDRESS,
319
+ description="A2A request"
320
+ )
321
+ }
322
+
323
+ app.add_middleware(
324
+ D402PaymentMiddleware,
325
+ server_address=SERVER_ADDRESS,
326
+ tool_payment_configs=configs,
327
+ requires_auth=False # Payment only
328
+ )
329
+
330
+ Usage Pattern 3 - General Servers (with decorators - future):
331
+ from traia_iatp.d402.servers import (
332
+ D402PaymentMiddleware,
333
+ require_payment,
334
+ extract_payment_configs
335
+ )
336
+
337
+ @app.post("/analyze")
338
+ @require_payment(price=TokenAmount(...), description="Analysis")
339
+ async def analyze(): pass
340
+
341
+ configs = extract_payment_configs(app, SERVER_ADDRESS)
342
+ app.add_middleware(
343
+ D402PaymentMiddleware,
344
+ server_address=SERVER_ADDRESS,
345
+ tool_payment_configs=configs
346
+ )
347
+ """
348
+
349
+ def __init__(
350
+ self,
351
+ app,
352
+ server_address: str,
353
+ # Payment configs (from decorators or manual)
354
+ tool_payment_configs: Dict[str, Dict[str, Any]],
355
+ # Auth and facilitator config
356
+ requires_auth: bool = False,
357
+ internal_api_key: Optional[str] = None,
358
+ testing_mode: bool = False,
359
+ facilitator_url: Optional[str] = None,
360
+ facilitator_api_key: Optional[str] = None,
361
+ server_name: Optional[str] = None
362
+ ):
363
+ """
364
+ Initialize D402 payment middleware.
365
+
366
+ Args:
367
+ app: Starlette/FastAPI app
368
+ server_address: Payment destination address
369
+ tool_payment_configs: Dict mapping endpoint paths to payment configs.
370
+ Use extract_payment_configs() to generate from decorators.
371
+ requires_auth: If True, accepts API key OR payment. If False, payment only.
372
+ internal_api_key: Server's API key (used when client pays in payment mode)
373
+ testing_mode: If True, skips facilitator (for local testing)
374
+ facilitator_url: Facilitator service URL
375
+ facilitator_api_key: API key for facilitator
376
+ server_name: Server identifier for logging
377
+
378
+ Example:
379
+ # Option 1: Extract from decorators (recommended)
380
+ configs = extract_payment_configs(app, SERVER_ADDRESS)
381
+ app.add_middleware(
382
+ D402PaymentMiddleware,
383
+ server_address=SERVER_ADDRESS,
384
+ tool_payment_configs=configs
385
+ )
386
+
387
+ # Option 2: Manual configs (MCP servers)
388
+ configs = extract_payment_configs_from_mcp(mcp, SERVER_ADDRESS)
389
+ app.add_middleware(
390
+ D402PaymentMiddleware,
391
+ server_address=SERVER_ADDRESS,
392
+ tool_payment_configs=configs
393
+ )
394
+ """
395
+ super().__init__(app)
396
+
397
+ self.tool_payment_configs = tool_payment_configs or {}
398
+ self.server_address = server_address
399
+ self.requires_auth = requires_auth
400
+ self.internal_api_key = internal_api_key # Server's internal API key
401
+ self.testing_mode = testing_mode or os.getenv("D402_TESTING_MODE", "false").lower() == "true"
402
+
403
+ # Initialize facilitator for payment verification and settlement
404
+ self.facilitator = None
405
+ if not self.testing_mode:
406
+ try:
407
+ # Try multiple operator key env var names (for different server types)
408
+ operator_key = (
409
+ os.getenv("UTILITY_AGENT_OPERATOR_PRIVATE_KEY") or # A2A utility agents
410
+ os.getenv("MCP_OPERATOR_PRIVATE_KEY") or # MCP servers
411
+ os.getenv("OPERATOR_PRIVATE_KEY") # Generic
412
+ )
413
+
414
+ # Get server name/ID from initialization or environment
415
+ server_name_var = server_name or os.getenv("MCP_SERVER_NAME", os.getenv("MCP_SERVER_ID", "unknown"))
416
+
417
+ # Note: server_url will be extracted from each request at runtime
418
+ # This is needed because Cloud Run URLs are not known until deployment
419
+ # and must be introspected from X-Forwarded-Host and X-Forwarded-Proto headers
420
+
421
+ self.facilitator = IATPSettlementFacilitator(
422
+ facilitator_url=facilitator_url or os.getenv("D402_FACILITATOR_URL", "https://facilitator.d402.net"),
423
+ facilitator_api_key=facilitator_api_key or os.getenv("D402_FACILITATOR_API_KEY"),
424
+ provider_operator_key=operator_key,
425
+ server_name=server_name_var,
426
+ server_url=None # Will be set per-request from headers
427
+ )
428
+
429
+ # Store server name for later use
430
+ self.server_name = server_name_var
431
+ if operator_key:
432
+ logger.info(f" Facilitator initialized with operator key (settlement enabled)")
433
+ else:
434
+ logger.warning(f" No operator key - settlement disabled")
435
+ except Exception as e:
436
+ logger.warning(f" Could not initialize facilitator: {e}")
437
+ self.testing_mode = True
438
+
439
+ logger.info(f"D402PaymentMiddleware initialized:")
440
+ logger.info(f" Payment-enabled endpoints: {len(self.tool_payment_configs)}")
441
+ if self.tool_payment_configs:
442
+ logger.info(f" Protected paths: {list(self.tool_payment_configs.keys())}")
443
+ logger.info(f" Server address: {server_address}")
444
+ logger.info(f" Testing mode: {self.testing_mode}")
445
+ logger.info(f" Facilitator: {'Enabled' if self.facilitator else 'Disabled (testing)'}")
446
+
447
+ async def __call__(self, scope, receive, send):
448
+ """Override ASGI __call__ to add debug logging and ensure proper invocation."""
449
+ logger.info(f"🔍 D402PaymentMiddleware.__call__ invoked: {scope.get('type', 'unknown')} {scope.get('path', 'unknown')}")
450
+
451
+ # If not HTTP, pass through immediately
452
+ if scope["type"] != "http":
453
+ logger.debug(f"Non-HTTP scope type: {scope['type']}, passing through")
454
+ await self.app(scope, receive, send)
455
+ return
456
+
457
+ logger.info(f"🔍 HTTP request detected, calling parent BaseHTTPMiddleware.__call__")
458
+ # Call parent's __call__ which will invoke dispatch()
459
+ return await super().__call__(scope, receive, send)
460
+
461
+ async def dispatch(self, request: Request, call_next):
462
+ """
463
+ Intercept requests for auth and payment checking.
464
+
465
+ Handles both:
466
+ 1. If server requires auth: Extract API key and store in request.state
467
+ 2. If tool requires payment: Check payment or auth, return HTTP 402 if missing
468
+ """
469
+
470
+ # Debug: Confirm dispatch is being called
471
+ logger.info(f"🔍 D402 middleware dispatch called: {request.method} {request.url.path}")
472
+
473
+ # Step 0: Skip payment processing on URLs that will redirect (trailing slash)
474
+ # This prevents duplicate payments when /mcp/ redirects to /mcp
475
+ if request.url.path.endswith('/') and request.url.path != '/':
476
+ # This request will likely redirect, skip payment processing
477
+ logger.debug(f"Skipping payment processing for trailing slash URL: {request.url.path}")
478
+ return await call_next(request)
479
+
480
+ # Step 1: Store middleware reference for decorator access (for settlement)
481
+ request.state.d402_middleware = self
482
+
483
+ # Step 2: Extract and store API key if present (for all requests)
484
+ if self.requires_auth:
485
+ auth = request.headers.get("Authorization", "")
486
+ if auth.lower().startswith("bearer "):
487
+ token = auth[7:].strip()
488
+ request.state.api_key = token
489
+ request.state.authenticated = True
490
+ logger.debug(f"D402: API key stored: {token[:10]}...")
491
+ # Check for X-API-Key header (case-insensitive)
492
+ elif request.headers.get("x-api-key"): # Starlette headers are case-insensitive when using lowercase
493
+ token = request.headers.get("x-api-key")
494
+ request.state.api_key = token
495
+ request.state.authenticated = True
496
+ logger.debug(f"D402: X-API-Key stored: {token[:10]}...")
497
+ else:
498
+ request.state.api_key = None
499
+ request.state.authenticated = False
500
+
501
+ # Step 3: Check payment for API calls
502
+ # Only intercept POST requests
503
+ if request.method != "POST":
504
+ return await call_next(request)
505
+
506
+ # Check if this endpoint is protected
507
+ request_path = request.url.path.rstrip('/') or '/'
508
+ logger.debug(f"D402 middleware inspecting request path: {request_path}")
509
+
510
+ # Skip if no payment configs
511
+ if not self.tool_payment_configs:
512
+ return await call_next(request)
513
+
514
+ try:
515
+ # Read body to identify the request
516
+ body = await request.body()
517
+ data = json.loads(body)
518
+
519
+ # Determine endpoint type and extract identifier
520
+ tool_name = None
521
+ tool_path = request_path
522
+
523
+ # MCP Pattern: /mcp with tools/call method
524
+ if request_path == "/mcp" and data.get("method") == "tools/call":
525
+ tool_name = data.get("params", {}).get("name")
526
+ # For MCP servers: /mcp/tools/{tool_name}
527
+ tool_path = f"/mcp/tools/{tool_name}"
528
+
529
+ # A2A Pattern: / (root) with JSON-RPC methods
530
+ # A2A uses methods like "message/send", "tasks/get", etc.
531
+ elif request_path in self.tool_payment_configs:
532
+ # Direct endpoint match (e.g., "/" for A2A)
533
+ tool_name = request_path
534
+ tool_path = request_path
535
+
536
+ # Generic pattern: check if path matches any configured pattern
537
+ elif "*" in self.tool_payment_configs:
538
+ # Wildcard - protect all endpoints
539
+ tool_name = "*"
540
+ tool_path = request_path
541
+
542
+ # If no tool/endpoint identified, pass through
543
+ if not tool_name:
544
+ logger.debug(f"D402 middleware: no payment config match for path '{request_path}', skipping")
545
+ return await self._continue_with_body(request, body, call_next)
546
+
547
+ # Check if tool requires payment
548
+ if tool_name in self.tool_payment_configs:
549
+ logger.debug(f"D402 middleware: enforcing payment for tool '{tool_name}'")
550
+ # Mode 1: If server requires auth AND client has API key → FREE
551
+ if self.requires_auth and request.state.authenticated:
552
+ logger.info(f"✅ {tool_name}: Client authenticated with API key (Mode 1: Free)")
553
+ # Set api_key_to_use = client's key
554
+ request.state.api_key_to_use = request.state.api_key
555
+ # Continue to FastMCP
556
+ return await self._continue_with_body(request, body, call_next)
557
+
558
+ # Mode 2: Check payment → Client must pay, server uses internal API key
559
+ payment_header = request.headers.get("X-Payment")
560
+ if not payment_header:
561
+ logger.info(f"💰 {tool_name}: Payment required (Mode 2) - HTTP 402")
562
+ config = self.tool_payment_configs[tool_name]
563
+ return self._create_402_response(config, "Payment required", request_path=tool_path)
564
+ else:
565
+ # Payment header present - VALIDATE IT!
566
+ logger.info(f"💰 {tool_name}: Payment header RECEIVED - validating...")
567
+ logger.info(f"📦 Payment header length: {len(payment_header)} bytes")
568
+
569
+ # TODO: Add full payment validation:
570
+ # 1. Decode and parse payment header
571
+ # 2. Verify EIP-3009 signature
572
+ # 3. Check amount >= required
573
+ # 4. Verify pay_to == SERVER_ADDRESS
574
+ # 5. Check timestamp validity
575
+ # 6. Call facilitator.verify() if not testing mode
576
+
577
+ # For now: Basic validation in testing mode
578
+ try:
579
+ from ..encoding import safe_base64_decode
580
+ payment_data = safe_base64_decode(payment_header)
581
+ if not payment_data:
582
+ logger.error(f"❌ {tool_name}: Invalid payment encoding")
583
+ # Return 402 with error
584
+ config = self.tool_payment_configs[tool_name]
585
+ return self._create_402_response(config, "Invalid payment encoding", request_path=tool_path)
586
+
587
+ payment_dict = json.loads(payment_data)
588
+
589
+ # Basic validation: check structure
590
+ if not payment_dict.get("payload") or not payment_dict["payload"].get("authorization"):
591
+ logger.error(f"❌ {tool_name}: Invalid payment structure")
592
+ config = self.tool_payment_configs[tool_name]
593
+ return self._create_402_response(config, "Invalid payment structure", request_path=tool_path)
594
+
595
+ auth = payment_dict["payload"]["authorization"]
596
+
597
+ # Verify payment destination
598
+ if auth.get("to", "").lower() != self.server_address.lower():
599
+ logger.error(f"❌ {tool_name}: Payment to wrong address")
600
+ config = self.tool_payment_configs[tool_name]
601
+ return self._create_402_response(config, "Payment to wrong address", request_path=tool_path)
602
+
603
+ # Verify payment amount
604
+ config = self.tool_payment_configs[tool_name]
605
+ payment_amount = int(auth.get("value", 0))
606
+ required_amount = int(config["price_wei"])
607
+
608
+ if payment_amount < required_amount:
609
+ logger.error(f"❌ {tool_name}: Insufficient payment: {payment_amount} < {required_amount}")
610
+ return self._create_402_response(config, f"Insufficient payment: {payment_amount} < {required_amount}", request_path=tool_path)
611
+
612
+ # Call facilitator.verify() if available (production mode)
613
+ if self.facilitator and not self.testing_mode:
614
+ try:
615
+ # Create PaymentPayload for facilitator
616
+ payment_payload = PaymentPayload.model_validate(payment_dict)
617
+
618
+ # Create PaymentRequirements with full token info
619
+ # Include client information in extra field for facilitator tracking
620
+ extra_data = {
621
+ "client_url": request.headers.get("referer") or request.headers.get("origin"),
622
+ "client_ip": request.client.host if request.client else None,
623
+ "user_agent": request.headers.get("user-agent")
624
+ }
625
+
626
+ payment_reqs = PaymentRequirements(
627
+ scheme="exact",
628
+ network=config["network"],
629
+ pay_to=config["server_address"],
630
+ max_amount_required=config["price_wei"],
631
+ max_timeout_seconds=86400, # 24 hours for settlement window
632
+ description=config["description"],
633
+ resource=tool_path,
634
+ mime_type="application/json",
635
+ asset=config["token_address"],
636
+ extra=extra_data
637
+ )
638
+
639
+ # Extract server's own URL from request headers for tracking
640
+ server_url = extract_server_url_from_request(request)
641
+ logger.info(f"🌐 Server URL (introspected): {server_url}")
642
+
643
+ # Temporarily update facilitator's server_url for this request
644
+ original_server_url = self.facilitator.server_url
645
+ self.facilitator.server_url = server_url
646
+
647
+ # Verify with facilitator
648
+ logger.info(f"🔐 Verifying payment with facilitator...")
649
+ #==============================================================
650
+ verify_result = await self.facilitator.verify(payment_payload, payment_reqs)
651
+ #==============================================================
652
+
653
+ # Restore original server_url
654
+ self.facilitator.server_url = original_server_url
655
+
656
+ logger.info(f"🔐 Facilitator verify result: {verify_result}")
657
+ if not verify_result.is_valid:
658
+ logger.error(f"❌ {tool_name}: Facilitator rejected payment: {verify_result.invalid_reason}")
659
+ return self._create_402_response(config, f"Payment verification failed: {verify_result.invalid_reason}", request_path=tool_path)
660
+
661
+ # Store payment_uuid and facilitatorFeePercent for settlement
662
+ request.state.payment_uuid = verify_result.payment_uuid
663
+ request.state.facilitator_fee_percent = verify_result.facilitator_fee_percent or 250
664
+ logger.info(f"✅ {tool_name}: Facilitator verified payment (UUID: {verify_result.payment_uuid[:20] if verify_result.payment_uuid else 'N/A'}...)")
665
+ logger.info(f" Facilitator fee: {request.state.facilitator_fee_percent} basis points")
666
+
667
+ except Exception as e:
668
+ logger.error(f"❌ {tool_name}: Facilitator error: {e}")
669
+ return self._create_402_response(config, f"Facilitator verification failed: {str(e)}", request_path=tool_path)
670
+ else:
671
+ logger.info(f"⚠️ {tool_name}: Testing mode - skipping facilitator verification")
672
+
673
+ # Payment validated! Set api_key_to_use
674
+ logger.info(f"✅ {tool_name}: Payment VERIFIED successfully (Mode 2: Paid)")
675
+ logger.info(f" Payment amount: {payment_amount} wei (required: {required_amount} wei)")
676
+ logger.info(f" From (wallet): {auth.get('from', 'unknown')}")
677
+ logger.info(f" To (provider): {auth.get('to', 'unknown')}")
678
+ logger.info(f" Request path: {auth.get('requestPath', auth.get('request_path', 'unknown'))}")
679
+ request.state.api_key_to_use = self.internal_api_key
680
+ request.state.payment_validated = True
681
+ request.state.payment_dict = payment_dict
682
+ request.state.tool_name = tool_name # Store for settlement
683
+
684
+ # Store payment info for settlement
685
+ request.state.payment_payload = PaymentPayload.model_validate(payment_dict)
686
+ logger.info(f"💾 {tool_name}: Payment payload stored for settlement")
687
+
688
+ except Exception as e:
689
+ logger.error(f"❌ {tool_name}: Payment validation error: {e}")
690
+ config = self.tool_payment_configs[tool_name]
691
+ return self._create_402_response(config, f"Payment validation failed: {str(e)}", request_path=tool_path)
692
+
693
+ # Continue with reconstructed request
694
+ response = await self._continue_with_body(request, body, call_next)
695
+
696
+ # If payment was validated and response is successful, settle the payment
697
+ if hasattr(request.state, 'payment_validated') and request.state.payment_validated:
698
+ if 200 <= response.status_code < 300:
699
+ payment_uuid = getattr(request.state, 'payment_uuid', None)
700
+
701
+ # Read response body ONCE (handle different response types)
702
+ response_body = b""
703
+ try:
704
+ # StreamingResponse has body_iterator, Response has body attribute
705
+ if hasattr(response, 'body_iterator'):
706
+ async for chunk in response.body_iterator:
707
+ response_body += chunk
708
+ elif hasattr(response, 'body'):
709
+ # Regular Response - body is bytes
710
+ response_body = response.body
711
+ else:
712
+ logger.warning("Response has no body_iterator or body attribute")
713
+ response_body = b""
714
+ except Exception as e:
715
+ logger.error(f"Error reading response body: {e}")
716
+ response_body = b""
717
+
718
+ # Check if response contains errors - don't settle if upstream API failed
719
+ should_settle = True
720
+ try:
721
+ response_str = response_body.decode() if response_body else ""
722
+ # Check for MCP error indicators
723
+ if '"isError":true' in response_str or '"isError": true' in response_str:
724
+ logger.warning(f"⚠️ Tool returned isError=true - NOT settling payment")
725
+ should_settle = False
726
+ elif response_str.count('"error"') > 1: # Multiple error fields suggest failure
727
+ logger.warning(f"⚠️ Tool response contains errors - NOT settling payment")
728
+ should_settle = False
729
+
730
+ # Recreate response for return with buffered body
731
+ from starlette.responses import Response
732
+ response = Response(
733
+ content=response_body,
734
+ status_code=response.status_code,
735
+ headers=dict(response.headers),
736
+ media_type=response.media_type
737
+ )
738
+ except Exception as e:
739
+ logger.error(f"Error checking response for errors: {e}")
740
+ # If we can't check, don't settle to be safe
741
+ should_settle = False
742
+
743
+ if not should_settle:
744
+ logger.info(f"💳 Skipping settlement due to tool error")
745
+ return response
746
+
747
+ logger.info(f"💳 Successful response with validated payment - triggering settlement")
748
+ logger.info(f" Payment UUID: {payment_uuid}")
749
+
750
+ # Trigger settlement asynchronously (fire-and-forget)
751
+ if payment_uuid and self.facilitator:
752
+ import asyncio
753
+ from .mcp import settle_payment, EndpointPaymentInfo
754
+
755
+ # Parse for settlement (runs async after response sent)
756
+ async def do_settlement():
757
+ try:
758
+ logger.info(f"🚀 Background settlement started (async - client already has response)")
759
+
760
+ # Parse response body
761
+ try:
762
+ output_data = json.loads(response_body.decode())
763
+ except:
764
+ output_data = {"response": response_body.decode() if response_body else "completed"}
765
+
766
+ # Get tool config
767
+ tool_name = getattr(request.state, 'tool_name', 'unknown')
768
+ config = self.tool_payment_configs.get(tool_name, {})
769
+
770
+ # Create endpoint info
771
+ endpoint_info = EndpointPaymentInfo(
772
+ settlement_token_address=config.get("token_address"),
773
+ settlement_token_network=config.get("network"),
774
+ payment_price_float=float(config.get("price_wei", 0)) / 1e6,
775
+ payment_price_wei=config.get("price_wei"),
776
+ server_address=config.get("server_address")
777
+ )
778
+
779
+ # Create context wrapper
780
+ class SettlementContext:
781
+ class State:
782
+ def __init__(self):
783
+ self.payment_payload = getattr(request.state, 'payment_payload', None)
784
+ self.payment_uuid = payment_uuid
785
+ self.facilitator_fee_percent = getattr(request.state, 'facilitator_fee_percent', 250)
786
+
787
+ def __init__(self):
788
+ self.state = SettlementContext.State()
789
+
790
+ settlement_ctx = SettlementContext()
791
+
792
+ # Use actual response data for output hash
793
+ logger.info(f"📊 Using actual response data for settlement")
794
+ logger.info(f" Output data size: {len(str(output_data))} chars")
795
+
796
+ settlement_success = await settle_payment(
797
+ context=settlement_ctx,
798
+ endpoint_info=endpoint_info,
799
+ output_data=output_data,
800
+ middleware=self
801
+ )
802
+
803
+ if settlement_success:
804
+ logger.info(f"✅ Background settlement completed")
805
+ else:
806
+ logger.warning(f"⚠️ Background settlement failed")
807
+
808
+ except Exception as e:
809
+ logger.error(f"❌ Settlement error: {e}")
810
+ import traceback
811
+ logger.error(traceback.format_exc())
812
+
813
+ asyncio.create_task(do_settlement())
814
+ logger.info(f"📅 Settlement task scheduled - client gets response immediately")
815
+
816
+ return response
817
+
818
+ except Exception as e:
819
+ logger.error(f"Error in D402PaymentMiddleware: {e}")
820
+ import traceback
821
+ logger.error(traceback.format_exc())
822
+ # Continue on error
823
+ return await call_next(request)
824
+
825
+ async def _continue_with_body(self, request: Request, body: bytes, call_next):
826
+ """Continue request processing with body we already read."""
827
+ # Create new request with reconstructed receive
828
+ async def receive():
829
+ return {"type": "http.request", "body": body, "more_body": False}
830
+
831
+ from starlette.requests import Request as NewRequest
832
+ new_request = NewRequest(request.scope, receive)
833
+ return await call_next(new_request)
834
+
835
+ def _create_402_response(self, config: Dict[str, Any], error_message: str, request_path: str = "/mcp") -> JSONResponse:
836
+ """Helper to create HTTP 402 response with request path for signature binding."""
837
+ # Include EIP712 domain in extra for client to sign payment
838
+ # This should be IATPWallet domain (consumer's wallet contract)
839
+ extra_data = config.get("eip712_domain", {
840
+ "name": "IATPWallet", # Consumer's wallet contract
841
+ "version": "1"
842
+ })
843
+
844
+ logger.info(f" 🔧 Creating 402 response with resource: {request_path}")
845
+
846
+ payment_req = PaymentRequirements(
847
+ scheme="exact",
848
+ network=config["network"],
849
+ pay_to=config["server_address"],
850
+ max_amount_required=config["price_wei"],
851
+ max_timeout_seconds=86400, # 24 hours for settlement window
852
+ description=config["description"],
853
+ resource=request_path, # Include actual API path for signature binding
854
+ mime_type="application/json",
855
+ asset=config["token_address"],
856
+ extra=extra_data # EIP712 domain for signature
857
+ )
858
+
859
+ response_data = d402PaymentRequiredResponse(
860
+ d402_version=d402_VERSION,
861
+ accepts=[payment_req],
862
+ error=error_message
863
+ )
864
+
865
+ return JSONResponse(
866
+ status_code=402,
867
+ content=response_data.model_dump(by_alias=True),
868
+ headers={"Access-Control-Expose-Headers": "X-Payment-Response"}
869
+ )
870
+
871
+
872
+ __all__ = [
873
+ "D402PaymentMiddleware",
874
+ "require_payment",
875
+ "extract_payment_configs",
876
+ "build_payment_config",
877
+ ]
878
+