traia-iatp 0.1.29__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.
Potentially problematic release.
This version of traia-iatp might be problematic. Click here for more details.
- traia_iatp/README.md +368 -0
- traia_iatp/__init__.py +54 -0
- traia_iatp/cli/__init__.py +5 -0
- traia_iatp/cli/main.py +483 -0
- traia_iatp/client/__init__.py +10 -0
- traia_iatp/client/a2a_client.py +274 -0
- traia_iatp/client/crewai_a2a_tools.py +335 -0
- traia_iatp/client/d402_a2a_client.py +293 -0
- traia_iatp/client/grpc_a2a_tools.py +349 -0
- traia_iatp/client/root_path_a2a_client.py +1 -0
- traia_iatp/contracts/__init__.py +12 -0
- traia_iatp/contracts/iatp_contracts_config.py +263 -0
- traia_iatp/contracts/wallet_creator.py +255 -0
- traia_iatp/core/__init__.py +43 -0
- traia_iatp/core/models.py +172 -0
- traia_iatp/d402/__init__.py +55 -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 +219 -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 +453 -0
- traia_iatp/d402/fastapi_middleware/__init__.py +6 -0
- traia_iatp/d402/fastapi_middleware/middleware.py +225 -0
- traia_iatp/d402/fastmcp_middleware.py +147 -0
- traia_iatp/d402/mcp_middleware.py +434 -0
- traia_iatp/d402/middleware.py +193 -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 +104 -0
- traia_iatp/d402/payment_signing.py +178 -0
- traia_iatp/d402/paywall.py +119 -0
- traia_iatp/d402/starlette_middleware.py +326 -0
- traia_iatp/d402/template.py +1 -0
- traia_iatp/d402/types.py +300 -0
- traia_iatp/mcp/__init__.py +18 -0
- traia_iatp/mcp/client.py +201 -0
- traia_iatp/mcp/d402_mcp_tool_adapter.py +361 -0
- traia_iatp/mcp/mcp_agent_template.py +481 -0
- traia_iatp/mcp/templates/Dockerfile.j2 +80 -0
- traia_iatp/mcp/templates/README.md.j2 +310 -0
- traia_iatp/mcp/templates/cursor-rules.md.j2 +520 -0
- traia_iatp/mcp/templates/deployment_params.json.j2 +20 -0
- traia_iatp/mcp/templates/docker-compose.yml.j2 +32 -0
- traia_iatp/mcp/templates/dockerignore.j2 +47 -0
- traia_iatp/mcp/templates/env.example.j2 +57 -0
- traia_iatp/mcp/templates/gitignore.j2 +77 -0
- traia_iatp/mcp/templates/mcp_health_check.py.j2 +150 -0
- traia_iatp/mcp/templates/pyproject.toml.j2 +32 -0
- traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
- traia_iatp/mcp/templates/run_local_docker.sh.j2 +390 -0
- traia_iatp/mcp/templates/server.py.j2 +175 -0
- traia_iatp/mcp/traia_mcp_adapter.py +543 -0
- traia_iatp/preview_diagrams.html +181 -0
- traia_iatp/registry/__init__.py +26 -0
- traia_iatp/registry/atlas_search_indexes.json +280 -0
- traia_iatp/registry/embeddings.py +298 -0
- traia_iatp/registry/iatp_search_api.py +846 -0
- traia_iatp/registry/mongodb_registry.py +771 -0
- traia_iatp/registry/readmes/ATLAS_SEARCH_INDEXES.md +252 -0
- traia_iatp/registry/readmes/ATLAS_SEARCH_SETUP.md +134 -0
- traia_iatp/registry/readmes/AUTHENTICATION_UPDATE.md +124 -0
- traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +172 -0
- traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +257 -0
- traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +208 -0
- traia_iatp/registry/readmes/README.md +251 -0
- traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +191 -0
- traia_iatp/scripts/__init__.py +2 -0
- traia_iatp/scripts/create_wallet.py +244 -0
- traia_iatp/server/__init__.py +15 -0
- traia_iatp/server/a2a_server.py +219 -0
- traia_iatp/server/example_template_usage.py +72 -0
- traia_iatp/server/iatp_server_agent_generator.py +237 -0
- traia_iatp/server/iatp_server_template_generator.py +235 -0
- traia_iatp/server/templates/.dockerignore.j2 +48 -0
- traia_iatp/server/templates/Dockerfile.j2 +49 -0
- traia_iatp/server/templates/README.md +137 -0
- traia_iatp/server/templates/README.md.j2 +425 -0
- traia_iatp/server/templates/__init__.py +1 -0
- traia_iatp/server/templates/__main__.py.j2 +565 -0
- traia_iatp/server/templates/agent.py.j2 +94 -0
- traia_iatp/server/templates/agent_config.json.j2 +22 -0
- traia_iatp/server/templates/agent_executor.py.j2 +279 -0
- traia_iatp/server/templates/docker-compose.yml.j2 +23 -0
- traia_iatp/server/templates/env.example.j2 +84 -0
- traia_iatp/server/templates/gitignore.j2 +78 -0
- traia_iatp/server/templates/grpc_server.py.j2 +218 -0
- traia_iatp/server/templates/pyproject.toml.j2 +78 -0
- traia_iatp/server/templates/run_local_docker.sh.j2 +103 -0
- traia_iatp/server/templates/server.py.j2 +243 -0
- traia_iatp/special_agencies/__init__.py +4 -0
- traia_iatp/special_agencies/registry_search_agency.py +392 -0
- traia_iatp/utils/__init__.py +10 -0
- traia_iatp/utils/docker_utils.py +251 -0
- traia_iatp/utils/general.py +64 -0
- traia_iatp/utils/iatp_utils.py +126 -0
- traia_iatp-0.1.29.dist-info/METADATA +423 -0
- traia_iatp-0.1.29.dist-info/RECORD +107 -0
- traia_iatp-0.1.29.dist-info/WHEEL +5 -0
- traia_iatp-0.1.29.dist-info/entry_points.txt +2 -0
- traia_iatp-0.1.29.dist-info/licenses/LICENSE +21 -0
- traia_iatp-0.1.29.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional, Callable, Dict, Any, List
|
|
3
|
+
from eth_account import Account
|
|
4
|
+
from ..payment_signing import sign_payment_header
|
|
5
|
+
from ..types import (
|
|
6
|
+
PaymentRequirements,
|
|
7
|
+
UnsupportedSchemeException,
|
|
8
|
+
)
|
|
9
|
+
from ..common import d402_VERSION
|
|
10
|
+
import secrets
|
|
11
|
+
from ..encoding import safe_base64_decode
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
# Define type for the payment requirements selector
|
|
15
|
+
PaymentSelectorCallable = Callable[
|
|
16
|
+
[List[PaymentRequirements], Optional[str], Optional[str], Optional[int]],
|
|
17
|
+
PaymentRequirements,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decode_x_payment_response(header: str) -> Dict[str, Any]:
|
|
22
|
+
"""Decode the X-PAYMENT-RESPONSE header.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
header: The X-PAYMENT-RESPONSE header to decode
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The decoded payment response containing:
|
|
29
|
+
- success: bool
|
|
30
|
+
- transaction: str (hex)
|
|
31
|
+
- network: str
|
|
32
|
+
- payer: str (address)
|
|
33
|
+
"""
|
|
34
|
+
decoded = safe_base64_decode(header)
|
|
35
|
+
result = json.loads(decoded)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PaymentError(Exception):
|
|
40
|
+
"""Base class for payment-related errors."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PaymentAmountExceededError(PaymentError):
|
|
46
|
+
"""Raised when payment amount exceeds maximum allowed value."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MissingRequestConfigError(PaymentError):
|
|
52
|
+
"""Raised when request configuration is missing."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PaymentAlreadyAttemptedError(PaymentError):
|
|
58
|
+
"""Raised when payment has already been attempted."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class d402Client:
|
|
64
|
+
"""Base client for handling d402 payments."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
operator_account: Account,
|
|
69
|
+
wallet_address: str = None,
|
|
70
|
+
max_value: Optional[int] = None,
|
|
71
|
+
payment_requirements_selector: Optional[PaymentSelectorCallable] = None,
|
|
72
|
+
):
|
|
73
|
+
"""Initialize the d402 client.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
operator_account: Operator account with private key for signing payments (EOA)
|
|
77
|
+
wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address for testing)
|
|
78
|
+
max_value: Optional safety limit for maximum payment amount per request in base units.
|
|
79
|
+
This is a global safety check that prevents paying more than intended.
|
|
80
|
+
WARNING: This is a simple numeric comparison and does NOT account for:
|
|
81
|
+
- Different tokens (USDC vs TRAIA vs others) - amounts are compared directly
|
|
82
|
+
- Token decimals - ensure max_value uses the same decimals as expected tokens
|
|
83
|
+
- Exchange rates - this is not a USD limit, it's a token amount limit
|
|
84
|
+
Each endpoint can have different payment requirements (amount and token),
|
|
85
|
+
but this limit applies to all requests. Set it based on your most expensive
|
|
86
|
+
expected payment in the token's base units.
|
|
87
|
+
If None, no limit is enforced (not recommended for production).
|
|
88
|
+
payment_requirements_selector: Optional custom selector for payment requirements
|
|
89
|
+
"""
|
|
90
|
+
self.operator_account = operator_account # Operator EOA for signing
|
|
91
|
+
self.wallet_address = wallet_address or operator_account.address # IATPWallet contract or EOA for testing
|
|
92
|
+
self.max_value = max_value
|
|
93
|
+
self._payment_requirements_selector = (
|
|
94
|
+
payment_requirements_selector or self.default_payment_requirements_selector
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def default_payment_requirements_selector(
|
|
99
|
+
accepts: List[PaymentRequirements],
|
|
100
|
+
network_filter: Optional[str] = None,
|
|
101
|
+
scheme_filter: Optional[str] = None,
|
|
102
|
+
max_value: Optional[int] = None,
|
|
103
|
+
) -> PaymentRequirements:
|
|
104
|
+
"""Select payment requirements from the list of accepted requirements.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
accepts: List of accepted payment requirements
|
|
108
|
+
network_filter: Optional network to filter by
|
|
109
|
+
scheme_filter: Optional scheme to filter by
|
|
110
|
+
max_value: Optional maximum allowed payment amount
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Selected payment requirements (PaymentRequirements instance from ..types)
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
UnsupportedSchemeException: If no supported scheme is found
|
|
117
|
+
PaymentAmountExceededError: If payment amount exceeds max_value
|
|
118
|
+
"""
|
|
119
|
+
for paymentRequirements in accepts:
|
|
120
|
+
scheme = paymentRequirements.scheme
|
|
121
|
+
network = paymentRequirements.network
|
|
122
|
+
|
|
123
|
+
# Check scheme filter
|
|
124
|
+
if scheme_filter and scheme != scheme_filter:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Check network filter
|
|
128
|
+
if network_filter and network != network_filter:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if scheme == "exact":
|
|
132
|
+
# Check max value if set
|
|
133
|
+
# NOTE: This is a simple numeric comparison. It does NOT account for:
|
|
134
|
+
# - Different tokens (USDC vs TRAIA vs others)
|
|
135
|
+
# - Token decimals differences
|
|
136
|
+
# - Exchange rates between tokens
|
|
137
|
+
# This is a safety limit to prevent accidentally paying too much.
|
|
138
|
+
# The comparison is done on the raw amount values in base units.
|
|
139
|
+
if max_value is not None:
|
|
140
|
+
max_amount = int(paymentRequirements.max_amount_required)
|
|
141
|
+
if max_amount > max_value:
|
|
142
|
+
raise PaymentAmountExceededError(
|
|
143
|
+
f"Payment amount {max_amount} (token: {paymentRequirements.asset}) "
|
|
144
|
+
f"exceeds maximum allowed value {max_value} base units. "
|
|
145
|
+
f"Note: This comparison does not account for token differences or decimals."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return paymentRequirements
|
|
149
|
+
|
|
150
|
+
raise UnsupportedSchemeException("No supported payment scheme found")
|
|
151
|
+
|
|
152
|
+
def select_payment_requirements(
|
|
153
|
+
self,
|
|
154
|
+
accepts: List[PaymentRequirements],
|
|
155
|
+
network_filter: Optional[str] = None,
|
|
156
|
+
scheme_filter: Optional[str] = None,
|
|
157
|
+
) -> PaymentRequirements:
|
|
158
|
+
"""Select payment requirements using the configured selector.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
accepts: List of accepted payment requirements (PaymentRequirements models)
|
|
162
|
+
network_filter: Optional network to filter by
|
|
163
|
+
scheme_filter: Optional scheme to filter by
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Selected payment requirements (PaymentRequirements instance from ..types)
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
UnsupportedSchemeException: If no supported scheme is found
|
|
170
|
+
PaymentAmountExceededError: If payment amount exceeds max_value
|
|
171
|
+
"""
|
|
172
|
+
return self._payment_requirements_selector(
|
|
173
|
+
accepts, network_filter, scheme_filter, self.max_value
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_payment_header(
|
|
178
|
+
self,
|
|
179
|
+
payment_requirements: PaymentRequirements,
|
|
180
|
+
d402_version: int = d402_VERSION,
|
|
181
|
+
request_path: str = None,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Create a payment header for the given requirements.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
payment_requirements: Selected payment requirements
|
|
187
|
+
d402_version: d402 protocol version
|
|
188
|
+
request_path: Optional API request path (if None, uses payment_requirements.resource)
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Signed payment header with PullFundsForSettlement signature
|
|
192
|
+
"""
|
|
193
|
+
unsigned_header = {
|
|
194
|
+
"d402Version": d402_version,
|
|
195
|
+
"scheme": payment_requirements.scheme,
|
|
196
|
+
"network": payment_requirements.network,
|
|
197
|
+
"payload": {
|
|
198
|
+
"signature": None,
|
|
199
|
+
"authorization": {
|
|
200
|
+
"from": self.wallet_address, # IATPWallet contract address
|
|
201
|
+
"to": payment_requirements.pay_to, # Provider's IATPWallet
|
|
202
|
+
"value": payment_requirements.max_amount_required,
|
|
203
|
+
"validAfter": str(int(time.time()) - 60), # 60 seconds before
|
|
204
|
+
"validBefore": str(
|
|
205
|
+
int(time.time()) + payment_requirements.max_timeout_seconds
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
signed_header = sign_payment_header(
|
|
212
|
+
self.operator_account,
|
|
213
|
+
payment_requirements,
|
|
214
|
+
unsigned_header,
|
|
215
|
+
wallet_address=self.wallet_address,
|
|
216
|
+
request_path=request_path or payment_requirements.resource
|
|
217
|
+
)
|
|
218
|
+
return signed_header
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""HTTPX client integration for d402 payments."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, Dict, List
|
|
5
|
+
from httpx import Request, Response, AsyncClient
|
|
6
|
+
from eth_account import Account
|
|
7
|
+
from functools import wraps
|
|
8
|
+
|
|
9
|
+
from .base import (
|
|
10
|
+
d402Client,
|
|
11
|
+
MissingRequestConfigError,
|
|
12
|
+
PaymentError,
|
|
13
|
+
PaymentSelectorCallable,
|
|
14
|
+
decode_x_payment_response,
|
|
15
|
+
)
|
|
16
|
+
from ..types import d402PaymentRequiredResponse
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpxHooks:
|
|
22
|
+
"""Event hooks for httpx client to handle d402 payments."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, client: d402Client):
|
|
25
|
+
self.client = client
|
|
26
|
+
self._is_retry = False
|
|
27
|
+
|
|
28
|
+
async def on_request(self, request: Request):
|
|
29
|
+
"""Handle request before it is sent."""
|
|
30
|
+
#TODO: (TBD) if mongodb had endpoints data for each mcp server then the client herre could apriori get the payment details and construct the payment payload.
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def on_response(self, response: Response) -> Response:
|
|
34
|
+
"""Handle response after it is received.
|
|
35
|
+
|
|
36
|
+
When a 402 Payment Required response is received:
|
|
37
|
+
1. Parse payment requirements from the response
|
|
38
|
+
2. Select appropriate payment option (token/network)
|
|
39
|
+
3. Create EIP-3009 signed payment authorization
|
|
40
|
+
4. Retry the original request with X-Payment header
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Log every response for debugging
|
|
44
|
+
logger.debug(f"d402 hook: on_response called - status={response.status_code}, url={response.url}")
|
|
45
|
+
|
|
46
|
+
# If this is not a 402, just return the response
|
|
47
|
+
if response.status_code != 402:
|
|
48
|
+
return response
|
|
49
|
+
|
|
50
|
+
# If this is a retry response, just return it (avoid infinite loop)
|
|
51
|
+
if self._is_retry:
|
|
52
|
+
logger.debug(f"d402 hook: This is a retry response, returning as-is")
|
|
53
|
+
return response
|
|
54
|
+
|
|
55
|
+
logger.info(f"🔔 d402 hook: Intercepted HTTP 402 - creating payment...")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
if not response.request:
|
|
59
|
+
raise MissingRequestConfigError("Missing request configuration")
|
|
60
|
+
|
|
61
|
+
# Read the response content before parsing
|
|
62
|
+
await response.aread()
|
|
63
|
+
|
|
64
|
+
# Parse payment requirements from 402 response
|
|
65
|
+
data = response.json()
|
|
66
|
+
payment_response = d402PaymentRequiredResponse(**data)
|
|
67
|
+
|
|
68
|
+
# Select payment requirements (matches token/network, checks max_value)
|
|
69
|
+
selected_requirements = self.client.select_payment_requirements(
|
|
70
|
+
payment_response.accepts
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# The server sets the resource field in payment_requirements
|
|
74
|
+
# This is the authoritative requestPath for the signature
|
|
75
|
+
# The server knows what endpoint is being called and sets it correctly
|
|
76
|
+
print(f"📍 DEBUG: Request path from server resource field: '{selected_requirements.resource}'")
|
|
77
|
+
|
|
78
|
+
# Create signed payment header using CLIENT's account
|
|
79
|
+
# Use payment_requirements.resource (don't override)
|
|
80
|
+
payment_header = self.client.create_payment_header(
|
|
81
|
+
selected_requirements,
|
|
82
|
+
payment_response.d402_version
|
|
83
|
+
# No request_path parameter - uses payment_requirements.resource
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Mark as retry to avoid infinite loop
|
|
87
|
+
self._is_retry = True
|
|
88
|
+
|
|
89
|
+
# Get the original request and add payment header
|
|
90
|
+
request = response.request
|
|
91
|
+
request.headers["X-Payment"] = payment_header
|
|
92
|
+
request.headers["Access-Control-Expose-Headers"] = "X-Payment-Response"
|
|
93
|
+
|
|
94
|
+
# Retry the request using the same httpx client that made the original request
|
|
95
|
+
# This ensures the retry uses the same client configuration (base_url, timeout, etc.)
|
|
96
|
+
# and that any hooks are still applied (via monkey-patch)
|
|
97
|
+
original_client = response.request.extensions.get("client")
|
|
98
|
+
if original_client and isinstance(original_client, AsyncClient):
|
|
99
|
+
# Use the original client for retry
|
|
100
|
+
retry_response = await original_client.send(request)
|
|
101
|
+
else:
|
|
102
|
+
# Fallback: create new client (will have hooks if monkey-patch is active)
|
|
103
|
+
async with AsyncClient() as new_client:
|
|
104
|
+
retry_response = await new_client.send(request)
|
|
105
|
+
|
|
106
|
+
# Copy the retry response data to the original response object
|
|
107
|
+
response.status_code = retry_response.status_code
|
|
108
|
+
response.headers = retry_response.headers
|
|
109
|
+
response._content = retry_response._content
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
except PaymentError as e:
|
|
113
|
+
self._is_retry = False
|
|
114
|
+
raise e
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self._is_retry = False
|
|
117
|
+
raise PaymentError(f"Failed to handle payment: {str(e)}") from e
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def d402_payment_hooks(
|
|
121
|
+
operator_account: Account,
|
|
122
|
+
wallet_address: str = None,
|
|
123
|
+
max_value: Optional[int] = None,
|
|
124
|
+
payment_requirements_selector: Optional[PaymentSelectorCallable] = None,
|
|
125
|
+
) -> Dict[str, List]:
|
|
126
|
+
"""Create httpx event hooks dictionary for handling 402 Payment Required responses.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
operator_account: Operator account with private key for signing payments (EOA)
|
|
130
|
+
wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address for testing)
|
|
131
|
+
max_value: Optional maximum allowed payment amount in base units
|
|
132
|
+
payment_requirements_selector: Optional custom selector for payment requirements.
|
|
133
|
+
Should be a callable that takes (accepts, network_filter, scheme_filter, max_value)
|
|
134
|
+
and returns a PaymentRequirements object.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dictionary of event hooks that can be directly assigned to client.event_hooks
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
```python
|
|
141
|
+
from eth_account import Account
|
|
142
|
+
from traia_iatp.d402.clients.httpx import d402_payment_hooks
|
|
143
|
+
import httpx
|
|
144
|
+
|
|
145
|
+
# For testing (uses EOA as wallet)
|
|
146
|
+
operator_account = Account.from_key("0x...")
|
|
147
|
+
client.event_hooks = d402_payment_hooks(operator_account)
|
|
148
|
+
|
|
149
|
+
# For production (with IATPWallet contract)
|
|
150
|
+
operator_account = Account.from_key("0x...") # Operator key
|
|
151
|
+
wallet = "0x..." # IATPWallet contract address
|
|
152
|
+
client.event_hooks = d402_payment_hooks(operator_account, wallet_address=wallet)
|
|
153
|
+
```
|
|
154
|
+
"""
|
|
155
|
+
# Create d402Client
|
|
156
|
+
client = d402Client(
|
|
157
|
+
operator_account,
|
|
158
|
+
wallet_address=wallet_address,
|
|
159
|
+
max_value=max_value,
|
|
160
|
+
payment_requirements_selector=payment_requirements_selector,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Create hooks
|
|
164
|
+
hooks = HttpxHooks(client)
|
|
165
|
+
|
|
166
|
+
# Return event hooks dictionary
|
|
167
|
+
return {
|
|
168
|
+
"request": [hooks.on_request],
|
|
169
|
+
"response": [hooks.on_response],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class d402HttpxClient(AsyncClient):
|
|
174
|
+
"""AsyncClient with built-in d402 payment handling."""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
operator_account: Account,
|
|
179
|
+
wallet_address: str = None,
|
|
180
|
+
max_value: Optional[int] = None,
|
|
181
|
+
payment_requirements_selector: Optional[PaymentSelectorCallable] = None,
|
|
182
|
+
**kwargs,
|
|
183
|
+
):
|
|
184
|
+
"""Initialize an AsyncClient with d402 payment handling.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
operator_account: Operator account with private key for signing payments (EOA)
|
|
188
|
+
wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address for testing)
|
|
189
|
+
max_value: Optional maximum allowed payment amount in base units
|
|
190
|
+
payment_requirements_selector: Optional custom selector for payment requirements.
|
|
191
|
+
Should be a callable that takes (accepts, network_filter, scheme_filter, max_value)
|
|
192
|
+
and returns a PaymentRequirements object.
|
|
193
|
+
**kwargs: Additional arguments to pass to AsyncClient
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
```python
|
|
197
|
+
from eth_account import Account
|
|
198
|
+
from traia_iatp.d402.clients.httpx import d402HttpxClient
|
|
199
|
+
|
|
200
|
+
# For testing (uses EOA as wallet)
|
|
201
|
+
operator_account = Account.from_key("0x...")
|
|
202
|
+
async with d402HttpxClient(operator_account, base_url="https://api.example.com") as client:
|
|
203
|
+
response = await client.get("/protected-endpoint")
|
|
204
|
+
|
|
205
|
+
# For production (with IATPWallet contract)
|
|
206
|
+
operator_account = Account.from_key("0x...") # Operator key
|
|
207
|
+
wallet = "0x..." # IATPWallet contract
|
|
208
|
+
async with d402HttpxClient(operator_account, wallet_address=wallet, base_url="https://api.example.com") as client:
|
|
209
|
+
response = await client.get("/protected-endpoint")
|
|
210
|
+
```
|
|
211
|
+
"""
|
|
212
|
+
super().__init__(**kwargs)
|
|
213
|
+
self.event_hooks = d402_payment_hooks(
|
|
214
|
+
operator_account, wallet_address, max_value, payment_requirements_selector
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = ["d402_payment_hooks", "d402HttpxClient", "HttpxHooks"]
|
|
219
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from .chains import (
|
|
5
|
+
get_chain_id,
|
|
6
|
+
get_token_decimals,
|
|
7
|
+
get_token_name,
|
|
8
|
+
get_token_version,
|
|
9
|
+
get_default_token_address,
|
|
10
|
+
)
|
|
11
|
+
from .types import Price, TokenAmount, PaymentRequirements, PaymentPayload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_money(amount: str | int, address: str, network: str) -> int:
|
|
15
|
+
"""Parse money string or int into int
|
|
16
|
+
|
|
17
|
+
Params:
|
|
18
|
+
amount: str | int - if int, should be the full amount including token specific decimals
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(amount, str):
|
|
21
|
+
if amount.startswith("$"):
|
|
22
|
+
amount = amount[1:]
|
|
23
|
+
decimal_amount = Decimal(amount)
|
|
24
|
+
|
|
25
|
+
chain_id = get_chain_id(network)
|
|
26
|
+
decimals = get_token_decimals(chain_id, address)
|
|
27
|
+
decimal_amount = decimal_amount * Decimal(10**decimals)
|
|
28
|
+
return int(decimal_amount)
|
|
29
|
+
return amount
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def process_price_to_atomic_amount(
|
|
33
|
+
price: Price, network: str
|
|
34
|
+
) -> tuple[str, str, dict[str, str]]:
|
|
35
|
+
"""Process a Price into atomic amount, asset address, and EIP-712 domain info
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
price: Either Money (USD string/int) or TokenAmount
|
|
39
|
+
network: Network identifier
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Tuple of (max_amount_required, asset_address, eip712_domain)
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If price format is invalid
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(price, (str, int)):
|
|
48
|
+
# Money type - convert USD to USDC atomic units
|
|
49
|
+
try:
|
|
50
|
+
if isinstance(price, str) and price.startswith("$"):
|
|
51
|
+
price = price[1:]
|
|
52
|
+
amount = Decimal(str(price))
|
|
53
|
+
|
|
54
|
+
# Get USDC address for the network
|
|
55
|
+
chain_id = get_chain_id(network)
|
|
56
|
+
asset_address = get_usdc_address(chain_id)
|
|
57
|
+
decimals = get_token_decimals(chain_id, asset_address)
|
|
58
|
+
|
|
59
|
+
# Convert to atomic units
|
|
60
|
+
atomic_amount = int(amount * Decimal(10**decimals))
|
|
61
|
+
|
|
62
|
+
# Get EIP-712 domain info
|
|
63
|
+
eip712_domain = {
|
|
64
|
+
"name": get_token_name(chain_id, asset_address),
|
|
65
|
+
"version": get_token_version(chain_id, asset_address),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return str(atomic_amount), asset_address, eip712_domain
|
|
69
|
+
|
|
70
|
+
except (ValueError, KeyError) as e:
|
|
71
|
+
raise ValueError(f"Invalid price format: {price}. Error: {e}")
|
|
72
|
+
|
|
73
|
+
elif isinstance(price, TokenAmount):
|
|
74
|
+
# TokenAmount type - already in atomic units with asset info
|
|
75
|
+
return (
|
|
76
|
+
price.amount,
|
|
77
|
+
price.asset.address,
|
|
78
|
+
{
|
|
79
|
+
"name": price.asset.eip712.name,
|
|
80
|
+
"version": price.asset.eip712.version,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"Invalid price type: {type(price)}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_usdc_address(chain_id: int | str) -> str:
|
|
89
|
+
"""Get the USDC contract address for a given chain ID"""
|
|
90
|
+
chain_id_str = str(chain_id) # Convert to string for consistency
|
|
91
|
+
return get_default_token_address(chain_id_str, "usdc")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_matching_payment_requirements(
|
|
95
|
+
payment_requirements: List[PaymentRequirements],
|
|
96
|
+
payment: PaymentPayload,
|
|
97
|
+
) -> Optional[PaymentRequirements]:
|
|
98
|
+
"""
|
|
99
|
+
Finds the matching payment requirements for the given payment.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
payment_requirements: The payment requirements to search through
|
|
103
|
+
payment: The payment to match against
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The matching payment requirements or None if no match is found
|
|
107
|
+
"""
|
|
108
|
+
for req in payment_requirements:
|
|
109
|
+
if req.scheme == payment.scheme and req.network == payment.network:
|
|
110
|
+
return req
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
d402_VERSION = 1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def safe_base64_encode(data: Union[str, bytes]) -> str:
|
|
6
|
+
"""Safely encode string or bytes to base64 string.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
data: String or bytes to encode
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
Base64 encoded string
|
|
13
|
+
"""
|
|
14
|
+
if isinstance(data, str):
|
|
15
|
+
data = data.encode("utf-8")
|
|
16
|
+
return base64.b64encode(data).decode("utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_base64_decode(data: str) -> str:
|
|
20
|
+
"""Safely decode base64 string to bytes and then to utf-8 string.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
data: Base64 encoded string
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Decoded utf-8 string
|
|
27
|
+
"""
|
|
28
|
+
return base64.b64decode(data).decode("utf-8")
|