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,469 @@
1
+ """
2
+ Raw ASGI wrapper for D402 payment enforcement.
3
+
4
+ This module provides a low-level ASGI wrapper that intercepts requests
5
+ BEFORE they reach any framework (Starlette, FastAPI, A2A, etc.).
6
+
7
+ Use this when BaseHTTPMiddleware doesn't work due to framework compatibility issues.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from typing import Dict, Any, Optional, Callable
13
+
14
+ from .types import PaymentRequirements, d402PaymentRequiredResponse, PaymentPayload
15
+ from .common import d402_VERSION
16
+ from .facilitator import IATPSettlementFacilitator
17
+ from .encoding import safe_base64_decode
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def extract_server_url_from_scope(scope: dict) -> str:
23
+ """
24
+ Extract the server's own URL from ASGI scope headers.
25
+
26
+ This is used for tracking in the facilitator which server a payment came from.
27
+ Works for both local and remote (Cloud Run) deployments.
28
+
29
+ Args:
30
+ scope: ASGI scope dict with headers
31
+
32
+ Returns:
33
+ Full server URL (e.g., 'https://my-agent.cloudrun.app' or 'http://localhost:9001')
34
+ """
35
+ # Parse headers (list of tuples of bytes)
36
+ headers = dict(scope.get("headers", []))
37
+
38
+ # Check X-Forwarded headers first (set by proxies/load balancers like Cloud Run)
39
+ forwarded_proto = headers.get(b"x-forwarded-proto", b"http").decode()
40
+ forwarded_host = headers.get(b"x-forwarded-host", b"").decode()
41
+
42
+ if forwarded_host:
43
+ # Cloud Run / proxy scenario
44
+ server_url = f"{forwarded_proto}://{forwarded_host}"
45
+ logger.debug(f"Server URL from X-Forwarded headers: {server_url}")
46
+ return server_url
47
+
48
+ # Fallback to Host header (for local/direct access)
49
+ host = headers.get(b"host", b"localhost:9001").decode()
50
+
51
+ # Determine protocol
52
+ if "localhost" in host or "127.0.0.1" in host or "host.docker.internal" in host:
53
+ proto = "http"
54
+ else:
55
+ # Remote host without X-Forwarded-Proto, assume https
56
+ proto = "https"
57
+
58
+ server_url = f"{proto}://{host}"
59
+ logger.debug(f"Server URL from Host header: {server_url}")
60
+ return server_url
61
+
62
+
63
+ class D402ASGIWrapper:
64
+ """
65
+ Raw ASGI wrapper for D402 payment enforcement.
66
+
67
+ This wraps any ASGI application and intercepts requests at the lowest level,
68
+ before any framework processing occurs. Guaranteed to work regardless of
69
+ framework quirks.
70
+
71
+ Usage:
72
+ starlette_app = app.build()
73
+ wrapped_app = D402ASGIWrapper(
74
+ app=starlette_app,
75
+ server_address="0x123...",
76
+ endpoint_payment_configs={"/a2a": {...}},
77
+ facilitator_url="http://localhost:7070"
78
+ )
79
+ uvicorn.run(wrapped_app, ...)
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ app: Callable, # Any ASGI app
85
+ server_address: str,
86
+ endpoint_payment_configs: Dict[str, Dict[str, Any]],
87
+ requires_auth: bool = False,
88
+ internal_api_key: Optional[str] = None,
89
+ testing_mode: bool = False,
90
+ facilitator_url: Optional[str] = None,
91
+ facilitator_api_key: Optional[str] = None,
92
+ server_name: Optional[str] = None
93
+ ):
94
+ """
95
+ Initialize D402 ASGI wrapper.
96
+
97
+ Args:
98
+ app: The ASGI application to wrap
99
+ server_address: Payment destination address
100
+ endpoint_payment_configs: Dict mapping paths to payment configs
101
+ requires_auth: If True, accepts API key OR payment
102
+ internal_api_key: Server's API key
103
+ testing_mode: If True, skips facilitator
104
+ facilitator_url: Facilitator service URL
105
+ facilitator_api_key: API key for facilitator
106
+ server_name: Server identifier
107
+ """
108
+ self.app = app
109
+ self.server_address = server_address
110
+ self.endpoint_payment_configs = endpoint_payment_configs or {}
111
+ self.requires_auth = requires_auth
112
+ self.internal_api_key = internal_api_key
113
+ self.testing_mode = testing_mode
114
+
115
+ # Initialize facilitator
116
+ self.facilitator = None
117
+ if not self.testing_mode:
118
+ try:
119
+ import os
120
+ operator_key = (
121
+ os.getenv("UTILITY_AGENT_OPERATOR_PRIVATE_KEY") or
122
+ os.getenv("MCP_OPERATOR_PRIVATE_KEY") or
123
+ os.getenv("OPERATOR_PRIVATE_KEY")
124
+ )
125
+
126
+ if operator_key:
127
+ # Note: server_url will be extracted from each request at runtime
128
+ # This is needed because Cloud Run URLs are not known until deployment
129
+ # and must be introspected from X-Forwarded-Host and X-Forwarded-Proto headers
130
+
131
+ self.facilitator = IATPSettlementFacilitator(
132
+ facilitator_url=facilitator_url or "https://facilitator.d402.net",
133
+ facilitator_api_key=facilitator_api_key,
134
+ provider_operator_key=operator_key,
135
+ server_name=server_name or "unknown",
136
+ server_url=None # Will be set per-request from headers
137
+ )
138
+ logger.info(" Facilitator initialized with operator key")
139
+
140
+ # Store server name for later use
141
+ self.server_name = server_name or "unknown"
142
+ else:
143
+ logger.warning(" No operator key - settlement disabled")
144
+ except Exception as e:
145
+ logger.warning(f" Could not initialize facilitator: {e}")
146
+ self.testing_mode = True
147
+
148
+ logger.info(f"D402ASGIWrapper initialized:")
149
+ logger.info(f" Protected endpoints: {list(self.endpoint_payment_configs.keys())}")
150
+ logger.info(f" Server address: {server_address}")
151
+ logger.info(f" Testing mode: {self.testing_mode}")
152
+ logger.info(f" Facilitator: {'Enabled' if self.facilitator else 'Disabled'}")
153
+
154
+ async def __call__(self, scope, receive, send):
155
+ """
156
+ ASGI entry point - intercepts all requests.
157
+ """
158
+ logger.info(f"🔍 D402ASGIWrapper.__call__: {scope.get('type')} {scope.get('path')}")
159
+
160
+ # Only process HTTP requests
161
+ if scope["type"] != "http":
162
+ await self.app(scope, receive, send)
163
+ return
164
+
165
+ # Only intercept POST requests
166
+ if scope["method"] != "POST":
167
+ await self.app(scope, receive, send)
168
+ return
169
+
170
+ # Check if endpoint is protected
171
+ path = scope["path"].rstrip('/') or '/'
172
+ if path not in self.endpoint_payment_configs:
173
+ logger.debug(f"Path {path} not protected, passing through")
174
+ await self.app(scope, receive, send)
175
+ return
176
+
177
+ logger.info(f"💰 Protected endpoint {path} - checking payment...")
178
+
179
+ # Check for X-Payment header
180
+ headers = dict(scope["headers"])
181
+ payment_header = headers.get(b"x-payment", b"").decode()
182
+
183
+ if not payment_header:
184
+ logger.info(f"💰 No payment header - returning HTTP 402")
185
+ # Return 402 Payment Required
186
+ config = self.endpoint_payment_configs[path]
187
+ await self._send_402_response(send, config, path)
188
+ return
189
+
190
+ logger.info(f"💰 Payment header received - validating...")
191
+ logger.info(f"📦 Payment header length: {len(payment_header)} bytes")
192
+
193
+ # Validate payment (copy logic from starlette_middleware.py)
194
+ try:
195
+ # 1. Decode and parse payment header
196
+ payment_data = safe_base64_decode(payment_header)
197
+ if not payment_data:
198
+ logger.error(f"❌ Invalid payment encoding")
199
+ config = self.endpoint_payment_configs[path]
200
+ await self._send_402_response(send, config, path)
201
+ return
202
+
203
+ payment_dict = json.loads(payment_data)
204
+
205
+ # 2. Check payment structure
206
+ if not payment_dict.get("payload") or not payment_dict["payload"].get("authorization"):
207
+ logger.error(f"❌ Invalid payment structure")
208
+ config = self.endpoint_payment_configs[path]
209
+ await self._send_402_response(send, config, path)
210
+ return
211
+
212
+ auth = payment_dict["payload"]["authorization"]
213
+
214
+ # 3. Verify payment destination
215
+ if auth.get("to", "").lower() != self.server_address.lower():
216
+ logger.error(f"❌ Payment to wrong address")
217
+ config = self.endpoint_payment_configs[path]
218
+ await self._send_402_response(send, config, path)
219
+ return
220
+
221
+ # 4. Verify payment amount
222
+ config = self.endpoint_payment_configs[path]
223
+ payment_amount = int(auth.get("value", 0))
224
+ required_amount = int(config["price_wei"])
225
+
226
+ if payment_amount < required_amount:
227
+ logger.error(f"❌ Insufficient payment: {payment_amount} < {required_amount}")
228
+ await self._send_402_response(send, config, path)
229
+ return
230
+
231
+ # 5. Call facilitator.verify() if available
232
+ payment_uuid = None
233
+ facilitator_fee_percent = 250
234
+ if self.facilitator and not self.testing_mode:
235
+ try:
236
+ # Extract server's own URL from request headers for tracking
237
+ server_url = extract_server_url_from_scope(scope)
238
+ logger.info(f"🌐 Server URL (introspected): {server_url}")
239
+
240
+ # Temporarily update facilitator's server_url for this request
241
+ original_server_url = self.facilitator.server_url
242
+ self.facilitator.server_url = server_url
243
+
244
+ payment_payload = PaymentPayload.model_validate(payment_dict)
245
+ payment_reqs = PaymentRequirements(
246
+ scheme="exact",
247
+ network=config["network"],
248
+ pay_to=config["server_address"],
249
+ max_amount_required=config["price_wei"],
250
+ max_timeout_seconds=86400,
251
+ description=config["description"],
252
+ resource=path,
253
+ mime_type="application/json",
254
+ asset=config["token_address"],
255
+ extra={}
256
+ )
257
+
258
+ logger.info(f"🔐 Verifying payment with facilitator...")
259
+ verify_result = await self.facilitator.verify(payment_payload, payment_reqs)
260
+
261
+ # Restore original server_url
262
+ self.facilitator.server_url = original_server_url
263
+
264
+ logger.info(f"🔐 Facilitator verify result: {verify_result}")
265
+
266
+ if not verify_result.is_valid:
267
+ logger.error(f"❌ Facilitator rejected payment: {verify_result.invalid_reason}")
268
+ await self._send_402_response(send, config, path)
269
+ return
270
+
271
+ payment_uuid = verify_result.payment_uuid
272
+ facilitator_fee_percent = verify_result.facilitator_fee_percent or 250
273
+ logger.info(f"✅ Facilitator verified payment (UUID: {payment_uuid[:20] if payment_uuid else 'N/A'}...)")
274
+ except Exception as e:
275
+ logger.error(f"❌ Facilitator error: {e}")
276
+ await self._send_402_response(send, config, path)
277
+ return
278
+ else:
279
+ logger.info(f"⚠️ Testing mode - skipping facilitator verification")
280
+
281
+ # Payment validated!
282
+ logger.info(f"✅ Payment VERIFIED successfully")
283
+ logger.info(f" Payment amount: {payment_amount} wei (required: {required_amount} wei)")
284
+ logger.info(f" From (wallet): {auth.get('from', 'unknown')}")
285
+ logger.info(f" To (provider): {auth.get('to', 'unknown')}")
286
+
287
+ # Store payment info for settlement
288
+ payment_info = {
289
+ "payment_validated": True,
290
+ "payment_dict": payment_dict,
291
+ "payment_payload": PaymentPayload.model_validate(payment_dict),
292
+ "payment_uuid": payment_uuid,
293
+ "facilitator_fee_percent": facilitator_fee_percent,
294
+ "endpoint_path": path,
295
+ "endpoint_config": config
296
+ }
297
+
298
+ except Exception as e:
299
+ logger.error(f"❌ Payment validation error: {e}")
300
+ import traceback
301
+ logger.error(traceback.format_exc())
302
+ config = self.endpoint_payment_configs[path]
303
+ await self._send_402_response(send, config, path)
304
+ return
305
+
306
+ # Payment valid - forward to app with response interception for settlement
307
+ response_started = False
308
+ response_status = None
309
+ response_body_chunks = []
310
+
311
+ async def send_wrapper(message):
312
+ """Wrap send to capture response for settlement."""
313
+ nonlocal response_started, response_status, response_body_chunks
314
+
315
+ logger.debug(f"🔍 send_wrapper: {message['type']}")
316
+
317
+ if message["type"] == "http.response.start":
318
+ response_started = True
319
+ response_status = message["status"]
320
+ logger.info(f"📡 Response starting: HTTP {response_status}")
321
+ # Forward the start message
322
+ await send(message)
323
+ elif message["type"] == "http.response.body":
324
+ # Capture body for settlement
325
+ body = message.get("body", b"")
326
+ if body:
327
+ response_body_chunks.append(body)
328
+ logger.debug(f"📦 Captured body chunk: {len(body)} bytes")
329
+ # Forward the body message
330
+ await send(message)
331
+
332
+ # If this is the last chunk, trigger settlement
333
+ if not message.get("more_body", False):
334
+ logger.info(f"📬 Last body chunk received, total: {sum(len(c) for c in response_body_chunks)} bytes")
335
+ await self._trigger_settlement(
336
+ response_status,
337
+ response_body_chunks,
338
+ payment_info
339
+ )
340
+ else:
341
+ # Forward other messages as-is
342
+ await send(message)
343
+
344
+ # Forward request with wrapped send
345
+ await self.app(scope, receive, send_wrapper)
346
+
347
+ async def _trigger_settlement(self, status_code: int, body_chunks: list, payment_info: dict):
348
+ """Trigger payment settlement after successful response."""
349
+ # Only settle on successful responses
350
+ if not (200 <= status_code < 300):
351
+ logger.info(f"⚠️ Non-success status {status_code}, skipping settlement")
352
+ return
353
+
354
+ if not payment_info.get("payment_validated"):
355
+ return
356
+
357
+ # Combine body chunks
358
+ response_body = b"".join(body_chunks)
359
+
360
+ # Check for errors in response
361
+ try:
362
+ response_str = response_body.decode() if response_body else ""
363
+ if '"error"' in response_str:
364
+ logger.warning(f"⚠️ Response contains errors - NOT settling payment")
365
+ return
366
+ except:
367
+ pass
368
+
369
+ logger.info(f"💳 Successful response - triggering settlement")
370
+
371
+ # Trigger settlement asynchronously
372
+ if self.facilitator and payment_info.get("payment_uuid"):
373
+ import asyncio
374
+ from .mcp_middleware import settle_payment, EndpointPaymentInfo
375
+
376
+ async def do_settlement():
377
+ try:
378
+ logger.info(f"🚀 Background settlement started")
379
+
380
+ # Parse response
381
+ try:
382
+ output_data = json.loads(response_body.decode())
383
+ except:
384
+ output_data = {"response": response_body.decode() if response_body else "completed"}
385
+
386
+ config = payment_info["endpoint_config"]
387
+ endpoint_info = EndpointPaymentInfo(
388
+ settlement_token_address=config["token_address"],
389
+ settlement_token_network=config["network"],
390
+ payment_price_float=float(config["price_wei"]) / 1e6,
391
+ payment_price_wei=config["price_wei"],
392
+ server_address=config["server_address"]
393
+ )
394
+
395
+ # Create context wrapper
396
+ class SettlementContext:
397
+ class State:
398
+ def __init__(self, payment_info):
399
+ self.payment_payload = payment_info["payment_payload"]
400
+ self.payment_uuid = payment_info["payment_uuid"]
401
+ self.facilitator_fee_percent = payment_info["facilitator_fee_percent"]
402
+
403
+ def __init__(self, payment_info):
404
+ self.state = SettlementContext.State(payment_info)
405
+
406
+ settlement_ctx = SettlementContext(payment_info)
407
+
408
+ settlement_success = await settle_payment(
409
+ context=settlement_ctx,
410
+ endpoint_info=endpoint_info,
411
+ output_data=output_data,
412
+ middleware=self
413
+ )
414
+
415
+ if settlement_success:
416
+ logger.info(f"✅ Background settlement completed")
417
+ else:
418
+ logger.warning(f"⚠️ Background settlement failed")
419
+ except Exception as e:
420
+ logger.error(f"❌ Settlement error: {e}")
421
+ import traceback
422
+ logger.error(traceback.format_exc())
423
+
424
+ asyncio.create_task(do_settlement())
425
+ logger.info(f"📅 Settlement task scheduled")
426
+
427
+ async def _send_402_response(self, send, config: Dict[str, Any], resource_path: str):
428
+ """Send HTTP 402 Payment Required response."""
429
+
430
+ payment_req = PaymentRequirements(
431
+ scheme="exact",
432
+ network=config["network"],
433
+ pay_to=config["server_address"],
434
+ max_amount_required=config["price_wei"],
435
+ max_timeout_seconds=86400,
436
+ description=config["description"],
437
+ resource=resource_path,
438
+ mime_type="application/json",
439
+ asset=config["token_address"],
440
+ extra=config.get("eip712_domain", {"name": "IATPWallet", "version": "1"})
441
+ )
442
+
443
+ response_data = d402PaymentRequiredResponse(
444
+ d402_version=d402_VERSION,
445
+ accepts=[payment_req],
446
+ error="Payment required"
447
+ )
448
+
449
+ response_body = json.dumps(response_data.model_dump(by_alias=True)).encode()
450
+
451
+ await send({
452
+ "type": "http.response.start",
453
+ "status": 402,
454
+ "headers": [
455
+ [b"content-type", b"application/json"],
456
+ [b"content-length", str(len(response_body)).encode()],
457
+ [b"access-control-expose-headers", b"X-Payment-Response"],
458
+ ],
459
+ })
460
+ await send({
461
+ "type": "http.response.body",
462
+ "body": response_body,
463
+ })
464
+
465
+ logger.info(f"💰 Sent HTTP 402 response for {resource_path}")
466
+
467
+
468
+ __all__ = ["D402ASGIWrapper"]
469
+
@@ -0,0 +1,102 @@
1
+ NETWORK_TO_ID = {
2
+ "sepolia": "11155111", # Ethereum Sepolia testnet
3
+ "base-sepolia": "84532",
4
+ "base": "8453",
5
+ "avalanche-fuji": "43113",
6
+ "avalanche": "43114",
7
+ }
8
+
9
+
10
+ def get_chain_id(network: str) -> str:
11
+ """Get the chain ID for a given network
12
+ Supports string encoded chain IDs and human readable networks
13
+ """
14
+ try:
15
+ int(network)
16
+ return network
17
+ except ValueError:
18
+ pass
19
+ if network not in NETWORK_TO_ID:
20
+ raise ValueError(f"Unsupported network: {network}")
21
+ return NETWORK_TO_ID[network]
22
+
23
+
24
+ KNOWN_TOKENS = {
25
+ "11155111": [ # Sepolia testnet
26
+ {
27
+ "human_name": "usdc",
28
+ "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
29
+ "name": "USD Coin",
30
+ "decimals": 6,
31
+ "version": "2",
32
+ }
33
+ ],
34
+ "84532": [
35
+ {
36
+ "human_name": "usdc",
37
+ "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
38
+ "name": "USDC",
39
+ "decimals": 6,
40
+ "version": "2",
41
+ }
42
+ ],
43
+ "8453": [
44
+ {
45
+ "human_name": "usdc",
46
+ "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
47
+ "name": "USD Coin", # needs to be exactly what is returned by name() on contract
48
+ "decimals": 6,
49
+ "version": "2",
50
+ }
51
+ ],
52
+ "43113": [
53
+ {
54
+ "human_name": "usdc",
55
+ "address": "0x5425890298aed601595a70AB815c96711a31Bc65",
56
+ "name": "USD Coin",
57
+ "decimals": 6,
58
+ "version": "2",
59
+ }
60
+ ],
61
+ "43114": [
62
+ {
63
+ "human_name": "usdc",
64
+ "address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
65
+ "name": "USDC",
66
+ "decimals": 6,
67
+ "version": "2",
68
+ }
69
+ ],
70
+ }
71
+
72
+
73
+ def get_token_name(chain_id: str, address: str) -> str:
74
+ """Get the token name for a given chain and address"""
75
+ for token in KNOWN_TOKENS[chain_id]:
76
+ if token["address"] == address:
77
+ return token["name"]
78
+ raise ValueError(f"Token not found for chain {chain_id} and address {address}")
79
+
80
+
81
+ def get_token_version(chain_id: str, address: str) -> str:
82
+ """Get the token version for a given chain and address"""
83
+ for token in KNOWN_TOKENS[chain_id]:
84
+ if token["address"] == address:
85
+ return token["version"]
86
+ raise ValueError(f"Token not found for chain {chain_id} and address {address}")
87
+
88
+
89
+ def get_token_decimals(chain_id: str, address: str) -> int:
90
+ """Get the token decimals for a given chain and address"""
91
+ for token in KNOWN_TOKENS[chain_id]:
92
+ if token["address"] == address:
93
+ return token["decimals"]
94
+ raise ValueError(f"Token not found for chain {chain_id} and address {address}")
95
+
96
+
97
+ def get_default_token_address(chain_id: str, token_type: str = "usdc") -> str:
98
+ """Get the default token address for a given chain and token type"""
99
+ for token in KNOWN_TOKENS[chain_id]:
100
+ if token["human_name"] == token_type:
101
+ return token["address"]
102
+ raise ValueError(f"Token type '{token_type}' not found for chain {chain_id}")