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,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
|
+
|