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,296 @@
|
|
|
1
|
+
"""D402 payment helpers for MCP servers with official SDK.
|
|
2
|
+
|
|
3
|
+
This module provides decorators and helper functions for d402 payment protocol.
|
|
4
|
+
Works with official MCP SDK (mcp.server.fastmcp).
|
|
5
|
+
|
|
6
|
+
FastMCP Middleware classes are in fastmcp_middleware.py (deprecated).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Optional, Dict, Any, Callable
|
|
12
|
+
from functools import wraps
|
|
13
|
+
|
|
14
|
+
# Import Context from official SDK
|
|
15
|
+
from mcp.server.fastmcp import Context
|
|
16
|
+
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import JSONResponse
|
|
19
|
+
from web3 import Web3
|
|
20
|
+
|
|
21
|
+
from .types import PaymentPayload, Price, TokenAmount, TokenAsset, EIP712Domain, PaymentRequirements, d402PaymentRequiredResponse
|
|
22
|
+
from .encoding import safe_base64_decode
|
|
23
|
+
from .common import process_price_to_atomic_amount, d402_VERSION
|
|
24
|
+
from .facilitator import IATPSettlementFacilitator
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class D402PaymentRequiredException(Exception):
|
|
28
|
+
"""Exception raised when payment is required (HTTP 402)."""
|
|
29
|
+
def __init__(self, payment_response: Dict[str, Any]):
|
|
30
|
+
self.payment_response = payment_response
|
|
31
|
+
super().__init__("Payment required")
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EndpointPaymentInfo:
|
|
37
|
+
"""Payment information for a specific endpoint."""
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
settlement_token_address: str,
|
|
41
|
+
settlement_token_network: str,
|
|
42
|
+
payment_price_float: float,
|
|
43
|
+
payment_price_wei: str,
|
|
44
|
+
server_address: str
|
|
45
|
+
):
|
|
46
|
+
self.settlement_token_address = settlement_token_address
|
|
47
|
+
self.settlement_token_network = settlement_token_network
|
|
48
|
+
self.payment_price_float = payment_price_float
|
|
49
|
+
self.payment_price_wei = payment_price_wei
|
|
50
|
+
self.server_address = server_address
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_active_api_key(context: Any) -> Optional[str]:
|
|
54
|
+
"""
|
|
55
|
+
Get the API key to use for calling external APIs.
|
|
56
|
+
|
|
57
|
+
Returns api_key_to_use which was set by:
|
|
58
|
+
1. D402PaymentMiddleware (in request.state)
|
|
59
|
+
2. @require_payment_for_tool decorator (copied to context.state)
|
|
60
|
+
|
|
61
|
+
Priority:
|
|
62
|
+
1. context.state.api_key_to_use (set by decorator)
|
|
63
|
+
2. request.state.api_key_to_use (set by middleware)
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
context: MCP context object
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
API key string (client's OR server's) if authorized, None otherwise
|
|
70
|
+
|
|
71
|
+
Usage in tools:
|
|
72
|
+
api_key = get_active_api_key(context)
|
|
73
|
+
if api_key:
|
|
74
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
# Check request.state (set by middleware)
|
|
78
|
+
# Context is a Pydantic model - we can't set arbitrary fields on it
|
|
79
|
+
# So we read directly from request.state where middleware stored it
|
|
80
|
+
logger.debug(f"get_active_api_key: Checking context type={type(context).__name__}")
|
|
81
|
+
logger.debug(f" has request_context: {hasattr(context, 'request_context')}")
|
|
82
|
+
|
|
83
|
+
if hasattr(context, 'request_context') and context.request_context:
|
|
84
|
+
logger.debug(f" request_context exists")
|
|
85
|
+
if hasattr(context.request_context, 'request') and context.request_context.request:
|
|
86
|
+
request = context.request_context.request
|
|
87
|
+
logger.debug(f" request exists, has state: {hasattr(request, 'state')}")
|
|
88
|
+
if hasattr(request, 'state'):
|
|
89
|
+
api_key = getattr(request.state, 'api_key_to_use', None)
|
|
90
|
+
logger.debug(f" api_key_to_use: {api_key[:10] if api_key else None}")
|
|
91
|
+
if api_key:
|
|
92
|
+
return api_key
|
|
93
|
+
|
|
94
|
+
logger.warning(f"get_active_api_key: Could not find api_key_to_use in request.state")
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"get_active_api_key error: {e}")
|
|
98
|
+
import traceback
|
|
99
|
+
logger.error(traceback.format_exc())
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def settle_payment(
|
|
105
|
+
context: Any,
|
|
106
|
+
endpoint_info: EndpointPaymentInfo,
|
|
107
|
+
output_data: Any,
|
|
108
|
+
middleware: Optional[Any] = None # Middleware instance (Starlette or FastMCP)
|
|
109
|
+
) -> bool:
|
|
110
|
+
"""
|
|
111
|
+
Settle a payment after successful API call with output hash attestation.
|
|
112
|
+
|
|
113
|
+
Complete 402 settlement flow:
|
|
114
|
+
1. Hash the output data (result returned to client)
|
|
115
|
+
2. Provider signs over output_hash + consumer_request
|
|
116
|
+
3. Submit to facilitator with proof of service completion
|
|
117
|
+
4. Facilitator submits to IATP Settlement Layer on-chain
|
|
118
|
+
|
|
119
|
+
This should be called AFTER the tool successfully processes the request
|
|
120
|
+
to submit the payment settlement to the facilitator/blockchain.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
context: MCP context (contains payment_payload)
|
|
124
|
+
endpoint_info: Endpoint payment requirements
|
|
125
|
+
output_data: The actual output/result being returned to client (will be hashed)
|
|
126
|
+
middleware: Optional D402MCPMiddleware instance (for facilitator access)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: True if settlement submitted successfully
|
|
130
|
+
|
|
131
|
+
Usage in tools (for production settlement):
|
|
132
|
+
# Execute API call
|
|
133
|
+
response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
134
|
+
result = response.json()
|
|
135
|
+
|
|
136
|
+
# Settle payment with output hash
|
|
137
|
+
if context.state.payment_payload:
|
|
138
|
+
await settle_payment(context, endpoint_payment, output_data=result, middleware=...)
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
payment_payload = getattr(context.state, 'payment_payload', None) if hasattr(context, 'state') else None
|
|
144
|
+
if not payment_payload:
|
|
145
|
+
logger.debug("No payment to settle (authenticated mode)")
|
|
146
|
+
return True # Not an error - client used their own API key
|
|
147
|
+
|
|
148
|
+
# Skip settlement in testing mode
|
|
149
|
+
if middleware and middleware.testing_mode:
|
|
150
|
+
logger.info("⚠️ Testing mode: Skipping payment settlement")
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
# Step 1: Hash the output data (proof of service completion)
|
|
154
|
+
import json
|
|
155
|
+
from web3 import Web3
|
|
156
|
+
|
|
157
|
+
logger.info("🔐 Starting payment settlement process...")
|
|
158
|
+
|
|
159
|
+
# Serialize output to JSON and hash it
|
|
160
|
+
output_json = json.dumps(output_data, sort_keys=True, separators=(',', ':'))
|
|
161
|
+
output_hash = Web3.keccak(text=output_json).hex()
|
|
162
|
+
logger.info(f"📊 Output data serialized: {len(output_json)} bytes")
|
|
163
|
+
logger.info(f"🔑 Output hash calculated: {output_hash}")
|
|
164
|
+
logger.info(f" First 1000 chars of output: {output_json[:1000]}")
|
|
165
|
+
|
|
166
|
+
# Step 2: Get payment_uuid from context (from facilitator verify response)
|
|
167
|
+
# The payment_uuid is the primary payment identifier from the facilitator
|
|
168
|
+
# It was set in verify_endpoint_payment() after facilitator.verify() returned it
|
|
169
|
+
payment_uuid = None
|
|
170
|
+
if hasattr(context, 'state') and hasattr(context.state, 'payment_uuid'):
|
|
171
|
+
payment_uuid = context.state.payment_uuid
|
|
172
|
+
|
|
173
|
+
if not payment_uuid:
|
|
174
|
+
logger.warning("No payment_uuid found in context - payment may not have been verified via facilitator")
|
|
175
|
+
|
|
176
|
+
# Step 3: Get facilitator fee from context (set by verify response)
|
|
177
|
+
facilitator_fee_percent = 250 # Default
|
|
178
|
+
if hasattr(context, 'state') and hasattr(context.state, 'facilitator_fee_percent'):
|
|
179
|
+
facilitator_fee_percent = context.state.facilitator_fee_percent
|
|
180
|
+
|
|
181
|
+
# Step 4: Create PaymentRequirements for this endpoint
|
|
182
|
+
# Include output_hash, payment_uuid, and facilitatorFeePercent in extra data
|
|
183
|
+
extra_data = {
|
|
184
|
+
"output_hash": output_hash,
|
|
185
|
+
"facilitator_fee_percent": facilitator_fee_percent
|
|
186
|
+
}
|
|
187
|
+
if payment_uuid:
|
|
188
|
+
extra_data["payment_uuid"] = payment_uuid
|
|
189
|
+
|
|
190
|
+
payment_requirements = PaymentRequirements(
|
|
191
|
+
scheme="exact",
|
|
192
|
+
network=endpoint_info.settlement_token_network,
|
|
193
|
+
pay_to=endpoint_info.server_address,
|
|
194
|
+
max_amount_required=endpoint_info.payment_price_wei,
|
|
195
|
+
max_timeout_seconds=300,
|
|
196
|
+
description=f"Service completed - output_hash: {output_hash}",
|
|
197
|
+
resource="",
|
|
198
|
+
mime_type="application/json",
|
|
199
|
+
asset=endpoint_info.settlement_token_address,
|
|
200
|
+
extra=extra_data # Include output hash, payment_uuid, and facilitatorFeePercent
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Step 4: Settle via facilitator
|
|
204
|
+
# The facilitator will:
|
|
205
|
+
# - Create provider attestation signing over the consumer's request + output_hash
|
|
206
|
+
# - Submit to relayer with proof of service completion
|
|
207
|
+
# - Relayer submits to IATPSettlementLayer on-chain
|
|
208
|
+
if middleware and middleware.facilitator:
|
|
209
|
+
try:
|
|
210
|
+
logger.info(f"📤 Submitting settlement to facilitator...")
|
|
211
|
+
logger.info(f" Payment UUID: {payment_uuid if payment_uuid else 'N/A'}")
|
|
212
|
+
logger.info(f" Output hash: {output_hash}")
|
|
213
|
+
logger.info(f" Amount: {endpoint_info.payment_price_wei} wei")
|
|
214
|
+
|
|
215
|
+
settle_result = await middleware.facilitator.settle(payment_payload, payment_requirements)
|
|
216
|
+
if settle_result.success:
|
|
217
|
+
logger.info(f"✅ Payment settlement request accepted by facilitator:")
|
|
218
|
+
logger.info(f" Status: PENDING_SETTLEMENT (queued for on-chain settlement)")
|
|
219
|
+
logger.info(f" Network: {settle_result.network}")
|
|
220
|
+
logger.info(f" Payer: {settle_result.payer}")
|
|
221
|
+
logger.info(f" Output Hash: {output_hash}")
|
|
222
|
+
logger.info(f" Note: Facilitator cron will batch-settle on-chain")
|
|
223
|
+
return True
|
|
224
|
+
else:
|
|
225
|
+
logger.error(f"❌ Payment settlement FAILED: {settle_result.error_reason}")
|
|
226
|
+
# TODO: Queue for retry
|
|
227
|
+
return False
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.error(f"Error settling payment via facilitator: {e}")
|
|
230
|
+
# Don't fail the request if settlement fails
|
|
231
|
+
# Settlement can be retried later
|
|
232
|
+
logger.warning("Settlement failed but request completed - will retry later")
|
|
233
|
+
# TODO: Queue settlement for retry
|
|
234
|
+
return False
|
|
235
|
+
else:
|
|
236
|
+
logger.warning("No facilitator available for settlement")
|
|
237
|
+
# TODO: Queue settlement for later retry
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
import traceback
|
|
242
|
+
logger.error(f"Error in settle_payment: {e}")
|
|
243
|
+
logger.error(f"Traceback:\n{traceback.format_exc()}")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def require_payment_for_tool(
|
|
248
|
+
price: Price,
|
|
249
|
+
description: str = ""
|
|
250
|
+
):
|
|
251
|
+
"""
|
|
252
|
+
Decorator for MCP tools that require payment - METADATA ONLY.
|
|
253
|
+
|
|
254
|
+
This decorator ONLY stores payment configuration metadata on the function.
|
|
255
|
+
All payment processing (verify, settle) is handled by D402PaymentMiddleware.
|
|
256
|
+
|
|
257
|
+
Usage:
|
|
258
|
+
@mcp.tool()
|
|
259
|
+
@require_payment_for_tool(
|
|
260
|
+
price=TokenAmount(
|
|
261
|
+
amount="1000",
|
|
262
|
+
asset=TokenAsset(
|
|
263
|
+
address="0xUSDC...",
|
|
264
|
+
decimals=6,
|
|
265
|
+
network="base-sepolia",
|
|
266
|
+
eip712=EIP712Domain(name="IATPWallet", version="1")
|
|
267
|
+
)
|
|
268
|
+
),
|
|
269
|
+
description="Get cryptocurrency price data"
|
|
270
|
+
)
|
|
271
|
+
async def get_price(context: Context, coin_id: str) -> Dict[str, Any]:
|
|
272
|
+
# Payment already verified by middleware
|
|
273
|
+
api_key = get_active_api_key(context)
|
|
274
|
+
response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
275
|
+
return response.json()
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
price: Payment configuration (TokenAmount with network, token, etc)
|
|
279
|
+
description: Service description for settlement
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Decorator that attaches metadata to the function
|
|
283
|
+
"""
|
|
284
|
+
def decorator(func: Callable):
|
|
285
|
+
# Store payment metadata on function for middleware extraction
|
|
286
|
+
func._d402_payment_config = {
|
|
287
|
+
"price": price,
|
|
288
|
+
"description": description
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Return function unchanged - middleware handles all payment logic
|
|
292
|
+
return func
|
|
293
|
+
|
|
294
|
+
return decorator
|
|
295
|
+
|
|
296
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""D402 payment models for IATP protocol."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PaymentScheme(str, Enum):
|
|
9
|
+
"""Payment schemes supported by d402."""
|
|
10
|
+
EXACT = "exact" # EIP-3009 exact payment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class D402ServicePrice(BaseModel):
|
|
14
|
+
"""Pricing configuration for an IATP service.
|
|
15
|
+
|
|
16
|
+
Supports any ERC20 token payment with full token details.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Token details
|
|
20
|
+
token_address: str = Field(..., description="Token contract address (e.g., USDC, TRAIA, DAI)")
|
|
21
|
+
token_symbol: str = Field(..., description="Token symbol for display")
|
|
22
|
+
token_decimals: int = Field(..., description="Token decimals (6 for USDC, 18 for most)")
|
|
23
|
+
|
|
24
|
+
# Price (stored in multiple formats for convenience)
|
|
25
|
+
price_wei: str = Field(..., description="Price in wei/atomic units")
|
|
26
|
+
price_float: float = Field(..., description="Price in token units (human-readable)")
|
|
27
|
+
|
|
28
|
+
# Network configuration
|
|
29
|
+
network: str = Field(..., description="Network (sepolia, base-sepolia, etc.)")
|
|
30
|
+
chain_id: int = Field(..., description="Chain ID")
|
|
31
|
+
|
|
32
|
+
# Optional USD equivalent (for display only)
|
|
33
|
+
usd_amount: Optional[float] = Field(None, description="Approximate USD value")
|
|
34
|
+
|
|
35
|
+
# Maximum timeout for payment completion
|
|
36
|
+
max_timeout_seconds: int = Field(default=300, description="Max time to complete payment")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class D402Config(BaseModel):
|
|
40
|
+
"""Configuration for d402 payment integration in IATP.
|
|
41
|
+
|
|
42
|
+
Supports per-path pricing with different tokens.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Enable/disable d402 payments
|
|
46
|
+
enabled: bool = Field(default=False, description="Enable d402 payments")
|
|
47
|
+
|
|
48
|
+
# Payment address (utility agent contract address)
|
|
49
|
+
pay_to_address: str = Field(..., description="Ethereum address to receive payments")
|
|
50
|
+
|
|
51
|
+
# Pricing configuration
|
|
52
|
+
# Can be per-path (e.g., {"/analyze": D402ServicePrice(...), "/extract": D402ServicePrice(...)})
|
|
53
|
+
# or default for all paths
|
|
54
|
+
path_prices: Dict[str, D402ServicePrice] = Field(
|
|
55
|
+
default_factory=dict,
|
|
56
|
+
description="Price configuration per path"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Default price (used if path not in path_prices)
|
|
60
|
+
default_price: Optional[D402ServicePrice] = Field(None, description="Default price for all paths")
|
|
61
|
+
|
|
62
|
+
# Legacy: Pricing per service/skill (deprecated, use path_prices)
|
|
63
|
+
skill_prices: Dict[str, D402ServicePrice] = Field(
|
|
64
|
+
default_factory=dict,
|
|
65
|
+
description="Custom prices per skill ID (deprecated)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Facilitator configuration
|
|
69
|
+
facilitator_url: str = Field(
|
|
70
|
+
default="https://api.traia.io/d402/facilitator",
|
|
71
|
+
description="URL of the d402 facilitator service"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Custom facilitator authentication (if needed)
|
|
75
|
+
facilitator_api_key: Optional[str] = Field(None, description="API key for facilitator")
|
|
76
|
+
|
|
77
|
+
# Paths to gate with payments (* for all)
|
|
78
|
+
protected_paths: list[str] = Field(
|
|
79
|
+
default_factory=lambda: ["*"],
|
|
80
|
+
description="Paths that require payment"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Service description for payment prompt
|
|
84
|
+
service_description: str = Field(..., description="Description shown in payment UI")
|
|
85
|
+
|
|
86
|
+
# Metadata
|
|
87
|
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class D402PaymentInfo(BaseModel):
|
|
91
|
+
"""Payment information for agent card discovery."""
|
|
92
|
+
|
|
93
|
+
enabled: bool = Field(..., description="Whether d402 is enabled")
|
|
94
|
+
payment_schemes: list[PaymentScheme] = Field(
|
|
95
|
+
default_factory=lambda: [PaymentScheme.EXACT],
|
|
96
|
+
description="Supported payment schemes"
|
|
97
|
+
)
|
|
98
|
+
networks: list[str] = Field(..., description="Supported blockchain networks")
|
|
99
|
+
default_price: D402ServicePrice = Field(..., description="Default pricing")
|
|
100
|
+
facilitator_url: str = Field(..., description="Facilitator service URL")
|
|
101
|
+
|
|
102
|
+
class Config:
|
|
103
|
+
use_enum_values = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class IATPSettlementRequest(BaseModel):
|
|
107
|
+
"""Request to settle a payment through IATP settlement layer."""
|
|
108
|
+
|
|
109
|
+
consumer: str = Field(..., description="Consumer address (client agent)")
|
|
110
|
+
provider: str = Field(..., description="Provider address (utility agent)")
|
|
111
|
+
amount: str = Field(..., description="Amount in atomic units")
|
|
112
|
+
timestamp: int = Field(..., description="Request timestamp")
|
|
113
|
+
service_description: str = Field(..., description="Description of service")
|
|
114
|
+
consumer_signature: str = Field(..., description="Consumer's EIP-712 signature")
|
|
115
|
+
provider_signature: str = Field(..., description="Provider's attestation signature")
|
|
116
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Network configuration for d402 payments.
|
|
2
|
+
|
|
3
|
+
This module defines supported networks and their token configurations.
|
|
4
|
+
Customized for IATP - uses database-driven network configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal, Dict, Any
|
|
8
|
+
from typing_extensions import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Network type definition
|
|
12
|
+
SupportedNetworks = Literal[
|
|
13
|
+
"sepolia",
|
|
14
|
+
"base-sepolia",
|
|
15
|
+
"arbitrum-sepolia",
|
|
16
|
+
"base-mainnet",
|
|
17
|
+
"arbitrum-mainnet",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NetworkConfig(TypedDict):
|
|
22
|
+
"""Network configuration."""
|
|
23
|
+
chain_id: int
|
|
24
|
+
name: str
|
|
25
|
+
rpc_url: str
|
|
26
|
+
explorer_url: str
|
|
27
|
+
usdc_address: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Network configurations
|
|
31
|
+
NETWORKS: Dict[str, NetworkConfig] = {
|
|
32
|
+
"sepolia": {
|
|
33
|
+
"chain_id": 11155111,
|
|
34
|
+
"name": "Ethereum Sepolia",
|
|
35
|
+
"rpc_url": "https://ethereum-sepolia-rpc.publicnode.com",
|
|
36
|
+
"explorer_url": "https://sepolia.etherscan.io",
|
|
37
|
+
"usdc_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
|
|
38
|
+
},
|
|
39
|
+
"base-sepolia": {
|
|
40
|
+
"chain_id": 84532,
|
|
41
|
+
"name": "Base Sepolia",
|
|
42
|
+
"rpc_url": "https://sepolia.base.org",
|
|
43
|
+
"explorer_url": "https://sepolia.basescan.org",
|
|
44
|
+
"usdc_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
45
|
+
},
|
|
46
|
+
"arbitrum-sepolia": {
|
|
47
|
+
"chain_id": 421614,
|
|
48
|
+
"name": "Arbitrum Sepolia",
|
|
49
|
+
"rpc_url": "https://arbitrum-sepolia-rpc.publicnode.com",
|
|
50
|
+
"explorer_url": "https://sepolia.arbiscan.io",
|
|
51
|
+
"usdc_address": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d"
|
|
52
|
+
},
|
|
53
|
+
"base-mainnet": {
|
|
54
|
+
"chain_id": 8453,
|
|
55
|
+
"name": "Base",
|
|
56
|
+
"rpc_url": "https://mainnet.base.org",
|
|
57
|
+
"explorer_url": "https://basescan.org",
|
|
58
|
+
"usdc_address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
59
|
+
},
|
|
60
|
+
"arbitrum-mainnet": {
|
|
61
|
+
"chain_id": 42161,
|
|
62
|
+
"name": "Arbitrum One",
|
|
63
|
+
"rpc_url": "https://arbitrum-one-rpc.publicnode.com",
|
|
64
|
+
"explorer_url": "https://arbiscan.io",
|
|
65
|
+
"usdc_address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_network_config(network: str) -> NetworkConfig:
|
|
71
|
+
"""Get configuration for a network."""
|
|
72
|
+
if network not in NETWORKS:
|
|
73
|
+
raise ValueError(f"Unsupported network: {network}")
|
|
74
|
+
return NETWORKS[network]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_usdc_address(network: str) -> str:
|
|
78
|
+
"""Get USDC address for a network."""
|
|
79
|
+
return get_network_config(network)["usdc_address"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_chain_id(network: str) -> int:
|
|
83
|
+
"""Get chain ID for a network."""
|
|
84
|
+
return get_network_config(network)["chain_id"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# TODO: Load from database network table
|
|
88
|
+
async def load_networks_from_db() -> Dict[str, NetworkConfig]:
|
|
89
|
+
"""Load network configurations from database.
|
|
90
|
+
|
|
91
|
+
This will query the Network table and build NETWORKS dict dynamically.
|
|
92
|
+
For now, returns static config.
|
|
93
|
+
"""
|
|
94
|
+
# from db.dal.models import Network
|
|
95
|
+
# networks = await db.query(Network).filter(Network.active == True).all()
|
|
96
|
+
# return {net.shortname: {...} for net in networks}
|
|
97
|
+
return NETWORKS
|
|
98
|
+
|
traia_iatp/d402/path.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import re
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def path_is_match(path: Union[str, list[str]], request_path: str) -> bool:
|
|
7
|
+
"""
|
|
8
|
+
Check if request path matches the specified path pattern(s).
|
|
9
|
+
|
|
10
|
+
Supports:
|
|
11
|
+
- Exact matching: "/api/users"
|
|
12
|
+
- Glob patterns: "/api/users/*", "/api/*/profile"
|
|
13
|
+
- Regex patterns (prefix with 'regex:'): "regex:^/api/users/\\d+$"
|
|
14
|
+
- List of any of the above
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
path: Path pattern(s) to match against. Can be a string or list of strings.
|
|
18
|
+
request_path: The actual request path to check.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
bool: True if the request path matches any of the patterns, False otherwise.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def single_path_match(pattern: str) -> bool:
|
|
25
|
+
# Regex pattern
|
|
26
|
+
if pattern.startswith("regex:"):
|
|
27
|
+
regex_pattern = pattern[6:] # Remove 'regex:' prefix
|
|
28
|
+
return bool(re.match(regex_pattern, request_path))
|
|
29
|
+
|
|
30
|
+
# Glob pattern (contains * or ?)
|
|
31
|
+
elif "*" in pattern or "?" in pattern:
|
|
32
|
+
return fnmatch.fnmatch(request_path, pattern)
|
|
33
|
+
|
|
34
|
+
# Exact match
|
|
35
|
+
else:
|
|
36
|
+
return pattern == request_path
|
|
37
|
+
|
|
38
|
+
if isinstance(path, str):
|
|
39
|
+
return single_path_match(path)
|
|
40
|
+
elif isinstance(path, list):
|
|
41
|
+
return any(single_path_match(p) for p in path)
|
|
42
|
+
|
|
43
|
+
return False
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper to extract payment configurations from @require_payment_for_tool decorators.
|
|
3
|
+
|
|
4
|
+
This allows us to have a single source of truth - payment config is declared
|
|
5
|
+
in the decorator, and we introspect it to build TOOL_PAYMENT_CONFIGS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
from .types import TokenAmount
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_payment_configs_from_mcp(mcp_server, server_address: str) -> Dict[str, Dict[str, Any]]:
|
|
17
|
+
"""
|
|
18
|
+
Extract payment configurations from tools decorated with @require_payment_for_tool.
|
|
19
|
+
|
|
20
|
+
This introspects the decorator closures to extract TokenAmount objects,
|
|
21
|
+
eliminating the need to duplicate payment configuration.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
mcp_server: FastMCP server instance
|
|
25
|
+
server_address: Server's payment address
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dict mapping tool names to payment configurations
|
|
29
|
+
Format: {"tool_name": {"price_wei": "1000", "token_address": "0x...", ...}}
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
mcp = FastMCP("Server")
|
|
33
|
+
|
|
34
|
+
# Add tools with @require_payment_for_tool decorators
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
@require_payment_for_tool(price=TokenAmount(...))
|
|
37
|
+
async def my_tool(context): ...
|
|
38
|
+
|
|
39
|
+
# Extract configs dynamically
|
|
40
|
+
TOOL_PAYMENT_CONFIGS = extract_payment_configs_from_mcp(mcp, SERVER_ADDRESS)
|
|
41
|
+
|
|
42
|
+
# Add middleware with extracted configs
|
|
43
|
+
app.add_middleware(D402PaymentMiddleware, tool_payment_configs=TOOL_PAYMENT_CONFIGS, ...)
|
|
44
|
+
"""
|
|
45
|
+
tool_payment_configs = {}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Get registered tools from FastMCP
|
|
49
|
+
tools = mcp_server._tool_manager.list_tools()
|
|
50
|
+
|
|
51
|
+
for tool in tools:
|
|
52
|
+
if not hasattr(tool, 'fn'):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
fn = tool.fn
|
|
56
|
+
tool_name = tool.name
|
|
57
|
+
|
|
58
|
+
# Check if function has payment metadata (from @require_payment_for_tool decorator)
|
|
59
|
+
# New approach: metadata stored as attribute (no closure needed)
|
|
60
|
+
if hasattr(fn, '_d402_payment_config'):
|
|
61
|
+
payment_config = fn._d402_payment_config
|
|
62
|
+
price = payment_config.get('price')
|
|
63
|
+
description = payment_config.get('description', tool_name)
|
|
64
|
+
|
|
65
|
+
if isinstance(price, TokenAmount):
|
|
66
|
+
token_amount = price
|
|
67
|
+
else:
|
|
68
|
+
logger.debug(f"Tool {tool_name}: price is not TokenAmount")
|
|
69
|
+
continue
|
|
70
|
+
# Fallback: Check closure for backwards compatibility
|
|
71
|
+
elif hasattr(fn, '__closure__') and fn.__closure__:
|
|
72
|
+
token_amount = None
|
|
73
|
+
description = None
|
|
74
|
+
for cell in fn.__closure__:
|
|
75
|
+
try:
|
|
76
|
+
val = cell.cell_contents
|
|
77
|
+
if isinstance(val, TokenAmount):
|
|
78
|
+
token_amount = val
|
|
79
|
+
elif isinstance(val, str):
|
|
80
|
+
description = val
|
|
81
|
+
except:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
if not token_amount:
|
|
85
|
+
logger.debug(f"Tool {tool_name}: No TokenAmount in closure")
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
description = description or tool.description or tool_name
|
|
89
|
+
else:
|
|
90
|
+
logger.debug(f"Tool {tool_name}: No payment metadata found")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if token_amount:
|
|
94
|
+
# Extract payment config from TokenAmount (including EIP712 domain)
|
|
95
|
+
# Use description from metadata if available, otherwise from tool
|
|
96
|
+
final_description = description if 'description' in locals() else (tool.description or tool_name)
|
|
97
|
+
|
|
98
|
+
config = {
|
|
99
|
+
"price_wei": token_amount.amount,
|
|
100
|
+
"token_address": token_amount.asset.address,
|
|
101
|
+
"network": token_amount.asset.network,
|
|
102
|
+
"server_address": server_address,
|
|
103
|
+
"description": final_description,
|
|
104
|
+
"eip712_domain": {
|
|
105
|
+
"name": token_amount.asset.eip712.name if token_amount.asset.eip712 else "USD Coin",
|
|
106
|
+
"version": token_amount.asset.eip712.version if token_amount.asset.eip712 else "2"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
tool_payment_configs[tool_name] = config
|
|
111
|
+
logger.info(f"✅ Extracted payment config for {tool_name}: {config['price_wei']} wei on {config['network']}")
|
|
112
|
+
else:
|
|
113
|
+
logger.debug(f"Tool {tool_name}: No TokenAmount found (free tool)")
|
|
114
|
+
|
|
115
|
+
logger.info(f"📊 Extracted {len(tool_payment_configs)} payment configs from decorators")
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Error extracting payment configs: {e}")
|
|
119
|
+
import traceback
|
|
120
|
+
logger.error(traceback.format_exc())
|
|
121
|
+
|
|
122
|
+
return tool_payment_configs
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["extract_payment_configs_from_mcp"]
|
|
126
|
+
|