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.
- traia_iatp/__init__.py +105 -8
- traia_iatp/cli/main.py +85 -1
- traia_iatp/client/__init__.py +28 -3
- traia_iatp/client/crewai_a2a_tools.py +32 -12
- traia_iatp/client/d402_a2a_client.py +348 -0
- traia_iatp/contracts/__init__.py +11 -0
- traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
- traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
- traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
- traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
- traia_iatp/contracts/iatp_contracts_config.py +263 -0
- traia_iatp/contracts/wallet_creator.py +369 -0
- traia_iatp/core/models.py +17 -3
- traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
- traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
- traia_iatp/d402/README.md +489 -0
- traia_iatp/d402/__init__.py +54 -0
- traia_iatp/d402/asgi_wrapper.py +469 -0
- traia_iatp/d402/chains.py +102 -0
- traia_iatp/d402/client.py +150 -0
- traia_iatp/d402/clients/__init__.py +7 -0
- traia_iatp/d402/clients/base.py +218 -0
- traia_iatp/d402/clients/httpx.py +266 -0
- traia_iatp/d402/common.py +114 -0
- traia_iatp/d402/encoding.py +28 -0
- traia_iatp/d402/examples/client_example.py +197 -0
- traia_iatp/d402/examples/server_example.py +171 -0
- traia_iatp/d402/facilitator.py +481 -0
- traia_iatp/d402/mcp_middleware.py +296 -0
- traia_iatp/d402/models.py +116 -0
- traia_iatp/d402/networks.py +98 -0
- traia_iatp/d402/path.py +43 -0
- traia_iatp/d402/payment_introspection.py +126 -0
- traia_iatp/d402/payment_signing.py +183 -0
- traia_iatp/d402/price_builder.py +164 -0
- traia_iatp/d402/servers/__init__.py +61 -0
- traia_iatp/d402/servers/base.py +139 -0
- traia_iatp/d402/servers/example_general_server.py +140 -0
- traia_iatp/d402/servers/fastapi.py +253 -0
- traia_iatp/d402/servers/mcp.py +304 -0
- traia_iatp/d402/servers/starlette.py +878 -0
- traia_iatp/d402/starlette_middleware.py +529 -0
- traia_iatp/d402/types.py +300 -0
- traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
- traia_iatp/mcp/__init__.py +3 -0
- traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
- traia_iatp/mcp/mcp_agent_template.py +78 -13
- traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
- traia_iatp/mcp/templates/README.md.j2 +104 -8
- traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
- traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
- traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
- traia_iatp/mcp/templates/env.example.j2 +60 -0
- traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
- traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
- traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
- traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
- traia_iatp/mcp/templates/server.py.j2 +174 -197
- traia_iatp/mcp/traia_mcp_adapter.py +182 -20
- traia_iatp/registry/__init__.py +47 -12
- traia_iatp/registry/atlas_search_indexes.json +108 -54
- traia_iatp/registry/iatp_search_api.py +169 -39
- traia_iatp/registry/mongodb_registry.py +241 -69
- traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
- traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
- traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
- traia_iatp/registry/readmes/README.md +3 -3
- traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
- traia_iatp/scripts/__init__.py +2 -0
- traia_iatp/scripts/create_wallet.py +244 -0
- traia_iatp/server/a2a_server.py +22 -7
- traia_iatp/server/iatp_server_template_generator.py +23 -0
- traia_iatp/server/templates/.dockerignore.j2 +48 -0
- traia_iatp/server/templates/Dockerfile.j2 +23 -1
- traia_iatp/server/templates/README.md +2 -2
- traia_iatp/server/templates/README.md.j2 +5 -5
- traia_iatp/server/templates/__main__.py.j2 +374 -66
- traia_iatp/server/templates/agent.py.j2 +12 -11
- traia_iatp/server/templates/agent_config.json.j2 +3 -3
- traia_iatp/server/templates/agent_executor.py.j2 +45 -27
- traia_iatp/server/templates/env.example.j2 +32 -4
- traia_iatp/server/templates/gitignore.j2 +7 -0
- traia_iatp/server/templates/pyproject.toml.j2 +13 -12
- traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
- traia_iatp/server/templates/server.py.j2 +197 -10
- traia_iatp/special_agencies/registry_search_agency.py +1 -1
- traia_iatp/utils/iatp_utils.py +6 -6
- traia_iatp-0.1.67.dist-info/METADATA +320 -0
- traia_iatp-0.1.67.dist-info/RECORD +117 -0
- traia_iatp-0.1.2.dist-info/METADATA +0 -414
- traia_iatp-0.1.2.dist-info/RECORD +0 -72
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
- {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}")
|