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,453 @@
|
|
|
1
|
+
"""Custom d402 facilitator that interfaces with IATP Settlement Layer."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import httpx
|
|
7
|
+
from eth_account import Account
|
|
8
|
+
from web3 import Web3
|
|
9
|
+
from eth_account.messages import encode_defunct
|
|
10
|
+
|
|
11
|
+
from .types import (
|
|
12
|
+
PaymentPayload,
|
|
13
|
+
PaymentRequirements,
|
|
14
|
+
VerifyResponse,
|
|
15
|
+
SettleResponse
|
|
16
|
+
)
|
|
17
|
+
from .models import IATPSettlementRequest
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IATPSettlementFacilitator:
|
|
23
|
+
"""Custom d402 facilitator that settles payments through IATP Settlement Layer.
|
|
24
|
+
|
|
25
|
+
This facilitator verifies d402 payment headers and then submits settlement
|
|
26
|
+
requests to the on-chain IATP Settlement Layer contract via a relayer service.
|
|
27
|
+
|
|
28
|
+
Flow:
|
|
29
|
+
1. Utility agent receives request with X-PAYMENT header
|
|
30
|
+
2. Facilitator verifies the payment signature and authorization
|
|
31
|
+
3. Utility agent processes the request
|
|
32
|
+
4. Facilitator settles the payment by submitting to relayer
|
|
33
|
+
5. Relayer submits to IATPSettlementLayer.sol on-chain
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
relayer_url: str,
|
|
39
|
+
relayer_api_key: Optional[str] = None,
|
|
40
|
+
provider_operator_key: Optional[str] = None,
|
|
41
|
+
web3_provider: Optional[str] = None,
|
|
42
|
+
facilitator_url: Optional[str] = None,
|
|
43
|
+
facilitator_api_key: Optional[str] = None
|
|
44
|
+
):
|
|
45
|
+
"""Initialize the IATP Settlement Facilitator.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
relayer_url: URL of the Traia relayer service
|
|
49
|
+
relayer_api_key: Optional API key for relayer authentication
|
|
50
|
+
provider_operator_key: Operator private key for provider attestation
|
|
51
|
+
web3_provider: Optional Web3 provider URL for direct blockchain interaction
|
|
52
|
+
facilitator_url: Optional external facilitator URL for verification (if None, uses local verification)
|
|
53
|
+
facilitator_api_key: Optional API key for external facilitator
|
|
54
|
+
"""
|
|
55
|
+
self.relayer_url = relayer_url.rstrip("/")
|
|
56
|
+
self.relayer_api_key = relayer_api_key
|
|
57
|
+
self.provider_operator_key = provider_operator_key
|
|
58
|
+
self.facilitator_url = facilitator_url.rstrip("/") if facilitator_url else None
|
|
59
|
+
self.facilitator_api_key = facilitator_api_key
|
|
60
|
+
|
|
61
|
+
# Initialize Web3 if provider is given
|
|
62
|
+
self.w3 = Web3(Web3.HTTPProvider(web3_provider)) if web3_provider else None
|
|
63
|
+
|
|
64
|
+
# Initialize operator account if key provided
|
|
65
|
+
self.operator_account = None
|
|
66
|
+
if provider_operator_key:
|
|
67
|
+
if provider_operator_key.startswith("0x"):
|
|
68
|
+
provider_operator_key = provider_operator_key[2:]
|
|
69
|
+
self.operator_account = Account.from_key(provider_operator_key)
|
|
70
|
+
|
|
71
|
+
async def verify(
|
|
72
|
+
self,
|
|
73
|
+
payment: PaymentPayload,
|
|
74
|
+
payment_requirements: PaymentRequirements
|
|
75
|
+
) -> VerifyResponse:
|
|
76
|
+
"""Verify a payment header is valid.
|
|
77
|
+
|
|
78
|
+
If facilitator_url is configured, calls external facilitator /verify endpoint
|
|
79
|
+
which returns a payment_uuid. Otherwise performs local verification.
|
|
80
|
+
|
|
81
|
+
This checks:
|
|
82
|
+
1. Signature is valid
|
|
83
|
+
2. Authorization is not expired
|
|
84
|
+
3. Amount matches requirements
|
|
85
|
+
4. From address has sufficient balance
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
payment: Payment payload from X-PAYMENT header
|
|
89
|
+
payment_requirements: Payment requirements from server
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
VerifyResponse with validation result and payment_uuid (if from external facilitator)
|
|
93
|
+
"""
|
|
94
|
+
# If external facilitator URL is configured, call it
|
|
95
|
+
if self.facilitator_url:
|
|
96
|
+
try:
|
|
97
|
+
headers = {"Content-Type": "application/json"}
|
|
98
|
+
if self.facilitator_api_key:
|
|
99
|
+
headers["Authorization"] = f"Bearer {self.facilitator_api_key}"
|
|
100
|
+
|
|
101
|
+
verify_request = {
|
|
102
|
+
"paymentPayload": payment.model_dump(by_alias=True),
|
|
103
|
+
"paymentRequirements": payment_requirements.model_dump(by_alias=True)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
107
|
+
response = await client.post(
|
|
108
|
+
f"{self.facilitator_url}/verify",
|
|
109
|
+
json=verify_request,
|
|
110
|
+
headers=headers
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if response.status_code == 200:
|
|
114
|
+
result = response.json()
|
|
115
|
+
# Parse response and extract payment_uuid
|
|
116
|
+
verify_response = VerifyResponse(**result)
|
|
117
|
+
if verify_response.payment_uuid:
|
|
118
|
+
logger.info(f"Payment verified via facilitator with payment_uuid: {verify_response.payment_uuid[:20]}...")
|
|
119
|
+
return verify_response
|
|
120
|
+
else:
|
|
121
|
+
error_msg = f"Facilitator verify error: {response.status_code} - {response.text}"
|
|
122
|
+
logger.error(error_msg)
|
|
123
|
+
return VerifyResponse(
|
|
124
|
+
is_valid=False,
|
|
125
|
+
invalid_reason=error_msg,
|
|
126
|
+
payer=None,
|
|
127
|
+
payment_uuid=None
|
|
128
|
+
)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Error calling external facilitator verify: {e}")
|
|
131
|
+
# Fall back to local verification
|
|
132
|
+
logger.warning("Falling back to local verification")
|
|
133
|
+
|
|
134
|
+
# Local verification (fallback or if no facilitator_url configured)
|
|
135
|
+
try:
|
|
136
|
+
# Extract payment details
|
|
137
|
+
if payment.scheme != "exact":
|
|
138
|
+
return VerifyResponse(
|
|
139
|
+
is_valid=False,
|
|
140
|
+
invalid_reason=f"Unsupported scheme: {payment.scheme}",
|
|
141
|
+
payer=None,
|
|
142
|
+
payment_uuid=None
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
payload = payment.payload
|
|
146
|
+
authorization = payload.authorization
|
|
147
|
+
signature = payload.signature
|
|
148
|
+
|
|
149
|
+
# Verify the signature matches the authorization
|
|
150
|
+
payer = authorization.from_
|
|
151
|
+
|
|
152
|
+
# Reconstruct the EIP-712 message and verify signature
|
|
153
|
+
# This would use the exact EIP-712 domain from payment_requirements.extra
|
|
154
|
+
eip712_domain = payment_requirements.extra or {}
|
|
155
|
+
|
|
156
|
+
# For now, perform basic validation
|
|
157
|
+
# In production, this should verify the EIP-3009 signature
|
|
158
|
+
if not payer or not signature:
|
|
159
|
+
return VerifyResponse(
|
|
160
|
+
is_valid=False,
|
|
161
|
+
invalid_reason="Missing payer or signature",
|
|
162
|
+
payer=None,
|
|
163
|
+
payment_uuid=None
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Verify amount matches requirements
|
|
167
|
+
if authorization.value != payment_requirements.max_amount_required:
|
|
168
|
+
return VerifyResponse(
|
|
169
|
+
is_valid=False,
|
|
170
|
+
invalid_reason=f"Amount mismatch: expected {payment_requirements.max_amount_required}, got {authorization.value}",
|
|
171
|
+
payer=payer,
|
|
172
|
+
payment_uuid=None
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Verify not expired
|
|
176
|
+
import time
|
|
177
|
+
current_time = int(time.time())
|
|
178
|
+
valid_after = int(authorization.valid_after)
|
|
179
|
+
valid_before = int(authorization.valid_before)
|
|
180
|
+
|
|
181
|
+
if current_time < valid_after or current_time > valid_before:
|
|
182
|
+
return VerifyResponse(
|
|
183
|
+
is_valid=False,
|
|
184
|
+
invalid_reason="Authorization expired or not yet valid",
|
|
185
|
+
payer=payer,
|
|
186
|
+
payment_uuid=None
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Verify to address matches pay_to
|
|
190
|
+
if authorization.to.lower() != payment_requirements.pay_to.lower():
|
|
191
|
+
return VerifyResponse(
|
|
192
|
+
is_valid=False,
|
|
193
|
+
invalid_reason=f"Pay-to address mismatch",
|
|
194
|
+
payer=payer,
|
|
195
|
+
payment_uuid=None
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# All checks passed (local verification - no payment_uuid)
|
|
199
|
+
return VerifyResponse(
|
|
200
|
+
is_valid=True,
|
|
201
|
+
invalid_reason=None,
|
|
202
|
+
payer=payer,
|
|
203
|
+
payment_uuid=None # Local verification doesn't provide payment_uuid
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error verifying payment: {e}")
|
|
208
|
+
return VerifyResponse(
|
|
209
|
+
is_valid=False,
|
|
210
|
+
invalid_reason=f"Verification error: {str(e)}",
|
|
211
|
+
payer=None,
|
|
212
|
+
payment_uuid=None
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def settle(
|
|
216
|
+
self,
|
|
217
|
+
payment: PaymentPayload,
|
|
218
|
+
payment_requirements: PaymentRequirements
|
|
219
|
+
) -> SettleResponse:
|
|
220
|
+
"""Settle a verified payment through the IATP Settlement Layer.
|
|
221
|
+
|
|
222
|
+
This submits the payment to the relayer, which will:
|
|
223
|
+
1. Verify both consumer and provider signatures
|
|
224
|
+
2. Submit to IATPSettlementLayer.settleRequest()
|
|
225
|
+
3. Process the EIP-3009 authorization on-chain
|
|
226
|
+
4. Credit the provider's epoch balance
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
payment: Verified payment payload
|
|
230
|
+
payment_requirements: Payment requirements
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
SettleResponse with settlement result
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
payload = payment.payload
|
|
237
|
+
authorization = payload.authorization
|
|
238
|
+
consumer_signature = payload.signature
|
|
239
|
+
|
|
240
|
+
# Create the service request struct (matches Solidity ServiceRequest)
|
|
241
|
+
service_request = {
|
|
242
|
+
"consumer": authorization.from_,
|
|
243
|
+
"provider": payment_requirements.pay_to,
|
|
244
|
+
"amount": authorization.value,
|
|
245
|
+
"timestamp": int(authorization.valid_after),
|
|
246
|
+
"serviceDescription": Web3.keccak(
|
|
247
|
+
text=payment_requirements.description
|
|
248
|
+
).hex()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Create provider attestation if operator key is available
|
|
252
|
+
provider_signature = None
|
|
253
|
+
extra = payment_requirements.extra or {}
|
|
254
|
+
output_hash = extra.get("output_hash")
|
|
255
|
+
payment_uuid = extra.get("payment_uuid") # Primary payment identifier from facilitator verify
|
|
256
|
+
facilitator_fee_percent = extra.get("facilitator_fee_percent", 250) # Get fee from facilitator verify response
|
|
257
|
+
|
|
258
|
+
if not payment_uuid:
|
|
259
|
+
logger.warning("No payment_uuid in payment_requirements.extra - attestation may not be linkable")
|
|
260
|
+
|
|
261
|
+
if self.operator_account:
|
|
262
|
+
# Create EIP-712 ProviderAttestation signature matching IATPWallet.sol
|
|
263
|
+
# ProviderAttestation(bytes32 consumerSignature, bytes32 outputHash, uint256 timestamp, bytes32 serviceDescription, uint256 facilitatorFeePercent)
|
|
264
|
+
|
|
265
|
+
# Hash the consumer signature bytes
|
|
266
|
+
consumer_signature_hash = Web3.keccak(hexstr=consumer_signature)
|
|
267
|
+
|
|
268
|
+
# Prepare output hash for contract verification
|
|
269
|
+
# Python: output_hash = keccak256(output_json) ← First hash (line 161 in mcp_middleware.py)
|
|
270
|
+
# Contract: outputHashHash = keccak256(outputHash bytes) ← Second hash (line 245 in IATPWallet.sol)
|
|
271
|
+
# Provider signs over outputHashHash (the double-hashed value)
|
|
272
|
+
if output_hash:
|
|
273
|
+
# Output hash is hex string like "0xabcd..." (already hashed once)
|
|
274
|
+
output_hash_bytes = bytes.fromhex(output_hash[2:] if output_hash.startswith("0x") else output_hash)
|
|
275
|
+
# Hash it again to match what contract will compute: keccak256(outputHash)
|
|
276
|
+
output_hash_hash = Web3.keccak(output_hash_bytes)
|
|
277
|
+
logger.debug(f"Output hash (1st): {output_hash[:20]}...")
|
|
278
|
+
logger.debug(f"Output hash (2nd): {output_hash_hash.hex()[:20]}...")
|
|
279
|
+
else:
|
|
280
|
+
# Use zero hash if no output provided
|
|
281
|
+
output_hash_hash = Web3.keccak(b"")
|
|
282
|
+
|
|
283
|
+
# Get service description hash
|
|
284
|
+
service_description_hash = Web3.keccak(text=payment_requirements.description)
|
|
285
|
+
|
|
286
|
+
# Attestation timestamp
|
|
287
|
+
attestation_timestamp = int(authorization.valid_after)
|
|
288
|
+
|
|
289
|
+
# Build EIP-712 typed data for ProviderAttestation
|
|
290
|
+
# Domain should be the Provider's IATPWallet domain
|
|
291
|
+
from .chains import get_chain_id
|
|
292
|
+
|
|
293
|
+
typed_data = {
|
|
294
|
+
"types": {
|
|
295
|
+
"ProviderAttestation": [
|
|
296
|
+
{"name": "consumerSignature", "type": "bytes32"},
|
|
297
|
+
{"name": "outputHash", "type": "bytes32"},
|
|
298
|
+
{"name": "timestamp", "type": "uint256"},
|
|
299
|
+
{"name": "serviceDescription", "type": "bytes32"},
|
|
300
|
+
{"name": "facilitatorFeePercent", "type": "uint256"},
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
"primaryType": "ProviderAttestation",
|
|
304
|
+
"domain": {
|
|
305
|
+
"name": "IATPWallet",
|
|
306
|
+
"version": "1",
|
|
307
|
+
"chainId": int(get_chain_id(payment.network)),
|
|
308
|
+
"verifyingContract": payment_requirements.pay_to, # Provider's IATPWallet address
|
|
309
|
+
},
|
|
310
|
+
"message": {
|
|
311
|
+
"consumerSignature": consumer_signature_hash,
|
|
312
|
+
"outputHash": output_hash_hash,
|
|
313
|
+
"timestamp": attestation_timestamp,
|
|
314
|
+
"serviceDescription": service_description_hash,
|
|
315
|
+
"facilitatorFeePercent": facilitator_fee_percent,
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# Sign with provider's operator key
|
|
320
|
+
signed = self.operator_account.sign_typed_data(
|
|
321
|
+
domain_data=typed_data["domain"],
|
|
322
|
+
message_types=typed_data["types"],
|
|
323
|
+
message_data=typed_data["message"],
|
|
324
|
+
)
|
|
325
|
+
provider_signature = signed.signature.hex()
|
|
326
|
+
if not provider_signature.startswith("0x"):
|
|
327
|
+
provider_signature = f"0x{provider_signature}"
|
|
328
|
+
|
|
329
|
+
logger.info(f"Provider attestation (EIP-712) created:")
|
|
330
|
+
if output_hash:
|
|
331
|
+
logger.info(f" Output hash: {output_hash[:20]}...")
|
|
332
|
+
if payment_uuid:
|
|
333
|
+
logger.info(f" Payment UUID: {payment_uuid[:20]}...")
|
|
334
|
+
logger.info(f" Facilitator fee: {facilitator_fee_percent} basis points ({facilitator_fee_percent/100}%)")
|
|
335
|
+
|
|
336
|
+
# Prepare settlement request for relayer
|
|
337
|
+
settlement_request = {
|
|
338
|
+
"signedRequest": consumer_signature,
|
|
339
|
+
"serviceRequest": service_request,
|
|
340
|
+
"providerSignature": provider_signature or "0x",
|
|
341
|
+
"attestationTimestamp": int(get_now_in_utc().timestamp()),
|
|
342
|
+
"network": payment.network,
|
|
343
|
+
"outputHash": output_hash or "0x", # Include output hash (proof of service completion)
|
|
344
|
+
"paymentUuid": payment_uuid or "0x" # Include payment_uuid (primary payment identifier)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Submit to relayer
|
|
348
|
+
headers = {"Content-Type": "application/json"}
|
|
349
|
+
if self.relayer_api_key:
|
|
350
|
+
headers["Authorization"] = f"Bearer {self.relayer_api_key}"
|
|
351
|
+
|
|
352
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
353
|
+
response = await client.post(
|
|
354
|
+
f"{self.relayer_url}/settle",
|
|
355
|
+
json=settlement_request,
|
|
356
|
+
headers=headers
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if response.status_code == 200:
|
|
360
|
+
result = response.json()
|
|
361
|
+
return SettleResponse(
|
|
362
|
+
success=True,
|
|
363
|
+
error_reason=None,
|
|
364
|
+
transaction=result.get("transactionHash"),
|
|
365
|
+
network=payment.network,
|
|
366
|
+
payer=authorization.from_
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
error_msg = f"Relayer error: {response.status_code} - {response.text}"
|
|
370
|
+
logger.error(error_msg)
|
|
371
|
+
return SettleResponse(
|
|
372
|
+
success=False,
|
|
373
|
+
error_reason=error_msg,
|
|
374
|
+
transaction=None,
|
|
375
|
+
network=payment.network,
|
|
376
|
+
payer=authorization.from_
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Error settling payment: {e}")
|
|
381
|
+
return SettleResponse(
|
|
382
|
+
success=False,
|
|
383
|
+
error_reason=f"Settlement error: {str(e)}",
|
|
384
|
+
transaction=None,
|
|
385
|
+
network=payment.network,
|
|
386
|
+
payer=None
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
async def list(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
390
|
+
"""List discoverable IATP services that accept d402 payments.
|
|
391
|
+
|
|
392
|
+
This queries the Traia registry for utility agents with d402 enabled.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
request: Optional filters for discovery
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
List of discoverable services
|
|
399
|
+
"""
|
|
400
|
+
# This would query the MongoDB registry
|
|
401
|
+
# For now, return empty list
|
|
402
|
+
return {
|
|
403
|
+
"d402Version": 1,
|
|
404
|
+
"items": [],
|
|
405
|
+
"pagination": {
|
|
406
|
+
"limit": 100,
|
|
407
|
+
"offset": 0,
|
|
408
|
+
"total": 0
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def create_iatp_facilitator(
|
|
414
|
+
relayer_url: str = "https://api.traia.io/relayer",
|
|
415
|
+
relayer_api_key: Optional[str] = None,
|
|
416
|
+
provider_operator_key: Optional[str] = None,
|
|
417
|
+
web3_provider: Optional[str] = None
|
|
418
|
+
) -> IATPSettlementFacilitator:
|
|
419
|
+
"""Convenience function to create an IATP Settlement Facilitator.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
relayer_url: URL of the Traia relayer service
|
|
423
|
+
relayer_api_key: Optional API key for relayer
|
|
424
|
+
provider_operator_key: Provider's operator private key
|
|
425
|
+
web3_provider: Optional Web3 provider URL
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Configured IATPSettlementFacilitator
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
facilitator = create_iatp_facilitator(
|
|
432
|
+
relayer_url="https://api.traia.io/relayer",
|
|
433
|
+
relayer_api_key=os.getenv("TRAIA_RELAYER_API_KEY"),
|
|
434
|
+
provider_operator_key=os.getenv("OPERATOR_PRIVATE_KEY")
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Use in d402 middleware
|
|
438
|
+
from traia_iatp.d402 import D402Config, require_iatp_payment
|
|
439
|
+
|
|
440
|
+
config = D402Config(
|
|
441
|
+
enabled=True,
|
|
442
|
+
pay_to_address="0x...", # Utility agent contract address
|
|
443
|
+
default_price=D402ServicePrice(...),
|
|
444
|
+
facilitator_url="custom" # Will use custom facilitator
|
|
445
|
+
)
|
|
446
|
+
"""
|
|
447
|
+
return IATPSettlementFacilitator(
|
|
448
|
+
relayer_url=relayer_url,
|
|
449
|
+
relayer_api_key=relayer_api_key,
|
|
450
|
+
provider_operator_key=provider_operator_key,
|
|
451
|
+
web3_provider=web3_provider
|
|
452
|
+
)
|
|
453
|
+
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Callable, Optional, get_args, cast
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
8
|
+
from pydantic import validate_call
|
|
9
|
+
|
|
10
|
+
from ..common import (
|
|
11
|
+
process_price_to_atomic_amount,
|
|
12
|
+
d402_VERSION,
|
|
13
|
+
find_matching_payment_requirements,
|
|
14
|
+
)
|
|
15
|
+
from ..encoding import safe_base64_decode
|
|
16
|
+
# FacilitatorClient not used - we use custom IATP facilitator
|
|
17
|
+
from typing import Optional
|
|
18
|
+
from typing_extensions import TypedDict
|
|
19
|
+
|
|
20
|
+
class FacilitatorConfig(TypedDict, total=False):
|
|
21
|
+
url: str
|
|
22
|
+
create_headers: Callable[[], dict[str, dict[str, str]]]
|
|
23
|
+
from ..path import path_is_match
|
|
24
|
+
from ..paywall import is_browser_request, get_paywall_html
|
|
25
|
+
from ..types import (
|
|
26
|
+
PaymentPayload,
|
|
27
|
+
PaymentRequirements,
|
|
28
|
+
Price,
|
|
29
|
+
d402PaymentRequiredResponse,
|
|
30
|
+
PaywallConfig,
|
|
31
|
+
SupportedNetworks,
|
|
32
|
+
HTTPInputSchema,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@validate_call
|
|
39
|
+
def require_payment(
|
|
40
|
+
price: Price,
|
|
41
|
+
pay_to_address: str,
|
|
42
|
+
path: str | list[str] = "*",
|
|
43
|
+
description: str = "",
|
|
44
|
+
mime_type: str = "",
|
|
45
|
+
max_deadline_seconds: int = 60,
|
|
46
|
+
input_schema: Optional[HTTPInputSchema] = None,
|
|
47
|
+
output_schema: Optional[Any] = None,
|
|
48
|
+
discoverable: Optional[bool] = True,
|
|
49
|
+
facilitator_config: Optional[FacilitatorConfig] = None,
|
|
50
|
+
network: str = "base-sepolia",
|
|
51
|
+
resource: Optional[str] = None,
|
|
52
|
+
paywall_config: Optional[PaywallConfig] = None,
|
|
53
|
+
custom_paywall_html: Optional[str] = None,
|
|
54
|
+
):
|
|
55
|
+
"""Generate a FastAPI middleware that gates payments for an endpoint.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
price (Price): Payment price. Can be:
|
|
59
|
+
- Money: USD amount as string/int (e.g., "$3.10", 0.10, "0.001") - defaults to USDC
|
|
60
|
+
- TokenAmount: Custom token amount with asset information
|
|
61
|
+
pay_to_address (str): Ethereum address to receive the payment
|
|
62
|
+
path (str | list[str], optional): Path to gate with payments. Defaults to "*" for all paths.
|
|
63
|
+
description (str, optional): Description of what is being purchased. Defaults to "".
|
|
64
|
+
mime_type (str, optional): MIME type of the resource. Defaults to "".
|
|
65
|
+
max_deadline_seconds (int, optional): Maximum time allowed for payment. Defaults to 60.
|
|
66
|
+
input_schema (Optional[HTTPInputSchema], optional): Schema for the request structure. Defaults to None.
|
|
67
|
+
output_schema (Optional[Any], optional): Schema for the response. Defaults to None.
|
|
68
|
+
discoverable (bool, optional): Whether the route is discoverable. Defaults to True.
|
|
69
|
+
facilitator_config (Optional[Dict[str, Any]], optional): Configuration for the payment facilitator.
|
|
70
|
+
If not provided, defaults to the public d402.org facilitator.
|
|
71
|
+
network (str, optional): Ethereum network ID. Defaults to "base-sepolia" (Base Sepolia testnet).
|
|
72
|
+
resource (Optional[str], optional): Resource URL. Defaults to None (uses request URL).
|
|
73
|
+
paywall_config (Optional[PaywallConfig], optional): Configuration for paywall UI customization.
|
|
74
|
+
Includes options like cdp_client_key, app_name, app_logo, session_token_endpoint.
|
|
75
|
+
custom_paywall_html (Optional[str], optional): Custom HTML to display for paywall instead of default.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Callable: FastAPI middleware function that checks for valid payment before processing requests
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# Validate network is supported
|
|
82
|
+
supported_networks = get_args(SupportedNetworks)
|
|
83
|
+
if network not in supported_networks:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Unsupported network: {network}. Must be one of: {supported_networks}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
max_amount_required, asset_address, eip712_domain = (
|
|
90
|
+
process_price_to_atomic_amount(price, network)
|
|
91
|
+
)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
raise ValueError(f"Invalid price: {price}. Error: {e}")
|
|
94
|
+
|
|
95
|
+
facilitator = FacilitatorClient(facilitator_config)
|
|
96
|
+
|
|
97
|
+
async def middleware(request: Request, call_next: Callable):
|
|
98
|
+
# Skip if the path is not the same as the path in the middleware
|
|
99
|
+
if not path_is_match(path, request.url.path):
|
|
100
|
+
return await call_next(request)
|
|
101
|
+
|
|
102
|
+
# Get resource URL if not explicitly provided
|
|
103
|
+
resource_url = resource or str(request.url)
|
|
104
|
+
|
|
105
|
+
# Construct payment details
|
|
106
|
+
payment_requirements = [
|
|
107
|
+
PaymentRequirements(
|
|
108
|
+
scheme="exact",
|
|
109
|
+
network=cast(SupportedNetworks, network),
|
|
110
|
+
asset=asset_address,
|
|
111
|
+
max_amount_required=max_amount_required,
|
|
112
|
+
resource=resource_url,
|
|
113
|
+
description=description,
|
|
114
|
+
mime_type=mime_type,
|
|
115
|
+
pay_to=pay_to_address,
|
|
116
|
+
max_timeout_seconds=max_deadline_seconds,
|
|
117
|
+
# TODO: Rename output_schema to request_structure
|
|
118
|
+
output_schema={
|
|
119
|
+
"input": {
|
|
120
|
+
"type": "http",
|
|
121
|
+
"method": request.method.upper(),
|
|
122
|
+
"discoverable": discoverable
|
|
123
|
+
if discoverable is not None
|
|
124
|
+
else True,
|
|
125
|
+
**(input_schema.model_dump() if input_schema else {}),
|
|
126
|
+
},
|
|
127
|
+
"output": output_schema,
|
|
128
|
+
},
|
|
129
|
+
extra=eip712_domain,
|
|
130
|
+
)
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def d402_response(error: str):
|
|
134
|
+
"""Create a 402 response with payment requirements."""
|
|
135
|
+
request_headers = dict(request.headers)
|
|
136
|
+
status_code = 402
|
|
137
|
+
|
|
138
|
+
if is_browser_request(request_headers):
|
|
139
|
+
html_content = custom_paywall_html or get_paywall_html(
|
|
140
|
+
error, payment_requirements, paywall_config
|
|
141
|
+
)
|
|
142
|
+
headers = {"Content-Type": "text/html; charset=utf-8"}
|
|
143
|
+
|
|
144
|
+
return HTMLResponse(
|
|
145
|
+
content=html_content,
|
|
146
|
+
status_code=status_code,
|
|
147
|
+
headers=headers,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
response_data = d402PaymentRequiredResponse(
|
|
151
|
+
d402_version=d402_VERSION,
|
|
152
|
+
accepts=payment_requirements,
|
|
153
|
+
error=error,
|
|
154
|
+
).model_dump(by_alias=True)
|
|
155
|
+
headers = {"Content-Type": "application/json"}
|
|
156
|
+
|
|
157
|
+
return JSONResponse(
|
|
158
|
+
content=response_data,
|
|
159
|
+
status_code=status_code,
|
|
160
|
+
headers=headers,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Check for payment header
|
|
164
|
+
payment_header = request.headers.get("X-PAYMENT", "")
|
|
165
|
+
|
|
166
|
+
if payment_header == "":
|
|
167
|
+
return d402_response("No X-PAYMENT header provided")
|
|
168
|
+
|
|
169
|
+
# Decode payment header
|
|
170
|
+
try:
|
|
171
|
+
payment_dict = json.loads(safe_base64_decode(payment_header))
|
|
172
|
+
payment = PaymentPayload(**payment_dict)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(
|
|
175
|
+
f"Invalid payment header format from {request.client.host if request.client else 'unknown'}: {str(e)}"
|
|
176
|
+
)
|
|
177
|
+
return d402_response("Invalid payment header format")
|
|
178
|
+
|
|
179
|
+
# Find matching payment requirements
|
|
180
|
+
selected_payment_requirements = find_matching_payment_requirements(
|
|
181
|
+
payment_requirements, payment
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not selected_payment_requirements:
|
|
185
|
+
return d402_response("No matching payment requirements found")
|
|
186
|
+
|
|
187
|
+
# Verify payment
|
|
188
|
+
verify_response = await facilitator.verify(
|
|
189
|
+
payment, selected_payment_requirements
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if not verify_response.is_valid:
|
|
193
|
+
error_reason = verify_response.invalid_reason or "Unknown error"
|
|
194
|
+
return d402_response(f"Invalid payment: {error_reason}")
|
|
195
|
+
|
|
196
|
+
request.state.payment_details = selected_payment_requirements
|
|
197
|
+
request.state.verify_response = verify_response
|
|
198
|
+
|
|
199
|
+
# Process the request
|
|
200
|
+
response = await call_next(request)
|
|
201
|
+
|
|
202
|
+
# Early return without settling if the response is not a 2xx
|
|
203
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
# Settle the payment
|
|
207
|
+
try:
|
|
208
|
+
settle_response = await facilitator.settle(
|
|
209
|
+
payment, selected_payment_requirements
|
|
210
|
+
)
|
|
211
|
+
if settle_response.success:
|
|
212
|
+
response.headers["X-PAYMENT-RESPONSE"] = base64.b64encode(
|
|
213
|
+
settle_response.model_dump_json(by_alias=True).encode("utf-8")
|
|
214
|
+
).decode("utf-8")
|
|
215
|
+
else:
|
|
216
|
+
return d402_response(
|
|
217
|
+
"Settle failed: "
|
|
218
|
+
+ (settle_response.error_reason or "Unknown error")
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
return d402_response("Settle failed")
|
|
222
|
+
|
|
223
|
+
return response
|
|
224
|
+
|
|
225
|
+
return middleware
|