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,183 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import secrets
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from typing_extensions import (
|
|
6
|
+
TypedDict,
|
|
7
|
+
) # use `typing_extensions.TypedDict` instead of `typing.TypedDict` on Python < 3.12
|
|
8
|
+
from eth_account import Account
|
|
9
|
+
from .encoding import safe_base64_encode, safe_base64_decode
|
|
10
|
+
from .types import (
|
|
11
|
+
PaymentRequirements,
|
|
12
|
+
)
|
|
13
|
+
from .chains import get_chain_id
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_nonce() -> bytes:
|
|
20
|
+
"""Create a random 32-byte nonce for authorization signatures."""
|
|
21
|
+
return secrets.token_bytes(32)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prepare_payment_header(
|
|
25
|
+
sender_address: str, d402_version: int, payment_requirements: PaymentRequirements
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
"""Prepare an unsigned payment header with sender address, d402 version, and payment requirements."""
|
|
28
|
+
nonce = create_nonce()
|
|
29
|
+
valid_after = str(int(time.time()) - 60) # 60 seconds before
|
|
30
|
+
# Ensure at least 24 hours for settlement (facilitator batches payments)
|
|
31
|
+
min_deadline_seconds = max(payment_requirements.max_timeout_seconds, 86400) # 24 hours
|
|
32
|
+
valid_before = str(int(time.time()) + min_deadline_seconds)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"d402Version": d402_version,
|
|
36
|
+
"scheme": payment_requirements.scheme,
|
|
37
|
+
"network": payment_requirements.network,
|
|
38
|
+
"payload": {
|
|
39
|
+
"signature": None,
|
|
40
|
+
"authorization": {
|
|
41
|
+
"from": sender_address,
|
|
42
|
+
"to": payment_requirements.pay_to,
|
|
43
|
+
"value": payment_requirements.max_amount_required,
|
|
44
|
+
"validAfter": valid_after,
|
|
45
|
+
"validBefore": valid_before,
|
|
46
|
+
"nonce": nonce,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PaymentHeader(TypedDict):
|
|
53
|
+
d402Version: int
|
|
54
|
+
scheme: str
|
|
55
|
+
network: str
|
|
56
|
+
payload: dict[str, Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sign_payment_header(
|
|
60
|
+
operator_account: Account,
|
|
61
|
+
payment_requirements: PaymentRequirements,
|
|
62
|
+
header: PaymentHeader,
|
|
63
|
+
wallet_address: str = None,
|
|
64
|
+
request_path: str = None
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Sign a payment header using EIP-712 PullFundsForSettlement signature.
|
|
68
|
+
|
|
69
|
+
This signature format matches IATPWallet.sol validateConsumerSignature.
|
|
70
|
+
|
|
71
|
+
Contract Type Hash (IATPWallet.sol line 34-36):
|
|
72
|
+
PullFundsForSettlement(
|
|
73
|
+
address wallet, // Consumer's IATPWallet contract address
|
|
74
|
+
address provider, // Provider's IATPWallet contract address
|
|
75
|
+
address token, // Token address (USDC, etc.)
|
|
76
|
+
uint256 amount, // Payment amount
|
|
77
|
+
uint256 deadline, // Signature expiration
|
|
78
|
+
string requestPath // API path (e.g., "/mcp/tools/call")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
Note: chainId is in the EIP-712 domain, NOT in the message (per EIP-712 standard)
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
operator_account: Operator account with private key for signing (EOA)
|
|
85
|
+
payment_requirements: Payment requirements from server
|
|
86
|
+
header: Payment header structure
|
|
87
|
+
wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address)
|
|
88
|
+
request_path: API request path (if None, uses payment_requirements.resource)
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
auth = header["payload"]["authorization"]
|
|
92
|
+
|
|
93
|
+
# Get wallet address (IATPWallet contract, not EOA)
|
|
94
|
+
consumer_wallet = wallet_address or auth["from"]
|
|
95
|
+
|
|
96
|
+
# Get request path from payment_requirements if not provided
|
|
97
|
+
if request_path is None:
|
|
98
|
+
request_path = payment_requirements.resource or "/mcp"
|
|
99
|
+
logger.info(f"🔍 payment_requirements.resource: {payment_requirements.resource}")
|
|
100
|
+
logger.info(f"🔍 Using request_path: {request_path}")
|
|
101
|
+
|
|
102
|
+
# Ensure we have a valid request path (contract requires non-empty string)
|
|
103
|
+
if not request_path or request_path.strip() == "":
|
|
104
|
+
logger.warning(f"⚠️ request_path was empty, defaulting to /mcp")
|
|
105
|
+
request_path = "/mcp"
|
|
106
|
+
|
|
107
|
+
# Get domain info from payment_requirements.extra (IATPWallet domain)
|
|
108
|
+
extra = payment_requirements.extra or {}
|
|
109
|
+
wallet_name = extra.get("name", "IATPWallet")
|
|
110
|
+
wallet_version = extra.get("version", "1")
|
|
111
|
+
|
|
112
|
+
# Build EIP-712 typed data for PullFundsForSettlement
|
|
113
|
+
# Note: chainId is in the domain, not the message (EIP-712 standard)
|
|
114
|
+
typed_data = {
|
|
115
|
+
"types": {
|
|
116
|
+
"PullFundsForSettlement": [
|
|
117
|
+
{"name": "wallet", "type": "address"},
|
|
118
|
+
{"name": "provider", "type": "address"},
|
|
119
|
+
{"name": "token", "type": "address"},
|
|
120
|
+
{"name": "amount", "type": "uint256"},
|
|
121
|
+
{"name": "deadline", "type": "uint256"},
|
|
122
|
+
{"name": "requestPath", "type": "string"},
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"primaryType": "PullFundsForSettlement",
|
|
126
|
+
"domain": {
|
|
127
|
+
"name": wallet_name,
|
|
128
|
+
"version": wallet_version,
|
|
129
|
+
"chainId": int(get_chain_id(payment_requirements.network)), # chainId in domain only
|
|
130
|
+
"verifyingContract": consumer_wallet, # Consumer's IATPWallet contract
|
|
131
|
+
},
|
|
132
|
+
"message": {
|
|
133
|
+
"wallet": consumer_wallet, # Consumer's IATPWallet contract address
|
|
134
|
+
"provider": auth["to"], # Provider's IATPWallet contract address
|
|
135
|
+
"token": payment_requirements.asset, # Token address (e.g., USDC)
|
|
136
|
+
"amount": int(auth["value"]),
|
|
137
|
+
"deadline": int(auth["validBefore"]),
|
|
138
|
+
"requestPath": request_path, # Actual API path, not nonce
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
signed_message = operator_account.sign_typed_data(
|
|
143
|
+
domain_data=typed_data["domain"],
|
|
144
|
+
message_types=typed_data["types"],
|
|
145
|
+
message_data=typed_data["message"],
|
|
146
|
+
)
|
|
147
|
+
signature = signed_message.signature.hex()
|
|
148
|
+
if not signature.startswith("0x"):
|
|
149
|
+
signature = f"0x{signature}"
|
|
150
|
+
|
|
151
|
+
header["payload"]["signature"] = signature
|
|
152
|
+
|
|
153
|
+
# Store wallet address and request path in header for verification
|
|
154
|
+
header["payload"]["authorization"]["from"] = consumer_wallet
|
|
155
|
+
header["payload"]["authorization"]["requestPath"] = request_path
|
|
156
|
+
|
|
157
|
+
encoded = encode_payment(header)
|
|
158
|
+
return encoded
|
|
159
|
+
except Exception:
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def encode_payment(payment_payload: Dict[str, Any]) -> str:
|
|
164
|
+
"""Encode a payment payload into a base64 string, handling HexBytes and other non-serializable types."""
|
|
165
|
+
from hexbytes import HexBytes
|
|
166
|
+
|
|
167
|
+
def default(obj):
|
|
168
|
+
if isinstance(obj, HexBytes):
|
|
169
|
+
return obj.hex()
|
|
170
|
+
if hasattr(obj, "to_dict"):
|
|
171
|
+
return obj.to_dict()
|
|
172
|
+
if hasattr(obj, "hex"):
|
|
173
|
+
return obj.hex()
|
|
174
|
+
raise TypeError(
|
|
175
|
+
f"Object of type {obj.__class__.__name__} is not JSON serializable"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return safe_base64_encode(json.dumps(payment_payload, default=default))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def decode_payment(encoded_payment: str) -> Dict[str, Any]:
|
|
182
|
+
"""Decode a base64 encoded payment string back into a PaymentPayload object."""
|
|
183
|
+
return json.loads(safe_base64_decode(encoded_payment))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
D402 Price Builder - Helper for creating payment amounts.
|
|
3
|
+
|
|
4
|
+
Simplifies creating TokenAmount objects for payment configuration with any token.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .types import TokenAmount, TokenAsset, EIP712Domain
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class D402PriceBuilder:
|
|
11
|
+
"""
|
|
12
|
+
Helper class for building D402 payment amounts with any token.
|
|
13
|
+
|
|
14
|
+
Initialize once with your token configuration, then create prices easily.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
# Initialize with ANY token configuration
|
|
18
|
+
builder = D402PriceBuilder(
|
|
19
|
+
token_address="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", # Your token
|
|
20
|
+
token_decimals=6, # Your decimals
|
|
21
|
+
network="sepolia", # Your network
|
|
22
|
+
token_symbol="USDC" # Your symbol
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Create prices easily
|
|
26
|
+
price_cheap = builder.create_price(0.001) # $0.001
|
|
27
|
+
price_standard = builder.create_price(0.01) # $0.01
|
|
28
|
+
price_premium = builder.create_price(0.05) # $0.05
|
|
29
|
+
|
|
30
|
+
# Use in decorators
|
|
31
|
+
@require_payment(price=price_premium, description="Premium API call")
|
|
32
|
+
async def premium_endpoint():
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
# USDC on Sepolia (6 decimals)
|
|
37
|
+
usdc_builder = D402PriceBuilder(
|
|
38
|
+
token_address="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
|
|
39
|
+
token_decimals=6,
|
|
40
|
+
network="sepolia",
|
|
41
|
+
token_symbol="USDC"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# TRAIA token (18 decimals)
|
|
45
|
+
traia_builder = D402PriceBuilder(
|
|
46
|
+
token_address="0x...",
|
|
47
|
+
token_decimals=18,
|
|
48
|
+
network="base-mainnet",
|
|
49
|
+
token_symbol="TRAIA"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Any custom token
|
|
53
|
+
custom_builder = D402PriceBuilder(
|
|
54
|
+
token_address="0xYourToken...",
|
|
55
|
+
token_decimals=8, # Your token's decimals
|
|
56
|
+
network="arbitrum-mainnet",
|
|
57
|
+
token_symbol="CUSTOM"
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
token_address: str,
|
|
64
|
+
token_decimals: int,
|
|
65
|
+
network: str,
|
|
66
|
+
token_symbol: str = "TOKEN",
|
|
67
|
+
eip712_name: str = "IATPWallet",
|
|
68
|
+
eip712_version: str = "1"
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Initialize the price builder with token configuration.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
token_address: ERC20 token contract address
|
|
75
|
+
token_decimals: Token decimals (6 for USDC, 18 for most ERC20)
|
|
76
|
+
network: Blockchain network (sepolia, base-sepolia, base-mainnet, etc.)
|
|
77
|
+
token_symbol: Token symbol for display (USDC, TRAIA, DAI, etc.)
|
|
78
|
+
eip712_name: EIP-712 domain name (default: "IATPWallet")
|
|
79
|
+
eip712_version: EIP-712 domain version (default: "1")
|
|
80
|
+
"""
|
|
81
|
+
self.token_address = token_address
|
|
82
|
+
self.token_decimals = token_decimals
|
|
83
|
+
self.network = network
|
|
84
|
+
self.token_symbol = token_symbol
|
|
85
|
+
self.eip712_domain = EIP712Domain(
|
|
86
|
+
name=eip712_name,
|
|
87
|
+
version=eip712_version
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def create_price(self, amount_usd: float) -> TokenAmount:
|
|
91
|
+
"""
|
|
92
|
+
Create a TokenAmount from USD amount.
|
|
93
|
+
|
|
94
|
+
Automatically converts USD to token's atomic units based on decimals.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
amount_usd: Amount in USD (e.g., 0.01 for 1 cent, 0.05 for 5 cents)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
TokenAmount object ready to use in payment configs
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
# USDC (6 decimals)
|
|
104
|
+
price = builder.create_price(0.01) # → "10000" wei (0.01 * 10^6)
|
|
105
|
+
price = builder.create_price(0.001) # → "1000" wei
|
|
106
|
+
price = builder.create_price(1.00) # → "1000000" wei
|
|
107
|
+
|
|
108
|
+
# TRAIA (18 decimals)
|
|
109
|
+
price = builder.create_price(0.01) # → "10000000000000000" wei (0.01 * 10^18)
|
|
110
|
+
|
|
111
|
+
# Use in decorator
|
|
112
|
+
@require_payment(price=price, description="API call")
|
|
113
|
+
async def my_endpoint():
|
|
114
|
+
pass
|
|
115
|
+
"""
|
|
116
|
+
# Convert USD to wei/atomic units based on token decimals
|
|
117
|
+
# Formula: amount_wei = amount_usd * (10 ** decimals)
|
|
118
|
+
amount_wei = str(int(amount_usd * (10 ** self.token_decimals)))
|
|
119
|
+
|
|
120
|
+
return TokenAmount(
|
|
121
|
+
amount=amount_wei,
|
|
122
|
+
asset=TokenAsset(
|
|
123
|
+
address=self.token_address,
|
|
124
|
+
decimals=self.token_decimals,
|
|
125
|
+
network=self.network,
|
|
126
|
+
symbol=self.token_symbol,
|
|
127
|
+
eip712=self.eip712_domain
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def create_price_wei(self, amount_wei: str) -> TokenAmount:
|
|
132
|
+
"""
|
|
133
|
+
Create a TokenAmount from wei/atomic units directly.
|
|
134
|
+
|
|
135
|
+
Useful when you already know the exact atomic amount.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
amount_wei: Amount in atomic units as string (e.g., "10000" for 0.01 USDC)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
TokenAmount object ready to use in payment configs
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
# Exact atomic amount (useful for non-USD pricing)
|
|
145
|
+
price = builder.create_price_wei("10000") # Exactly 10000 atomic units
|
|
146
|
+
"""
|
|
147
|
+
return TokenAmount(
|
|
148
|
+
amount=amount_wei,
|
|
149
|
+
asset=TokenAsset(
|
|
150
|
+
address=self.token_address,
|
|
151
|
+
decimals=self.token_decimals,
|
|
152
|
+
network=self.network,
|
|
153
|
+
symbol=self.token_symbol,
|
|
154
|
+
eip712=self.eip712_domain
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
return f"D402PriceBuilder(token={self.token_symbol}, network={self.network}, decimals={self.token_decimals})"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["D402PriceBuilder"]
|
|
163
|
+
|
|
164
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""D402 payment middleware for various server frameworks.
|
|
2
|
+
|
|
3
|
+
This module provides framework-specific middleware implementations for the d402
|
|
4
|
+
payment protocol. Each framework has its own module with appropriate middleware.
|
|
5
|
+
|
|
6
|
+
Supported frameworks:
|
|
7
|
+
- Starlette: For Starlette-based applications (including FastMCP)
|
|
8
|
+
- FastAPI: For FastAPI applications
|
|
9
|
+
- MCP: For Model Context Protocol (MCP) servers with tool decorators
|
|
10
|
+
|
|
11
|
+
Usage examples:
|
|
12
|
+
|
|
13
|
+
1. MCP Server (FastMCP):
|
|
14
|
+
from traia_iatp.d402.servers import D402PaymentMiddleware, require_payment_for_tool
|
|
15
|
+
|
|
16
|
+
app = mcp.streamable_http_app()
|
|
17
|
+
app.add_middleware(D402PaymentMiddleware, ...)
|
|
18
|
+
|
|
19
|
+
2. FastAPI Server:
|
|
20
|
+
from traia_iatp.d402.servers.fastapi import D402FastAPIMiddleware, require_payment
|
|
21
|
+
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
middleware = D402FastAPIMiddleware(...)
|
|
24
|
+
middleware.add_to_app(app)
|
|
25
|
+
|
|
26
|
+
3. Starlette Server:
|
|
27
|
+
from traia_iatp.d402.servers import D402PaymentMiddleware
|
|
28
|
+
|
|
29
|
+
app = Starlette()
|
|
30
|
+
app.add_middleware(D402PaymentMiddleware, ...)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from .starlette import (
|
|
34
|
+
D402PaymentMiddleware,
|
|
35
|
+
require_payment,
|
|
36
|
+
extract_payment_configs,
|
|
37
|
+
build_payment_config,
|
|
38
|
+
)
|
|
39
|
+
from .mcp import (
|
|
40
|
+
EndpointPaymentInfo,
|
|
41
|
+
get_active_api_key,
|
|
42
|
+
require_payment_for_tool,
|
|
43
|
+
settle_payment,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Starlette middleware (works for any Starlette-based app)
|
|
48
|
+
"D402PaymentMiddleware",
|
|
49
|
+
|
|
50
|
+
# Generic decorators and extractors (for any server type)
|
|
51
|
+
"require_payment",
|
|
52
|
+
"extract_payment_configs",
|
|
53
|
+
"build_payment_config",
|
|
54
|
+
|
|
55
|
+
# MCP-specific helpers (wraps require_payment for MCP tools)
|
|
56
|
+
"EndpointPaymentInfo",
|
|
57
|
+
"get_active_api_key",
|
|
58
|
+
"require_payment_for_tool",
|
|
59
|
+
"settle_payment",
|
|
60
|
+
]
|
|
61
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Base classes and utilities for d402 server middleware.
|
|
2
|
+
|
|
3
|
+
This module provides shared functionality used across different server framework
|
|
4
|
+
implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, Any, Optional
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PaymentMode(Enum):
|
|
15
|
+
"""Payment mode for a request."""
|
|
16
|
+
FREE_WITH_API_KEY = "free_with_api_key" # Client has valid API key
|
|
17
|
+
PAID_WITH_SERVER_KEY = "paid_with_server_key" # Client paid, server uses internal key
|
|
18
|
+
NO_PAYMENT = "no_payment" # No payment required
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BasePaymentConfig:
|
|
22
|
+
"""Base configuration for payment-enabled servers.
|
|
23
|
+
|
|
24
|
+
This class provides common configuration used across all server frameworks.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
server_address: str,
|
|
30
|
+
requires_auth: bool = False,
|
|
31
|
+
internal_api_key: Optional[str] = None,
|
|
32
|
+
testing_mode: bool = False,
|
|
33
|
+
facilitator_url: Optional[str] = None,
|
|
34
|
+
facilitator_api_key: Optional[str] = None,
|
|
35
|
+
server_name: Optional[str] = None
|
|
36
|
+
):
|
|
37
|
+
"""Initialize payment configuration.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
server_address: Address where payments should be sent
|
|
41
|
+
requires_auth: Whether server accepts API keys for free access
|
|
42
|
+
internal_api_key: Server's internal API key (used when client pays)
|
|
43
|
+
testing_mode: If True, skip facilitator verification
|
|
44
|
+
facilitator_url: URL of the payment facilitator
|
|
45
|
+
facilitator_api_key: API key for facilitator authentication
|
|
46
|
+
server_name: Name/ID of this server for tracking
|
|
47
|
+
"""
|
|
48
|
+
self.server_address = server_address
|
|
49
|
+
self.requires_auth = requires_auth
|
|
50
|
+
self.internal_api_key = internal_api_key
|
|
51
|
+
self.testing_mode = testing_mode
|
|
52
|
+
self.facilitator_url = facilitator_url
|
|
53
|
+
self.facilitator_api_key = facilitator_api_key
|
|
54
|
+
self.server_name = server_name
|
|
55
|
+
|
|
56
|
+
self.validate()
|
|
57
|
+
|
|
58
|
+
def validate(self):
|
|
59
|
+
"""Validate configuration."""
|
|
60
|
+
if not self.server_address:
|
|
61
|
+
raise ValueError("server_address is required")
|
|
62
|
+
|
|
63
|
+
if not self.testing_mode and not self.facilitator_url:
|
|
64
|
+
raise ValueError("facilitator_url required when testing_mode is False")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_api_key_from_headers(headers: Dict[str, str]) -> Optional[str]:
|
|
68
|
+
"""Extract API key from request headers.
|
|
69
|
+
|
|
70
|
+
Supports both Authorization: Bearer <token> and X-API-KEY: <token> formats.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
headers: Request headers (dict or Headers object)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
API key string if found, None otherwise
|
|
77
|
+
"""
|
|
78
|
+
# Authorization header
|
|
79
|
+
auth = headers.get("authorization") or headers.get("Authorization", "")
|
|
80
|
+
if auth.lower().startswith("bearer "):
|
|
81
|
+
return auth[7:].strip()
|
|
82
|
+
|
|
83
|
+
# X-API-KEY header
|
|
84
|
+
api_key = headers.get("x-api-key") or headers.get("X-API-KEY", "")
|
|
85
|
+
if api_key:
|
|
86
|
+
return api_key.strip()
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def log_payment_verification_start(tool_name: str, has_auth: bool, payment_header_present: bool):
|
|
92
|
+
"""Log start of payment verification process.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
tool_name: Name of the tool/endpoint being called
|
|
96
|
+
has_auth: Whether client has API key
|
|
97
|
+
payment_header_present: Whether payment header is present
|
|
98
|
+
"""
|
|
99
|
+
if has_auth:
|
|
100
|
+
logger.info(f"✅ {tool_name}: Client authenticated with API key (Mode 1: Free)")
|
|
101
|
+
elif payment_header_present:
|
|
102
|
+
logger.info(f"💰 {tool_name}: Payment header RECEIVED - validating...")
|
|
103
|
+
else:
|
|
104
|
+
logger.info(f"💰 {tool_name}: Payment required (Mode 2) - HTTP 402")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def log_payment_validation_success(
|
|
108
|
+
tool_name: str,
|
|
109
|
+
payment_amount: int,
|
|
110
|
+
required_amount: int,
|
|
111
|
+
from_address: str,
|
|
112
|
+
to_address: str,
|
|
113
|
+
request_path: str
|
|
114
|
+
):
|
|
115
|
+
"""Log successful payment validation.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
tool_name: Name of the tool/endpoint
|
|
119
|
+
payment_amount: Amount paid
|
|
120
|
+
required_amount: Amount required
|
|
121
|
+
from_address: Payer address
|
|
122
|
+
to_address: Payee address
|
|
123
|
+
request_path: Request path for signature binding
|
|
124
|
+
"""
|
|
125
|
+
logger.info(f"✅ {tool_name}: Payment VERIFIED successfully (Mode 2: Paid)")
|
|
126
|
+
logger.info(f" Payment amount: {payment_amount} wei (required: {required_amount} wei)")
|
|
127
|
+
logger.info(f" From (wallet): {from_address}")
|
|
128
|
+
logger.info(f" To (provider): {to_address}")
|
|
129
|
+
logger.info(f" Request path: {request_path}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
"PaymentMode",
|
|
134
|
+
"BasePaymentConfig",
|
|
135
|
+
"extract_api_key_from_headers",
|
|
136
|
+
"log_payment_verification_start",
|
|
137
|
+
"log_payment_validation_success",
|
|
138
|
+
]
|
|
139
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example: General-purpose API server with D402 payment support.
|
|
3
|
+
|
|
4
|
+
This example shows how to use the @require_payment decorator for a
|
|
5
|
+
custom FastAPI server with multiple endpoints at different prices.
|
|
6
|
+
|
|
7
|
+
This is the same pattern as MCP servers, just generalized for any endpoint!
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from fastapi import FastAPI, Request
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from traia_iatp.d402.servers import (
|
|
15
|
+
D402PaymentMiddleware,
|
|
16
|
+
require_payment,
|
|
17
|
+
extract_payment_configs
|
|
18
|
+
)
|
|
19
|
+
from traia_iatp.d402 import D402PriceBuilder
|
|
20
|
+
|
|
21
|
+
# Initialize FastAPI app
|
|
22
|
+
app = FastAPI(title="Paid Analysis API")
|
|
23
|
+
|
|
24
|
+
# Get configuration from environment
|
|
25
|
+
SERVER_ADDRESS = os.getenv("SERVER_ADDRESS", "0x1234567890123456789012345678901234567890")
|
|
26
|
+
NETWORK = os.getenv("NETWORK", "sepolia")
|
|
27
|
+
TOKEN_ADDRESS = os.getenv("TOKEN_ADDRESS", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238")
|
|
28
|
+
TOKEN_DECIMALS = int(os.getenv("TOKEN_DECIMALS", "6"))
|
|
29
|
+
TOKEN_SYMBOL = os.getenv("TOKEN_SYMBOL", "USDC")
|
|
30
|
+
|
|
31
|
+
# Create price builder - works with ANY token!
|
|
32
|
+
# Just pass your token configuration from environment
|
|
33
|
+
price_builder = D402PriceBuilder(
|
|
34
|
+
token_address=TOKEN_ADDRESS,
|
|
35
|
+
token_decimals=TOKEN_DECIMALS,
|
|
36
|
+
network=NETWORK,
|
|
37
|
+
token_symbol=TOKEN_SYMBOL
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# API ENDPOINTS - Different prices for different operations
|
|
42
|
+
# ============================================================================
|
|
43
|
+
|
|
44
|
+
@app.post("/quick-check")
|
|
45
|
+
@require_payment(
|
|
46
|
+
price=price_builder.create_price(0.001), # $0.001 - uses builder!
|
|
47
|
+
endpoint_path="/quick-check",
|
|
48
|
+
description="Quick health check of data"
|
|
49
|
+
)
|
|
50
|
+
async def quick_check(request: Request, data: dict):
|
|
51
|
+
"""Cheap, fast check - only $0.001"""
|
|
52
|
+
return {"status": "ok", "data_quality": "good"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.post("/analyze")
|
|
56
|
+
@require_payment(
|
|
57
|
+
price=price_builder.create_price(0.01), # $0.01 - uses builder!
|
|
58
|
+
endpoint_path="/analyze",
|
|
59
|
+
description="Standard analysis"
|
|
60
|
+
)
|
|
61
|
+
async def analyze(request: Request, data: dict):
|
|
62
|
+
"""Standard analysis - $0.01"""
|
|
63
|
+
return {"analysis": "detailed results", "confidence": 0.95}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.post("/deep-analysis")
|
|
67
|
+
@require_payment(
|
|
68
|
+
price=price_builder.create_price(0.05), # $0.05 - uses builder!
|
|
69
|
+
endpoint_path="/deep-analysis",
|
|
70
|
+
description="Comprehensive deep analysis with ML models"
|
|
71
|
+
)
|
|
72
|
+
async def deep_analysis(request: Request, data: dict):
|
|
73
|
+
"""Expensive operation - $0.05"""
|
|
74
|
+
return {
|
|
75
|
+
"analysis": "very detailed results",
|
|
76
|
+
"confidence": 0.99,
|
|
77
|
+
"model": "advanced-ml-v2"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/health")
|
|
82
|
+
async def health():
|
|
83
|
+
"""Health check - FREE (no decorator, no payment required)"""
|
|
84
|
+
return {"status": "healthy"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ============================================================================
|
|
88
|
+
# D402 MIDDLEWARE SETUP
|
|
89
|
+
# ============================================================================
|
|
90
|
+
|
|
91
|
+
def create_app_with_middleware():
|
|
92
|
+
"""Add D402 middleware to the app."""
|
|
93
|
+
|
|
94
|
+
# Extract payment configs from @require_payment decorators
|
|
95
|
+
# This is the SAME approach as MCP servers, just generalized!
|
|
96
|
+
payment_configs = extract_payment_configs(app, SERVER_ADDRESS)
|
|
97
|
+
|
|
98
|
+
print("=" * 80)
|
|
99
|
+
print("D402 Payment Configuration:")
|
|
100
|
+
print(f" Server Address: {SERVER_ADDRESS}")
|
|
101
|
+
print(f" Protected endpoints: {len(payment_configs)}")
|
|
102
|
+
for path, config in payment_configs.items():
|
|
103
|
+
price_usd = config['price_float']
|
|
104
|
+
print(f" {path}: ${price_usd} USD")
|
|
105
|
+
print("=" * 80)
|
|
106
|
+
|
|
107
|
+
# Add D402 middleware
|
|
108
|
+
facilitator_url = os.getenv("D402_FACILITATOR_URL", "http://localhost:7070")
|
|
109
|
+
testing_mode = os.getenv("D402_TESTING_MODE", "true").lower() == "true"
|
|
110
|
+
|
|
111
|
+
app.add_middleware(
|
|
112
|
+
D402PaymentMiddleware,
|
|
113
|
+
server_address=SERVER_ADDRESS,
|
|
114
|
+
tool_payment_configs=payment_configs, # Same interface as MCP!
|
|
115
|
+
requires_auth=False, # Payment only (no API keys)
|
|
116
|
+
testing_mode=testing_mode,
|
|
117
|
+
facilitator_url=facilitator_url,
|
|
118
|
+
server_name="example-analysis-api"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
print("✅ D402 middleware added")
|
|
122
|
+
print(f" Testing mode: {testing_mode}")
|
|
123
|
+
print(f" Facilitator: {facilitator_url}")
|
|
124
|
+
print("=" * 80)
|
|
125
|
+
|
|
126
|
+
return app
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
# Create app with middleware
|
|
131
|
+
app_with_d402 = create_app_with_middleware()
|
|
132
|
+
|
|
133
|
+
# Run server
|
|
134
|
+
port = int(os.getenv("PORT", "8000"))
|
|
135
|
+
uvicorn.run(
|
|
136
|
+
app_with_d402,
|
|
137
|
+
host="0.0.0.0",
|
|
138
|
+
port=port
|
|
139
|
+
)
|
|
140
|
+
|