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