traia-iatp 0.1.2__py3-none-any.whl → 0.1.67__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. traia_iatp/__init__.py +105 -8
  2. traia_iatp/cli/main.py +85 -1
  3. traia_iatp/client/__init__.py +28 -3
  4. traia_iatp/client/crewai_a2a_tools.py +32 -12
  5. traia_iatp/client/d402_a2a_client.py +348 -0
  6. traia_iatp/contracts/__init__.py +11 -0
  7. traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
  8. traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
  9. traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
  10. traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
  11. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  12. traia_iatp/contracts/wallet_creator.py +369 -0
  13. traia_iatp/core/models.py +17 -3
  14. traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
  15. traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
  16. traia_iatp/d402/README.md +489 -0
  17. traia_iatp/d402/__init__.py +54 -0
  18. traia_iatp/d402/asgi_wrapper.py +469 -0
  19. traia_iatp/d402/chains.py +102 -0
  20. traia_iatp/d402/client.py +150 -0
  21. traia_iatp/d402/clients/__init__.py +7 -0
  22. traia_iatp/d402/clients/base.py +218 -0
  23. traia_iatp/d402/clients/httpx.py +266 -0
  24. traia_iatp/d402/common.py +114 -0
  25. traia_iatp/d402/encoding.py +28 -0
  26. traia_iatp/d402/examples/client_example.py +197 -0
  27. traia_iatp/d402/examples/server_example.py +171 -0
  28. traia_iatp/d402/facilitator.py +481 -0
  29. traia_iatp/d402/mcp_middleware.py +296 -0
  30. traia_iatp/d402/models.py +116 -0
  31. traia_iatp/d402/networks.py +98 -0
  32. traia_iatp/d402/path.py +43 -0
  33. traia_iatp/d402/payment_introspection.py +126 -0
  34. traia_iatp/d402/payment_signing.py +183 -0
  35. traia_iatp/d402/price_builder.py +164 -0
  36. traia_iatp/d402/servers/__init__.py +61 -0
  37. traia_iatp/d402/servers/base.py +139 -0
  38. traia_iatp/d402/servers/example_general_server.py +140 -0
  39. traia_iatp/d402/servers/fastapi.py +253 -0
  40. traia_iatp/d402/servers/mcp.py +304 -0
  41. traia_iatp/d402/servers/starlette.py +878 -0
  42. traia_iatp/d402/starlette_middleware.py +529 -0
  43. traia_iatp/d402/types.py +300 -0
  44. traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
  45. traia_iatp/mcp/__init__.py +3 -0
  46. traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
  47. traia_iatp/mcp/mcp_agent_template.py +78 -13
  48. traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
  49. traia_iatp/mcp/templates/README.md.j2 +104 -8
  50. traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
  51. traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
  52. traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
  53. traia_iatp/mcp/templates/env.example.j2 +60 -0
  54. traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
  55. traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
  56. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  57. traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
  58. traia_iatp/mcp/templates/server.py.j2 +174 -197
  59. traia_iatp/mcp/traia_mcp_adapter.py +182 -20
  60. traia_iatp/registry/__init__.py +47 -12
  61. traia_iatp/registry/atlas_search_indexes.json +108 -54
  62. traia_iatp/registry/iatp_search_api.py +169 -39
  63. traia_iatp/registry/mongodb_registry.py +241 -69
  64. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
  65. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
  66. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
  67. traia_iatp/registry/readmes/README.md +3 -3
  68. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
  69. traia_iatp/scripts/__init__.py +2 -0
  70. traia_iatp/scripts/create_wallet.py +244 -0
  71. traia_iatp/server/a2a_server.py +22 -7
  72. traia_iatp/server/iatp_server_template_generator.py +23 -0
  73. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  74. traia_iatp/server/templates/Dockerfile.j2 +23 -1
  75. traia_iatp/server/templates/README.md +2 -2
  76. traia_iatp/server/templates/README.md.j2 +5 -5
  77. traia_iatp/server/templates/__main__.py.j2 +374 -66
  78. traia_iatp/server/templates/agent.py.j2 +12 -11
  79. traia_iatp/server/templates/agent_config.json.j2 +3 -3
  80. traia_iatp/server/templates/agent_executor.py.j2 +45 -27
  81. traia_iatp/server/templates/env.example.j2 +32 -4
  82. traia_iatp/server/templates/gitignore.j2 +7 -0
  83. traia_iatp/server/templates/pyproject.toml.j2 +13 -12
  84. traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
  85. traia_iatp/server/templates/server.py.j2 +197 -10
  86. traia_iatp/special_agencies/registry_search_agency.py +1 -1
  87. traia_iatp/utils/iatp_utils.py +6 -6
  88. traia_iatp-0.1.67.dist-info/METADATA +320 -0
  89. traia_iatp-0.1.67.dist-info/RECORD +117 -0
  90. traia_iatp-0.1.2.dist-info/METADATA +0 -414
  91. traia_iatp-0.1.2.dist-info/RECORD +0 -72
  92. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
  93. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
  94. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
  95. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,150 @@
1
+ """D402 client for IATP agent-to-agent payments."""
2
+
3
+ import logging
4
+ from typing import Optional, Dict, Any
5
+ from eth_account import Account
6
+ from .clients.base import d402Client
7
+ from .types import PaymentRequirements
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class D402IATPClient:
13
+ """Client for making d402 payments in IATP protocol.
14
+
15
+ This wraps the Coinbase d402 client and provides IATP-specific functionality,
16
+ including integration with utility agent smart contracts.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ account: Account,
22
+ max_value: Optional[int] = None,
23
+ agent_contract_address: Optional[str] = None,
24
+ operator_private_key: Optional[str] = None
25
+ ):
26
+ """Initialize the d402 IATP client.
27
+
28
+ Args:
29
+ account: eth_account.Account instance for signing payments
30
+ max_value: Optional maximum allowed payment amount in base units
31
+ agent_contract_address: Optional address of the client agent's smart contract
32
+ operator_private_key: Optional operator private key for signing service requests
33
+ """
34
+ self.account = account
35
+ self.max_value = max_value
36
+ self.agent_contract_address = agent_contract_address
37
+ self.operator_private_key = operator_private_key
38
+
39
+ # Initialize the underlying d402 client
40
+ self.d402_client = d402Client(
41
+ operator_account=account,
42
+ max_value=max_value
43
+ )
44
+
45
+ def create_payment_header(
46
+ self,
47
+ payment_requirements: PaymentRequirements,
48
+ d402_version: int = 1
49
+ ) -> str:
50
+ """Create a payment header for the given requirements.
51
+
52
+ This creates an EIP-3009 signed payment authorization that the facilitator
53
+ can use to pull funds from the client agent's wallet.
54
+
55
+ Args:
56
+ payment_requirements: Selected payment requirements from server
57
+ d402_version: d402 protocol version
58
+
59
+ Returns:
60
+ Base64-encoded signed payment header for X-PAYMENT header
61
+ """
62
+ return self.d402_client.create_payment_header(
63
+ payment_requirements=payment_requirements,
64
+ d402_version=d402_version
65
+ )
66
+
67
+ def select_payment_requirements(
68
+ self,
69
+ accepts: list[PaymentRequirements],
70
+ network_filter: Optional[str] = None,
71
+ scheme_filter: Optional[str] = "exact"
72
+ ) -> PaymentRequirements:
73
+ """Select payment requirements from available options.
74
+
75
+ Args:
76
+ accepts: List of accepted payment requirements from server
77
+ network_filter: Optional network to filter by
78
+ scheme_filter: Optional scheme to filter by (default: "exact")
79
+
80
+ Returns:
81
+ Selected payment requirements
82
+
83
+ Raises:
84
+ UnsupportedSchemeException: If no supported scheme found
85
+ PaymentAmountExceededError: If amount exceeds max_value
86
+ """
87
+ return self.d402_client.select_payment_requirements(
88
+ accepts=accepts,
89
+ network_filter=network_filter,
90
+ scheme_filter=scheme_filter
91
+ )
92
+
93
+ def get_payment_info_for_agent_card(self, agent_card: dict) -> Optional[Dict[str, Any]]:
94
+ """Extract d402 payment information from an agent card.
95
+
96
+ Args:
97
+ agent_card: Agent card dictionary
98
+
99
+ Returns:
100
+ Payment information if available, None otherwise
101
+ """
102
+ metadata = agent_card.get("metadata", {})
103
+ return metadata.get("d402")
104
+
105
+
106
+ def create_iatp_payment_client(
107
+ private_key: str,
108
+ max_value_usd: Optional[float] = None,
109
+ agent_contract_address: Optional[str] = None
110
+ ) -> D402IATPClient:
111
+ """Convenience function to create an IATP payment client.
112
+
113
+ Args:
114
+ private_key: Hex-encoded private key (with or without 0x prefix)
115
+ max_value_usd: Optional maximum payment in USD
116
+ agent_contract_address: Optional agent contract address
117
+
118
+ Returns:
119
+ Configured D402IATPClient
120
+
121
+ Example:
122
+ client = create_iatp_payment_client(
123
+ private_key="0x...",
124
+ max_value_usd=10.0 # Max $10 per request
125
+ )
126
+
127
+ # Use with httpx
128
+ from .clients.httpx import Httpd402Client
129
+ http_client = Httpd402Client(client)
130
+ response = await http_client.get("https://agent.example.com/api")
131
+ """
132
+ # Remove 0x prefix if present
133
+ if private_key.startswith("0x"):
134
+ private_key = private_key[2:]
135
+
136
+ # Create eth_account.Account
137
+ account = Account.from_key(private_key)
138
+
139
+ # Convert USD to atomic units (assuming USDC with 6 decimals)
140
+ max_value = None
141
+ if max_value_usd is not None:
142
+ max_value = int(max_value_usd * 1_000_000) # USDC has 6 decimals
143
+
144
+ return D402IATPClient(
145
+ account=account,
146
+ max_value=max_value,
147
+ agent_contract_address=agent_contract_address,
148
+ operator_private_key=private_key
149
+ )
150
+
@@ -0,0 +1,7 @@
1
+ """D402 client implementations."""
2
+
3
+ from .base import d402Client, decode_x_payment_response
4
+ from .httpx import d402_payment_hooks, d402HttpxClient
5
+
6
+ __all__ = ["d402Client", "decode_x_payment_response", "d402_payment_hooks", "d402HttpxClient"]
7
+
@@ -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,266 @@
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, httpx_client: AsyncClient = None):
25
+ self.client = client
26
+ self.httpx_client = httpx_client # Reference to the httpx client for retries
27
+ self._is_retry = False
28
+
29
+ async def on_request(self, request: Request):
30
+ """Handle request before it is sent."""
31
+ #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.
32
+ pass
33
+
34
+ async def on_response(self, response: Response) -> Response:
35
+ """Handle response after it is received.
36
+
37
+ When a 402 Payment Required response is received:
38
+ 1. Parse payment requirements from the response
39
+ 2. Select appropriate payment option (token/network)
40
+ 3. Create EIP-3009 signed payment authorization
41
+ 4. Retry the original request with X-Payment header
42
+ """
43
+
44
+ # Log every response for debugging
45
+ logger.debug(f"d402 hook: on_response called - status={response.status_code}, url={response.url}")
46
+
47
+ # If this is not a 402, just return the response
48
+ if response.status_code != 402:
49
+ return response
50
+
51
+ # If this is a retry response, just return it (avoid infinite loop)
52
+ if self._is_retry:
53
+ logger.debug(f"d402 hook: This is a retry response, returning as-is")
54
+ return response
55
+
56
+ logger.info(f"🔔 d402 hook: Intercepted HTTP 402 - creating payment...")
57
+
58
+ try:
59
+ if not response.request:
60
+ raise MissingRequestConfigError("Missing request configuration")
61
+
62
+ # Read the response content before parsing
63
+ await response.aread()
64
+
65
+ # Parse payment requirements from 402 response
66
+ data = response.json()
67
+ payment_response = d402PaymentRequiredResponse(**data)
68
+
69
+ # Select payment requirements (matches token/network, checks max_value)
70
+ selected_requirements = self.client.select_payment_requirements(
71
+ payment_response.accepts
72
+ )
73
+
74
+ # The server sets the resource field in payment_requirements
75
+ # This is the authoritative requestPath for the signature
76
+ # The server knows what endpoint is being called and sets it correctly
77
+ print(f"📍 DEBUG: Request path from server resource field: '{selected_requirements.resource}'")
78
+ logger.info(f"💳 Creating payment header for retry...")
79
+
80
+ # Create signed payment header using CLIENT's account
81
+ # Use payment_requirements.resource (don't override)
82
+ payment_header = self.client.create_payment_header(
83
+ selected_requirements,
84
+ payment_response.d402_version
85
+ # No request_path parameter - uses payment_requirements.resource
86
+ )
87
+
88
+ # Mark as retry to avoid infinite loop
89
+ self._is_retry = True
90
+ logger.info(f"💳 Payment header created, preparing retry...")
91
+
92
+ # Get the original request and add payment header
93
+ request = response.request
94
+ request.headers["X-Payment"] = payment_header
95
+ request.headers["Access-Control-Expose-Headers"] = "X-Payment-Response"
96
+ logger.info(f"💳 Added X-Payment header to request")
97
+
98
+ # Retry the request using the httpx client
99
+ # Priority:
100
+ # 1. self.httpx_client (if set by d402HttpxClient)
101
+ # 2. response.request.extensions.get("client") (if available)
102
+ # 3. Fallback: create new client (may lose hooks)
103
+
104
+ retry_client = None
105
+ if self.httpx_client:
106
+ logger.info(f"💳 Using self.httpx_client for retry (d402HttpxClient)")
107
+ print(f"💳 Using d402HttpxClient instance for retry")
108
+ retry_client = self.httpx_client
109
+ else:
110
+ original_client = response.request.extensions.get("client")
111
+ logger.info(f"💳 original_client from extensions: {original_client is not None}")
112
+
113
+ if original_client and isinstance(original_client, AsyncClient):
114
+ logger.info(f"💳 Using original client from extensions...")
115
+ retry_client = original_client
116
+
117
+ if retry_client:
118
+ logger.info(f"💳 Retrying with client that has hooks...")
119
+ retry_response = await retry_client.send(request)
120
+ else:
121
+ # No client with hooks available - can't retry safely
122
+ # Just return the 402 response as-is
123
+ logger.error(f"❌ No client available for retry, cannot handle payment")
124
+ print(f"❌ Cannot retry payment - no httpx client with hooks available")
125
+ self._is_retry = False
126
+ return response
127
+
128
+ logger.info(f"💳 Retry response received: HTTP {retry_response.status_code}")
129
+ print(f"💳 RETRY RESPONSE: HTTP {retry_response.status_code}")
130
+
131
+ # Copy the retry response data to the original response object
132
+ response.status_code = retry_response.status_code
133
+ response.headers = retry_response.headers
134
+ response._content = retry_response._content
135
+
136
+ logger.info(f"✅ Payment handling complete, returning response")
137
+ print(f"✅ Payment retry complete, returning HTTP {response.status_code}")
138
+ return response
139
+
140
+ except PaymentError as e:
141
+ self._is_retry = False
142
+ print(f"❌ PaymentError in hook: {e}")
143
+ raise e
144
+ except Exception as e:
145
+ self._is_retry = False
146
+ print(f"❌ Exception in payment hook: {e}")
147
+ import traceback
148
+ traceback.print_exc()
149
+ raise PaymentError(f"Failed to handle payment: {str(e)}") from e
150
+
151
+
152
+ def d402_payment_hooks(
153
+ operator_account: Account,
154
+ wallet_address: str = None,
155
+ max_value: Optional[int] = None,
156
+ payment_requirements_selector: Optional[PaymentSelectorCallable] = None,
157
+ httpx_client: AsyncClient = None,
158
+ ) -> Dict[str, List]:
159
+ """Create httpx event hooks dictionary for handling 402 Payment Required responses.
160
+
161
+ Args:
162
+ operator_account: Operator account with private key for signing payments (EOA)
163
+ wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address for testing)
164
+ max_value: Optional maximum allowed payment amount in base units
165
+ payment_requirements_selector: Optional custom selector for payment requirements.
166
+ Should be a callable that takes (accepts, network_filter, scheme_filter, max_value)
167
+ and returns a PaymentRequirements object.
168
+
169
+ Returns:
170
+ Dictionary of event hooks that can be directly assigned to client.event_hooks
171
+
172
+ Example:
173
+ ```python
174
+ from eth_account import Account
175
+ from traia_iatp.d402.clients.httpx import d402_payment_hooks
176
+ import httpx
177
+
178
+ # For testing (uses EOA as wallet)
179
+ operator_account = Account.from_key("0x...")
180
+ client.event_hooks = d402_payment_hooks(operator_account)
181
+
182
+ # For production (with IATPWallet contract)
183
+ operator_account = Account.from_key("0x...") # Operator key
184
+ wallet = "0x..." # IATPWallet contract address
185
+ client.event_hooks = d402_payment_hooks(operator_account, wallet_address=wallet)
186
+ ```
187
+ """
188
+ # Create d402Client
189
+ client = d402Client(
190
+ operator_account,
191
+ wallet_address=wallet_address,
192
+ max_value=max_value,
193
+ payment_requirements_selector=payment_requirements_selector,
194
+ )
195
+
196
+ # Create hooks with optional httpx_client reference
197
+ hooks = HttpxHooks(client, httpx_client=httpx_client)
198
+
199
+ # Return event hooks dictionary
200
+ return {
201
+ "request": [hooks.on_request],
202
+ "response": [hooks.on_response],
203
+ }
204
+
205
+
206
+ class d402HttpxClient(AsyncClient):
207
+ """AsyncClient with built-in d402 payment handling."""
208
+
209
+ def __init__(
210
+ self,
211
+ operator_account: Account,
212
+ wallet_address: str = None,
213
+ max_value: Optional[int] = None,
214
+ payment_requirements_selector: Optional[PaymentSelectorCallable] = None,
215
+ **kwargs,
216
+ ):
217
+ """Initialize an AsyncClient with d402 payment handling.
218
+
219
+ Args:
220
+ operator_account: Operator account with private key for signing payments (EOA)
221
+ wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address for testing)
222
+ max_value: Optional maximum allowed payment amount in base units
223
+ payment_requirements_selector: Optional custom selector for payment requirements.
224
+ Should be a callable that takes (accepts, network_filter, scheme_filter, max_value)
225
+ and returns a PaymentRequirements object.
226
+ **kwargs: Additional arguments to pass to AsyncClient
227
+
228
+ Example:
229
+ ```python
230
+ from eth_account import Account
231
+ from traia_iatp.d402.clients.httpx import d402HttpxClient
232
+
233
+ # For testing (uses EOA as wallet)
234
+ operator_account = Account.from_key("0x...")
235
+ async with d402HttpxClient(operator_account, base_url="https://api.example.com") as client:
236
+ response = await client.get("/protected-endpoint")
237
+
238
+ # For production (with IATPWallet contract)
239
+ operator_account = Account.from_key("0x...") # Operator key
240
+ wallet = "0x..." # IATPWallet contract
241
+ async with d402HttpxClient(operator_account, wallet_address=wallet, base_url="https://api.example.com") as client:
242
+ response = await client.get("/protected-endpoint")
243
+ ```
244
+ """
245
+ super().__init__(**kwargs)
246
+
247
+ # Create d402Client
248
+ payment_client = d402Client(
249
+ operator_account,
250
+ wallet_address=wallet_address,
251
+ max_value=max_value,
252
+ payment_requirements_selector=payment_requirements_selector,
253
+ )
254
+
255
+ # Create hooks with reference to this httpx client
256
+ hooks = HttpxHooks(payment_client, httpx_client=self)
257
+
258
+ # Set event hooks
259
+ self.event_hooks = {
260
+ "request": [hooks.on_request],
261
+ "response": [hooks.on_response],
262
+ }
263
+
264
+
265
+ __all__ = ["d402_payment_hooks", "d402HttpxClient", "HttpxHooks"]
266
+