paynode-sdk-python 1.4.0__tar.gz → 2.0.0__tar.gz
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.
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/PKG-INFO +13 -2
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/README.md +12 -1
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk/__init__.py +2 -2
- paynode_sdk_python-2.0.0/paynode_sdk/client.py +314 -0
- paynode_sdk_python-2.0.0/paynode_sdk/idempotency.py +67 -0
- paynode_sdk_python-2.0.0/paynode_sdk/middleware.py +150 -0
- paynode_sdk_python-2.0.0/paynode_sdk/verifier.py +288 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk/webhook.py +6 -6
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk_python.egg-info/PKG-INFO +13 -2
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk_python.egg-info/SOURCES.txt +3 -1
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/pyproject.toml +1 -1
- paynode_sdk_python-2.0.0/tests/test_client.py +190 -0
- paynode_sdk_python-2.0.0/tests/test_internals.py +99 -0
- paynode_sdk_python-2.0.0/tests/test_verifier_logic.py +71 -0
- paynode_sdk_python-1.4.0/paynode_sdk/client.py +0 -237
- paynode_sdk_python-1.4.0/paynode_sdk/idempotency.py +0 -33
- paynode_sdk_python-1.4.0/paynode_sdk/middleware.py +0 -110
- paynode_sdk_python-1.4.0/paynode_sdk/verifier.py +0 -134
- paynode_sdk_python-1.4.0/tests/test_client.py +0 -108
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/LICENSE +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk/constants.py +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk/errors.py +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-1.4.0 → paynode_sdk_python-2.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paynode-sdk-python
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: PayNode Protocol Python SDK for AI Agents
|
|
5
5
|
Author-email: PayNodeLabs <contact@paynode.dev>
|
|
6
6
|
License: MIT
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
[](https://docs.paynode.dev)
|
|
24
24
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
25
25
|
|
|
26
|
-
The official Python SDK for the **PayNode Protocol**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol.
|
|
26
|
+
The official Python SDK for the **PayNode Protocol (v3.1)**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
27
27
|
|
|
28
28
|
## 📖 Read the Docs
|
|
29
29
|
|
|
@@ -54,6 +54,17 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
54
54
|
print(response.json())
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### Key Features (v2.0)
|
|
58
|
+
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
59
|
+
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
60
|
+
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
|
61
|
+
- **FastAPI Middleware**: Easy-to-use middleware for merchants to protect their API routes.
|
|
62
|
+
|
|
63
|
+
## 🗺️ Roadmap
|
|
64
|
+
- **TRON Support**: USDT (TRC-20) payment integration.
|
|
65
|
+
- **Solana Support**: SPL USDC/USDT payment integration.
|
|
66
|
+
- **Cross-chain**: Universal settlement via bridges.
|
|
67
|
+
|
|
57
68
|
## 🚀 Run the Demo
|
|
58
69
|
|
|
59
70
|
The SDK includes a complete Merchant/Agent demo in the `examples/` directory.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://docs.paynode.dev)
|
|
4
4
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
5
5
|
|
|
6
|
-
The official Python SDK for the **PayNode Protocol**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol.
|
|
6
|
+
The official Python SDK for the **PayNode Protocol (v3.1)**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
7
7
|
|
|
8
8
|
## 📖 Read the Docs
|
|
9
9
|
|
|
@@ -34,6 +34,17 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
34
34
|
print(response.json())
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
### Key Features (v2.0)
|
|
38
|
+
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
39
|
+
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
40
|
+
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
|
41
|
+
- **FastAPI Middleware**: Easy-to-use middleware for merchants to protect their API routes.
|
|
42
|
+
|
|
43
|
+
## 🗺️ Roadmap
|
|
44
|
+
- **TRON Support**: USDT (TRC-20) payment integration.
|
|
45
|
+
- **Solana Support**: SPL USDC/USDT payment integration.
|
|
46
|
+
- **Cross-chain**: Universal settlement via bridges.
|
|
47
|
+
|
|
37
48
|
## 🚀 Run the Demo
|
|
38
49
|
|
|
39
50
|
The SDK includes a complete Merchant/Agent demo in the `examples/` directory.
|
|
@@ -8,7 +8,7 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="websocket
|
|
|
8
8
|
from .middleware import PayNodeMiddleware, x402_gate
|
|
9
9
|
from .verifier import PayNodeVerifier
|
|
10
10
|
from .errors import ErrorCode, PayNodeException
|
|
11
|
-
from .idempotency import IdempotencyStore, MemoryIdempotencyStore
|
|
11
|
+
from .idempotency import IdempotencyStore, MemoryIdempotencyStore, RedisIdempotencyStore
|
|
12
12
|
from .webhook import PayNodeWebhookNotifier, PaymentEvent
|
|
13
13
|
from .client import PayNodeAgentClient
|
|
14
14
|
from .constants import (
|
|
@@ -22,7 +22,7 @@ from .constants import (
|
|
|
22
22
|
|
|
23
23
|
__all__ = [
|
|
24
24
|
"PayNodeMiddleware", "x402_gate", "PayNodeVerifier", "ErrorCode", "PayNodeException",
|
|
25
|
-
"IdempotencyStore", "MemoryIdempotencyStore",
|
|
25
|
+
"IdempotencyStore", "MemoryIdempotencyStore", "RedisIdempotencyStore",
|
|
26
26
|
"PayNodeWebhookNotifier", "PaymentEvent",
|
|
27
27
|
"PayNodeAgentClient",
|
|
28
28
|
"PAYNODE_ROUTER_ADDRESS", "PAYNODE_ROUTER_ADDRESS_SANDBOX",
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
import requests
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from eth_account.messages import encode_typed_data
|
|
8
|
+
from web3 import Web3
|
|
9
|
+
from requests.adapters import HTTPAdapter
|
|
10
|
+
from urllib3.util.retry import Retry
|
|
11
|
+
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
|
|
12
|
+
from .errors import PayNodeException, ErrorCode
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("paynode_sdk.client")
|
|
15
|
+
|
|
16
|
+
class PayNodeAgentClient:
|
|
17
|
+
"""
|
|
18
|
+
The main PayNode Client for AI Agents (v3.1).
|
|
19
|
+
Automatically handles the x402 'Payment Required' handshake.
|
|
20
|
+
Supports RPC redundancy, EIP-2612 Permit, and EIP-3009 Authorization.
|
|
21
|
+
"""
|
|
22
|
+
def __init__(self, private_key: str, rpc_urls: list | str = BASE_RPC_URLS):
|
|
23
|
+
self.rpc_urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
|
|
24
|
+
self.w3 = self._init_w3()
|
|
25
|
+
|
|
26
|
+
# Initialize account and discard private key string
|
|
27
|
+
self.account = self.w3.eth.account.from_key(private_key)
|
|
28
|
+
self.nonce_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
# Setup session
|
|
31
|
+
self.session = requests.Session()
|
|
32
|
+
retry_strategy = Retry(
|
|
33
|
+
total=3,
|
|
34
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
35
|
+
allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"]
|
|
36
|
+
)
|
|
37
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
38
|
+
self.session.mount("https://", adapter)
|
|
39
|
+
self.session.mount("http://", adapter)
|
|
40
|
+
|
|
41
|
+
def _init_w3(self):
|
|
42
|
+
"""Finds a working RPC from the list concurrently (Faster initialization)."""
|
|
43
|
+
|
|
44
|
+
def _check_rpc(rpc_url):
|
|
45
|
+
try:
|
|
46
|
+
temp_w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={'timeout': 3}))
|
|
47
|
+
if temp_w3.is_connected():
|
|
48
|
+
return temp_w3
|
|
49
|
+
return None
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
with ThreadPoolExecutor(max_workers=min(len(self.rpc_urls), 5)) as executor:
|
|
54
|
+
future_to_url = {executor.submit(_check_rpc, url): url for url in self.rpc_urls}
|
|
55
|
+
# Return the first one that succeeds
|
|
56
|
+
for future in as_completed(future_to_url):
|
|
57
|
+
w3_instance = future.result()
|
|
58
|
+
if w3_instance:
|
|
59
|
+
logger.debug(f"⚡ [PayNode-PY] Connected to RPC: {future_to_url[future]}")
|
|
60
|
+
return w3_instance
|
|
61
|
+
|
|
62
|
+
raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
|
|
63
|
+
|
|
64
|
+
def request_gate(self, url: str, method: str = "GET", **kwargs):
|
|
65
|
+
"""The high-level autonomous method handling 402 loop."""
|
|
66
|
+
return self._request_with_402_retry(method.upper(), url, **kwargs)
|
|
67
|
+
|
|
68
|
+
def get(self, url, **kwargs):
|
|
69
|
+
return self.request_gate(url, "GET", **kwargs)
|
|
70
|
+
|
|
71
|
+
def post(self, url, **kwargs):
|
|
72
|
+
return self.request_gate(url, "POST", **kwargs)
|
|
73
|
+
|
|
74
|
+
def _request_with_402_retry(self, method, url, max_retries=3, **kwargs):
|
|
75
|
+
response = None
|
|
76
|
+
for _ in range(max_retries):
|
|
77
|
+
response = self.session.request(method, url, **kwargs)
|
|
78
|
+
if response.status_code == 402:
|
|
79
|
+
logger.info("💡 [PayNode-PY] 402 Detected. Analyzing protocol version...")
|
|
80
|
+
|
|
81
|
+
# Check for x402 v2 (JSON body or X-402-Required header)
|
|
82
|
+
content_type = response.headers.get('Content-Type', '')
|
|
83
|
+
b64_required = response.headers.get('X-402-Required')
|
|
84
|
+
order_id = response.headers.get('X-402-Order-Id')
|
|
85
|
+
|
|
86
|
+
body = None
|
|
87
|
+
if 'application/json' in content_type:
|
|
88
|
+
try:
|
|
89
|
+
body = response.json()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.debug(f"⚠️ [PayNode-PY] Failed to parse 402 JSON body: {e}")
|
|
92
|
+
|
|
93
|
+
if not body and b64_required:
|
|
94
|
+
try:
|
|
95
|
+
import base64
|
|
96
|
+
import json
|
|
97
|
+
body = json.loads(base64.b64decode(b64_required).decode())
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"❌ [PayNode-PY] Failed to decode X-402-Required header: {e}")
|
|
100
|
+
|
|
101
|
+
if body and body.get('x402Version') == 2:
|
|
102
|
+
logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
|
|
103
|
+
if order_id: body['orderId'] = order_id
|
|
104
|
+
kwargs = self._handle_x402_v2(body, **kwargs)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
raise PayNodeException(ErrorCode.internal_error, message="Unsupported or malformed 402 response")
|
|
108
|
+
return response
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
def _handle_x402_v2(self, requirements, **kwargs):
|
|
112
|
+
chain_id = self.w3.eth.chain_id
|
|
113
|
+
caip2_chain_id = f"eip155:{chain_id}"
|
|
114
|
+
|
|
115
|
+
# Select suitable requirement
|
|
116
|
+
requirement = next((req for req in requirements.get('accepts', [])
|
|
117
|
+
if req.get('network') == caip2_chain_id), None)
|
|
118
|
+
|
|
119
|
+
if not requirement:
|
|
120
|
+
raise PayNodeException(ErrorCode.internal_error, message=f"No compatible payment requirement found for network {caip2_chain_id}")
|
|
121
|
+
|
|
122
|
+
logger.info(f"💡 [PayNode-PY] Payment request (v2): {requirement['amount']} atomic units of {requirement['asset']} to {requirement['payTo']}")
|
|
123
|
+
|
|
124
|
+
# Dust limit check
|
|
125
|
+
if int(requirement['amount']) < MIN_PAYMENT_AMOUNT:
|
|
126
|
+
raise PayNodeException(ErrorCode.amount_too_low, message=f"Payment amount {requirement['amount']} is below the minimum dust limit of {MIN_PAYMENT_AMOUNT}")
|
|
127
|
+
|
|
128
|
+
order_id = requirement.get('orderId') or requirements.get('orderId') or urlparse(kwargs.get('url', '')).path
|
|
129
|
+
|
|
130
|
+
payload_data = {}
|
|
131
|
+
ptype = requirement.get('type', 'onchain')
|
|
132
|
+
|
|
133
|
+
if ptype == 'eip3009':
|
|
134
|
+
valid_after = int(time.time()) - 60
|
|
135
|
+
valid_before = int(time.time()) + requirement.get('maxTimeoutSeconds', 3600)
|
|
136
|
+
import os
|
|
137
|
+
nonce = "0x" + os.urandom(32).hex()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
payload_data = self.sign_transfer_with_authorization(
|
|
141
|
+
requirement['asset'],
|
|
142
|
+
requirement['payTo'],
|
|
143
|
+
int(requirement['amount']),
|
|
144
|
+
valid_after,
|
|
145
|
+
valid_before,
|
|
146
|
+
nonce,
|
|
147
|
+
requirement.get('extra', {})
|
|
148
|
+
)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise PayNodeException(ErrorCode.transaction_failed, message="Failed to sign payment authorization", details=e)
|
|
151
|
+
else:
|
|
152
|
+
# type: 'onchain'
|
|
153
|
+
router_addr = requirement.get('router')
|
|
154
|
+
if not router_addr:
|
|
155
|
+
raise PayNodeException(ErrorCode.internal_error, message="On-chain payment required but no router address provided.")
|
|
156
|
+
|
|
157
|
+
logger.info(f"⚡ [PayNode-PY] Executing on-chain payment to {requirement['payTo']}...")
|
|
158
|
+
amount = int(requirement['amount'])
|
|
159
|
+
asset = requirement['asset']
|
|
160
|
+
allowance = self._get_allowance(asset, router_addr)
|
|
161
|
+
|
|
162
|
+
if allowance >= amount:
|
|
163
|
+
tx_hash = self.pay(router_addr, asset, requirement['payTo'], amount, order_id)
|
|
164
|
+
else:
|
|
165
|
+
tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id)
|
|
166
|
+
|
|
167
|
+
payload_data = {"txHash": tx_hash}
|
|
168
|
+
|
|
169
|
+
# Unified Payload for v3.1
|
|
170
|
+
payment_payload = {
|
|
171
|
+
"version": "3.1",
|
|
172
|
+
"type": ptype,
|
|
173
|
+
"orderId": order_id,
|
|
174
|
+
"payload": payload_data
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
logger.info(f"✅ [PayNode-PY] {ptype} payment prepared. Retrying request...")
|
|
178
|
+
|
|
179
|
+
import json
|
|
180
|
+
import base64
|
|
181
|
+
b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
|
|
182
|
+
|
|
183
|
+
retry_headers = kwargs.get('headers', {}).copy()
|
|
184
|
+
retry_headers.update({
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
'X-402-Payload': b64_payload,
|
|
187
|
+
'X-402-Order-Id': order_id
|
|
188
|
+
})
|
|
189
|
+
kwargs['headers'] = retry_headers
|
|
190
|
+
return kwargs
|
|
191
|
+
|
|
192
|
+
def sign_transfer_with_authorization(self, token_addr, to, amount, valid_after, valid_before, nonce, extra=None):
|
|
193
|
+
extra = extra or {}
|
|
194
|
+
token_addr = Web3.to_checksum_address(token_addr)
|
|
195
|
+
to = Web3.to_checksum_address(to)
|
|
196
|
+
|
|
197
|
+
domain = {
|
|
198
|
+
"name": extra.get("name", "USD Coin"),
|
|
199
|
+
"version": extra.get("version", "2"),
|
|
200
|
+
"chainId": self.w3.eth.chain_id,
|
|
201
|
+
"verifyingContract": token_addr,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
types = {
|
|
205
|
+
"EIP712Domain": [
|
|
206
|
+
{"name": "name", "type": "string"},
|
|
207
|
+
{"name": "version", "type": "string"},
|
|
208
|
+
{"name": "chainId", "type": "uint256"},
|
|
209
|
+
{"name": "verifyingContract", "type": "address"},
|
|
210
|
+
],
|
|
211
|
+
"TransferWithAuthorization": [
|
|
212
|
+
{"name": "from", "type": "address"},
|
|
213
|
+
{"name": "to", "type": "address"},
|
|
214
|
+
{"name": "value", "type": "uint256"},
|
|
215
|
+
{"name": "validAfter", "type": "uint256"},
|
|
216
|
+
{"name": "validBefore", "type": "uint256"},
|
|
217
|
+
{"name": "nonce", "type": "bytes32"},
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
message = {
|
|
222
|
+
"from": self.account.address,
|
|
223
|
+
"to": to,
|
|
224
|
+
"value": int(amount),
|
|
225
|
+
"validAfter": int(valid_after),
|
|
226
|
+
"validBefore": int(valid_before),
|
|
227
|
+
"nonce": Web3.to_bytes(hexstr=nonce),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
structured_data = {
|
|
231
|
+
"types": types,
|
|
232
|
+
"domain": domain,
|
|
233
|
+
"primaryType": "TransferWithAuthorization",
|
|
234
|
+
"message": message,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
signed = self.account.sign_typed_data(full_message=structured_data)
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"signature": signed.signature.hex(),
|
|
241
|
+
"authorization": {
|
|
242
|
+
"from": self.account.address,
|
|
243
|
+
"to": to,
|
|
244
|
+
"value": str(amount),
|
|
245
|
+
"validAfter": str(valid_after),
|
|
246
|
+
"validBefore": str(valid_before),
|
|
247
|
+
"nonce": nonce
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def _get_allowance(self, token_addr, spender_addr):
|
|
252
|
+
abi = [{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"}]
|
|
253
|
+
token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=abi)
|
|
254
|
+
return token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
|
|
255
|
+
|
|
256
|
+
def sign_permit(self, token_addr, spender_addr, amount, deadline=None):
|
|
257
|
+
if deadline is None:
|
|
258
|
+
deadline = int(time.time()) + 3600
|
|
259
|
+
|
|
260
|
+
token_addr = Web3.to_checksum_address(token_addr)
|
|
261
|
+
spender_addr = Web3.to_checksum_address(spender_addr)
|
|
262
|
+
|
|
263
|
+
abi = [
|
|
264
|
+
{"inputs": [{"name": "o", "type": "address"}], "name": "nonces", "outputs": [{"name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
265
|
+
{"inputs": [], "name": "name", "outputs": [{"name": "", "type": "string"}], "stateMutability": "view", "type": "function"}
|
|
266
|
+
]
|
|
267
|
+
token = self.w3.eth.contract(address=token_addr, abi=abi)
|
|
268
|
+
nonce = token.functions.nonces(self.account.address).call()
|
|
269
|
+
name = token.functions.name().call()
|
|
270
|
+
chain_id = self.w3.eth.chain_id
|
|
271
|
+
|
|
272
|
+
domain = {"name": name, "version": "1", "chainId": chain_id, "verifyingContract": token_addr}
|
|
273
|
+
message = {"owner": self.account.address, "spender": spender_addr, "value": amount, "nonce": nonce, "deadline": deadline}
|
|
274
|
+
types = {
|
|
275
|
+
"EIP712Domain": [
|
|
276
|
+
{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
|
|
277
|
+
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"},
|
|
278
|
+
],
|
|
279
|
+
"Permit": [
|
|
280
|
+
{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
|
|
281
|
+
{"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"},
|
|
282
|
+
{"name": "deadline", "type": "uint256"},
|
|
283
|
+
],
|
|
284
|
+
}
|
|
285
|
+
structured_data = {"types": types, "domain": domain, "primaryType": "Permit", "message": message}
|
|
286
|
+
signed = self.account.sign_typed_data(full_message=structured_data)
|
|
287
|
+
return {"v": signed.v, "r": Web3.to_bytes(signed.r).rjust(32, b'\0'), "s": Web3.to_bytes(signed.s).rjust(32, b'\0'), "deadline": deadline}
|
|
288
|
+
|
|
289
|
+
def pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
290
|
+
sig = self.sign_permit(token_addr, router_addr, amount)
|
|
291
|
+
router_abi = [{"inputs": [{"name": "payer", "type": "address"}, {"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}, {"name": "v", "type": "uint8"}, {"name": "r", "type": "bytes32"}, {"name": "s", "type": "bytes32"}], "name": "payWithPermit", "outputs": [], "stateMutability": "nonpayable", "type": "function"}]
|
|
292
|
+
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
293
|
+
order_id_bytes = self.w3.keccak(text=order_id)
|
|
294
|
+
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
295
|
+
with self.nonce_lock:
|
|
296
|
+
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
297
|
+
tx = router.functions.payWithPermit(self.account.address, Web3.to_checksum_address(token_addr), Web3.to_checksum_address(merchant_addr), amount, order_id_bytes, sig["deadline"], sig["v"], sig["r"], sig["s"]).build_transaction({'from': self.account.address, 'nonce': nonce, 'gas': 300000, 'gasPrice': current_gas_price})
|
|
298
|
+
signed_tx = self.account.sign_transaction(tx)
|
|
299
|
+
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
300
|
+
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
301
|
+
return self.w3.to_hex(tx_h)
|
|
302
|
+
|
|
303
|
+
def pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
304
|
+
router_abi = [{"inputs": [{"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}], "name": "pay", "outputs": [], "stateMutability": "nonpayable", "type": "function"}]
|
|
305
|
+
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
306
|
+
order_id_bytes = self.w3.keccak(text=order_id)
|
|
307
|
+
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
308
|
+
with self.nonce_lock:
|
|
309
|
+
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
310
|
+
tx = router.functions.pay(Web3.to_checksum_address(token_addr), Web3.to_checksum_address(merchant_addr), amount, order_id_bytes).build_transaction({'from': self.account.address, 'nonce': nonce, 'gas': 200000, 'gasPrice': current_gas_price})
|
|
311
|
+
signed_tx = self.account.sign_transaction(tx)
|
|
312
|
+
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
313
|
+
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
314
|
+
return self.w3.to_hex(tx_h)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
class IdempotencyStore(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
8
|
+
"""
|
|
9
|
+
Returns True if hash was newly set, False if already exists and not expired.
|
|
10
|
+
"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def delete(self, tx_hash: str) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Deletes a transaction hash from the store.
|
|
17
|
+
Used for rolling back a lock if subsequent verification fails.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class MemoryIdempotencyStore(IdempotencyStore):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.cache: Dict[str, float] = {}
|
|
24
|
+
|
|
25
|
+
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
26
|
+
now = time.time()
|
|
27
|
+
expiry = self.cache.get(tx_hash)
|
|
28
|
+
|
|
29
|
+
if expiry and expiry > now:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
self.cache[tx_hash] = now + ttl_seconds
|
|
33
|
+
self._cleanup()
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
async def delete(self, tx_hash: str) -> None:
|
|
37
|
+
self.cache.pop(tx_hash, None)
|
|
38
|
+
|
|
39
|
+
def _cleanup(self):
|
|
40
|
+
now = time.time()
|
|
41
|
+
# Simple cleanup logic: remove expired entries
|
|
42
|
+
expired_keys = [k for k, v in self.cache.items() if v <= now]
|
|
43
|
+
for k in expired_keys:
|
|
44
|
+
del self.cache[k]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RedisIdempotencyStore(IdempotencyStore):
|
|
48
|
+
"""
|
|
49
|
+
Production-ready implementation using Redis.
|
|
50
|
+
Uses `SET txHash 1 NX EX ttlSeconds` for atomic check-and-set.
|
|
51
|
+
|
|
52
|
+
Requires: pip install redis
|
|
53
|
+
Usage:
|
|
54
|
+
import redis
|
|
55
|
+
store = RedisIdempotencyStore(redis.Redis(host='localhost', port=6379))
|
|
56
|
+
"""
|
|
57
|
+
def __init__(self, redis_client, prefix: str = "paynode:tx:"):
|
|
58
|
+
self.redis = redis_client
|
|
59
|
+
self.prefix = prefix
|
|
60
|
+
|
|
61
|
+
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
62
|
+
key = f"{self.prefix}{tx_hash}"
|
|
63
|
+
return bool(self.redis.set(key, 1, ex=ttl_seconds, nx=True))
|
|
64
|
+
|
|
65
|
+
async def delete(self, tx_hash: str) -> None:
|
|
66
|
+
key = f"{self.prefix}{tx_hash}"
|
|
67
|
+
self.redis.delete(key)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Callable, Any
|
|
6
|
+
from fastapi import Request, Response
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
from .verifier import PayNodeVerifier
|
|
9
|
+
from .errors import ErrorCode
|
|
10
|
+
from .idempotency import IdempotencyStore
|
|
11
|
+
from .constants import (
|
|
12
|
+
BASE_RPC_URLS,
|
|
13
|
+
PAYNODE_ROUTER_ADDRESS,
|
|
14
|
+
BASE_USDC_ADDRESS,
|
|
15
|
+
BASE_USDC_DECIMALS
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("paynode_sdk.middleware")
|
|
21
|
+
|
|
22
|
+
class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: Any,
|
|
26
|
+
merchant_address: str,
|
|
27
|
+
price: str,
|
|
28
|
+
contract_address: str = PAYNODE_ROUTER_ADDRESS,
|
|
29
|
+
chain_id: int = 8453,
|
|
30
|
+
currency: str = "USDC",
|
|
31
|
+
token_address: str = BASE_USDC_ADDRESS,
|
|
32
|
+
decimals: int = BASE_USDC_DECIMALS,
|
|
33
|
+
rpc_urls: list | str = BASE_RPC_URLS,
|
|
34
|
+
store: Optional[IdempotencyStore] = None,
|
|
35
|
+
generate_order_id: Optional[Callable[[Request], str]] = None,
|
|
36
|
+
**kwargs
|
|
37
|
+
):
|
|
38
|
+
super().__init__(app)
|
|
39
|
+
# The Verifier holds the state of the idempotency store
|
|
40
|
+
self.verifier = PayNodeVerifier(rpc_urls=rpc_urls, contract_address=contract_address, chain_id=chain_id, store=store)
|
|
41
|
+
self.merchant_address = merchant_address
|
|
42
|
+
self.contract_address = contract_address
|
|
43
|
+
self.currency = currency
|
|
44
|
+
self.token_address = token_address
|
|
45
|
+
self.price = price
|
|
46
|
+
self.decimals = decimals
|
|
47
|
+
self.chain_id = chain_id
|
|
48
|
+
self.generate_order_id = generate_order_id or (lambda r: f"agent_py_{int(time.time() * 1000)}")
|
|
49
|
+
|
|
50
|
+
self.amount_int = int(float(price) * (10 ** decimals))
|
|
51
|
+
self.description = kwargs.get('description', "Protected Resource")
|
|
52
|
+
self.max_timeout_seconds = kwargs.get('max_timeout_seconds', 3600)
|
|
53
|
+
|
|
54
|
+
async def dispatch(self, request: Request, call_next):
|
|
55
|
+
v2_payload_header = request.headers.get('x-402-payload')
|
|
56
|
+
order_id = request.headers.get('x-402-order-id')
|
|
57
|
+
|
|
58
|
+
if not order_id:
|
|
59
|
+
order_id = self.generate_order_id(request)
|
|
60
|
+
|
|
61
|
+
# Handle x402 v2 Unified Payload
|
|
62
|
+
unified_payload = None
|
|
63
|
+
if v2_payload_header:
|
|
64
|
+
try:
|
|
65
|
+
unified_payload = json.loads(base64.b64decode(v2_payload_header.encode()).decode())
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"❌ [PayNode-Middleware] Failed to decode X-402-Payload header: {e}")
|
|
68
|
+
|
|
69
|
+
if unified_payload:
|
|
70
|
+
try:
|
|
71
|
+
result = await self.verifier.verify(
|
|
72
|
+
unified_payload,
|
|
73
|
+
{
|
|
74
|
+
"merchantAddress": self.merchant_address,
|
|
75
|
+
"tokenAddress": self.token_address,
|
|
76
|
+
"amount": str(self.amount_int),
|
|
77
|
+
"orderId": order_id
|
|
78
|
+
},
|
|
79
|
+
unified_payload.get("payload", {}).get("extra", {}) if unified_payload.get("type") == "eip3009" else {}
|
|
80
|
+
)
|
|
81
|
+
if result.get("isValid"):
|
|
82
|
+
request.state.paynode = {"unified_payload": unified_payload, "order_id": order_id}
|
|
83
|
+
return await call_next(request)
|
|
84
|
+
else:
|
|
85
|
+
err = result.get("error")
|
|
86
|
+
return JSONResponse(
|
|
87
|
+
status_code=403,
|
|
88
|
+
content={
|
|
89
|
+
"error": "Forbidden",
|
|
90
|
+
"code": err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt,
|
|
91
|
+
"message": str(err)
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(f"⚠️ [PayNode-Middleware] Failed to process x402 v2 payload: {e}")
|
|
96
|
+
|
|
97
|
+
# No valid payment found, return 402 with X-402-Required
|
|
98
|
+
v2_response = {
|
|
99
|
+
"x402Version": 2,
|
|
100
|
+
"error": "Payment Required by PayNode",
|
|
101
|
+
"resource": {
|
|
102
|
+
"url": str(request.url),
|
|
103
|
+
"description": self.description,
|
|
104
|
+
"mimeType": request.headers.get("accept", "application/json")
|
|
105
|
+
},
|
|
106
|
+
"accepts": [
|
|
107
|
+
{
|
|
108
|
+
"scheme": "exact",
|
|
109
|
+
"type": "eip3009",
|
|
110
|
+
"network": f"eip155:{self.chain_id}",
|
|
111
|
+
"amount": str(self.amount_int),
|
|
112
|
+
"asset": self.token_address,
|
|
113
|
+
"payTo": self.merchant_address,
|
|
114
|
+
"maxTimeoutSeconds": self.max_timeout_seconds,
|
|
115
|
+
"extra": {
|
|
116
|
+
"name": self.currency,
|
|
117
|
+
"version": "2"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"scheme": "exact",
|
|
122
|
+
"type": "onchain",
|
|
123
|
+
"network": f"eip155:{self.chain_id}",
|
|
124
|
+
"amount": str(self.amount_int),
|
|
125
|
+
"asset": self.token_address,
|
|
126
|
+
"payTo": self.merchant_address,
|
|
127
|
+
"maxTimeoutSeconds": self.max_timeout_seconds,
|
|
128
|
+
"router": self.contract_address
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
b64_required = base64.b64encode(json.dumps(v2_response).encode()).decode()
|
|
134
|
+
|
|
135
|
+
headers = {
|
|
136
|
+
'X-402-Required': b64_required,
|
|
137
|
+
'X-402-Order-Id': order_id,
|
|
138
|
+
}
|
|
139
|
+
return JSONResponse(status_code=402, headers=headers, content=v2_response)
|
|
140
|
+
|
|
141
|
+
def x402_gate(
|
|
142
|
+
merchant_address: str,
|
|
143
|
+
price: str,
|
|
144
|
+
**kwargs
|
|
145
|
+
) -> Any:
|
|
146
|
+
"""
|
|
147
|
+
Semantic helper to mirror JS x402Gate.
|
|
148
|
+
Usage: app.add_middleware(x402_gate, merchant_address=..., price=...)
|
|
149
|
+
"""
|
|
150
|
+
return lambda app: PayNodeMiddleware(app, merchant_address=merchant_address, price=price, **kwargs)
|