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.

Files changed (107) hide show
  1. traia_iatp/README.md +368 -0
  2. traia_iatp/__init__.py +54 -0
  3. traia_iatp/cli/__init__.py +5 -0
  4. traia_iatp/cli/main.py +483 -0
  5. traia_iatp/client/__init__.py +10 -0
  6. traia_iatp/client/a2a_client.py +274 -0
  7. traia_iatp/client/crewai_a2a_tools.py +335 -0
  8. traia_iatp/client/d402_a2a_client.py +293 -0
  9. traia_iatp/client/grpc_a2a_tools.py +349 -0
  10. traia_iatp/client/root_path_a2a_client.py +1 -0
  11. traia_iatp/contracts/__init__.py +12 -0
  12. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  13. traia_iatp/contracts/wallet_creator.py +255 -0
  14. traia_iatp/core/__init__.py +43 -0
  15. traia_iatp/core/models.py +172 -0
  16. traia_iatp/d402/__init__.py +55 -0
  17. traia_iatp/d402/chains.py +102 -0
  18. traia_iatp/d402/client.py +150 -0
  19. traia_iatp/d402/clients/__init__.py +7 -0
  20. traia_iatp/d402/clients/base.py +218 -0
  21. traia_iatp/d402/clients/httpx.py +219 -0
  22. traia_iatp/d402/common.py +114 -0
  23. traia_iatp/d402/encoding.py +28 -0
  24. traia_iatp/d402/examples/client_example.py +197 -0
  25. traia_iatp/d402/examples/server_example.py +171 -0
  26. traia_iatp/d402/facilitator.py +453 -0
  27. traia_iatp/d402/fastapi_middleware/__init__.py +6 -0
  28. traia_iatp/d402/fastapi_middleware/middleware.py +225 -0
  29. traia_iatp/d402/fastmcp_middleware.py +147 -0
  30. traia_iatp/d402/mcp_middleware.py +434 -0
  31. traia_iatp/d402/middleware.py +193 -0
  32. traia_iatp/d402/models.py +116 -0
  33. traia_iatp/d402/networks.py +98 -0
  34. traia_iatp/d402/path.py +43 -0
  35. traia_iatp/d402/payment_introspection.py +104 -0
  36. traia_iatp/d402/payment_signing.py +178 -0
  37. traia_iatp/d402/paywall.py +119 -0
  38. traia_iatp/d402/starlette_middleware.py +326 -0
  39. traia_iatp/d402/template.py +1 -0
  40. traia_iatp/d402/types.py +300 -0
  41. traia_iatp/mcp/__init__.py +18 -0
  42. traia_iatp/mcp/client.py +201 -0
  43. traia_iatp/mcp/d402_mcp_tool_adapter.py +361 -0
  44. traia_iatp/mcp/mcp_agent_template.py +481 -0
  45. traia_iatp/mcp/templates/Dockerfile.j2 +80 -0
  46. traia_iatp/mcp/templates/README.md.j2 +310 -0
  47. traia_iatp/mcp/templates/cursor-rules.md.j2 +520 -0
  48. traia_iatp/mcp/templates/deployment_params.json.j2 +20 -0
  49. traia_iatp/mcp/templates/docker-compose.yml.j2 +32 -0
  50. traia_iatp/mcp/templates/dockerignore.j2 +47 -0
  51. traia_iatp/mcp/templates/env.example.j2 +57 -0
  52. traia_iatp/mcp/templates/gitignore.j2 +77 -0
  53. traia_iatp/mcp/templates/mcp_health_check.py.j2 +150 -0
  54. traia_iatp/mcp/templates/pyproject.toml.j2 +32 -0
  55. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  56. traia_iatp/mcp/templates/run_local_docker.sh.j2 +390 -0
  57. traia_iatp/mcp/templates/server.py.j2 +175 -0
  58. traia_iatp/mcp/traia_mcp_adapter.py +543 -0
  59. traia_iatp/preview_diagrams.html +181 -0
  60. traia_iatp/registry/__init__.py +26 -0
  61. traia_iatp/registry/atlas_search_indexes.json +280 -0
  62. traia_iatp/registry/embeddings.py +298 -0
  63. traia_iatp/registry/iatp_search_api.py +846 -0
  64. traia_iatp/registry/mongodb_registry.py +771 -0
  65. traia_iatp/registry/readmes/ATLAS_SEARCH_INDEXES.md +252 -0
  66. traia_iatp/registry/readmes/ATLAS_SEARCH_SETUP.md +134 -0
  67. traia_iatp/registry/readmes/AUTHENTICATION_UPDATE.md +124 -0
  68. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +172 -0
  69. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +257 -0
  70. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +208 -0
  71. traia_iatp/registry/readmes/README.md +251 -0
  72. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +191 -0
  73. traia_iatp/scripts/__init__.py +2 -0
  74. traia_iatp/scripts/create_wallet.py +244 -0
  75. traia_iatp/server/__init__.py +15 -0
  76. traia_iatp/server/a2a_server.py +219 -0
  77. traia_iatp/server/example_template_usage.py +72 -0
  78. traia_iatp/server/iatp_server_agent_generator.py +237 -0
  79. traia_iatp/server/iatp_server_template_generator.py +235 -0
  80. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  81. traia_iatp/server/templates/Dockerfile.j2 +49 -0
  82. traia_iatp/server/templates/README.md +137 -0
  83. traia_iatp/server/templates/README.md.j2 +425 -0
  84. traia_iatp/server/templates/__init__.py +1 -0
  85. traia_iatp/server/templates/__main__.py.j2 +565 -0
  86. traia_iatp/server/templates/agent.py.j2 +94 -0
  87. traia_iatp/server/templates/agent_config.json.j2 +22 -0
  88. traia_iatp/server/templates/agent_executor.py.j2 +279 -0
  89. traia_iatp/server/templates/docker-compose.yml.j2 +23 -0
  90. traia_iatp/server/templates/env.example.j2 +84 -0
  91. traia_iatp/server/templates/gitignore.j2 +78 -0
  92. traia_iatp/server/templates/grpc_server.py.j2 +218 -0
  93. traia_iatp/server/templates/pyproject.toml.j2 +78 -0
  94. traia_iatp/server/templates/run_local_docker.sh.j2 +103 -0
  95. traia_iatp/server/templates/server.py.j2 +243 -0
  96. traia_iatp/special_agencies/__init__.py +4 -0
  97. traia_iatp/special_agencies/registry_search_agency.py +392 -0
  98. traia_iatp/utils/__init__.py +10 -0
  99. traia_iatp/utils/docker_utils.py +251 -0
  100. traia_iatp/utils/general.py +64 -0
  101. traia_iatp/utils/iatp_utils.py +126 -0
  102. traia_iatp-0.1.29.dist-info/METADATA +423 -0
  103. traia_iatp-0.1.29.dist-info/RECORD +107 -0
  104. traia_iatp-0.1.29.dist-info/WHEEL +5 -0
  105. traia_iatp-0.1.29.dist-info/entry_points.txt +2 -0
  106. traia_iatp-0.1.29.dist-info/licenses/LICENSE +21 -0
  107. 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,6 @@
1
+ """FastAPI middleware for d402 payments."""
2
+
3
+ from .middleware import require_payment
4
+
5
+ __all__ = ["require_payment"]
6
+
@@ -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