paynode-sdk-python 1.4.0__py3-none-any.whl → 2.0.0__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.
- paynode_sdk/__init__.py +2 -2
- paynode_sdk/client.py +199 -122
- paynode_sdk/idempotency.py +36 -2
- paynode_sdk/middleware.py +87 -47
- paynode_sdk/verifier.py +229 -75
- paynode_sdk/webhook.py +6 -6
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.0.0.dist-info}/METADATA +13 -2
- paynode_sdk_python-2.0.0.dist-info/RECORD +13 -0
- paynode_sdk_python-1.4.0.dist-info/RECORD +0 -13
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.0.0.dist-info}/WHEEL +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.0.0.dist-info}/top_level.txt +0 -0
paynode_sdk/__init__.py
CHANGED
|
@@ -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",
|
paynode_sdk/client.py
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import logging
|
|
3
3
|
import threading
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
5
|
import requests
|
|
6
|
+
from urllib.parse import urlparse
|
|
5
7
|
from eth_account.messages import encode_typed_data
|
|
6
8
|
from web3 import Web3
|
|
7
9
|
from requests.adapters import HTTPAdapter
|
|
8
10
|
from urllib3.util.retry import Retry
|
|
9
|
-
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS
|
|
11
|
+
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
|
|
10
12
|
from .errors import PayNodeException, ErrorCode
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger("paynode_sdk.client")
|
|
13
15
|
|
|
14
16
|
class PayNodeAgentClient:
|
|
15
17
|
"""
|
|
16
|
-
The main PayNode Client for AI Agents (
|
|
18
|
+
The main PayNode Client for AI Agents (v3.1).
|
|
17
19
|
Automatically handles the x402 'Payment Required' handshake.
|
|
18
|
-
Supports RPC redundancy
|
|
20
|
+
Supports RPC redundancy, EIP-2612 Permit, and EIP-3009 Authorization.
|
|
19
21
|
"""
|
|
20
22
|
def __init__(self, private_key: str, rpc_urls: list | str = BASE_RPC_URLS):
|
|
21
23
|
self.rpc_urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
|
|
@@ -37,16 +39,27 @@ class PayNodeAgentClient:
|
|
|
37
39
|
self.session.mount("http://", adapter)
|
|
38
40
|
|
|
39
41
|
def _init_w3(self):
|
|
40
|
-
"""Finds a working RPC from the list."""
|
|
41
|
-
|
|
42
|
+
"""Finds a working RPC from the list concurrently (Faster initialization)."""
|
|
43
|
+
|
|
44
|
+
def _check_rpc(rpc_url):
|
|
42
45
|
try:
|
|
43
|
-
|
|
44
|
-
if
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.")
|
|
50
63
|
|
|
51
64
|
def request_gate(self, url: str, method: str = "GET", **kwargs):
|
|
52
65
|
"""The high-level autonomous method handling 402 loop."""
|
|
@@ -59,83 +72,194 @@ class PayNodeAgentClient:
|
|
|
59
72
|
return self.request_gate(url, "POST", **kwargs)
|
|
60
73
|
|
|
61
74
|
def _request_with_402_retry(self, method, url, max_retries=3, **kwargs):
|
|
75
|
+
response = None
|
|
62
76
|
for _ in range(max_retries):
|
|
63
77
|
response = self.session.request(method, url, **kwargs)
|
|
64
78
|
if response.status_code == 402:
|
|
65
|
-
logger.info("💡 [PayNode-PY] 402 Detected.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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")
|
|
72
108
|
return response
|
|
73
109
|
return response
|
|
74
110
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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)
|
|
111
164
|
else:
|
|
112
|
-
|
|
113
|
-
tx_hash = self.pay_with_permit(router_addr, token_addr, merchant_addr, amount_raw, order_id)
|
|
165
|
+
tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id)
|
|
114
166
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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()
|
|
119
182
|
|
|
120
183
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
121
|
-
retry_headers.update({
|
|
184
|
+
retry_headers.update({
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
'X-402-Payload': b64_payload,
|
|
187
|
+
'X-402-Order-Id': order_id
|
|
188
|
+
})
|
|
122
189
|
kwargs['headers'] = retry_headers
|
|
123
190
|
return kwargs
|
|
124
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
|
+
|
|
125
251
|
def _get_allowance(self, token_addr, spender_addr):
|
|
126
252
|
abi = [{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"}]
|
|
127
253
|
token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=abi)
|
|
128
254
|
return token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
|
|
129
255
|
|
|
130
256
|
def sign_permit(self, token_addr, spender_addr, amount, deadline=None):
|
|
131
|
-
"""Signs EIP-2612 Permit data."""
|
|
132
257
|
if deadline is None:
|
|
133
258
|
deadline = int(time.time()) + 3600
|
|
134
259
|
|
|
135
260
|
token_addr = Web3.to_checksum_address(token_addr)
|
|
136
261
|
spender_addr = Web3.to_checksum_address(spender_addr)
|
|
137
262
|
|
|
138
|
-
# Get nonce and domain separator
|
|
139
263
|
abi = [
|
|
140
264
|
{"inputs": [{"name": "o", "type": "address"}], "name": "nonces", "outputs": [{"name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
141
265
|
{"inputs": [], "name": "name", "outputs": [{"name": "", "type": "string"}], "stateMutability": "view", "type": "function"}
|
|
@@ -145,93 +269,46 @@ class PayNodeAgentClient:
|
|
|
145
269
|
name = token.functions.name().call()
|
|
146
270
|
chain_id = self.w3.eth.chain_id
|
|
147
271
|
|
|
148
|
-
domain = {
|
|
149
|
-
|
|
150
|
-
"version": "1",
|
|
151
|
-
"chainId": chain_id,
|
|
152
|
-
"verifyingContract": token_addr,
|
|
153
|
-
}
|
|
154
|
-
message = {
|
|
155
|
-
"owner": self.account.address,
|
|
156
|
-
"spender": spender_addr,
|
|
157
|
-
"value": amount,
|
|
158
|
-
"nonce": nonce,
|
|
159
|
-
"deadline": deadline,
|
|
160
|
-
}
|
|
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}
|
|
161
274
|
types = {
|
|
162
275
|
"EIP712Domain": [
|
|
163
|
-
{"name": "name", "type": "string"},
|
|
164
|
-
{"name": "
|
|
165
|
-
{"name": "chainId", "type": "uint256"},
|
|
166
|
-
{"name": "verifyingContract", "type": "address"},
|
|
276
|
+
{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
|
|
277
|
+
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"},
|
|
167
278
|
],
|
|
168
279
|
"Permit": [
|
|
169
|
-
{"name": "owner", "type": "address"},
|
|
170
|
-
{"name": "
|
|
171
|
-
{"name": "value", "type": "uint256"},
|
|
172
|
-
{"name": "nonce", "type": "uint256"},
|
|
280
|
+
{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
|
|
281
|
+
{"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"},
|
|
173
282
|
{"name": "deadline", "type": "uint256"},
|
|
174
283
|
],
|
|
175
284
|
}
|
|
176
|
-
|
|
177
|
-
structured_data = {
|
|
178
|
-
"types": types,
|
|
179
|
-
"domain": domain,
|
|
180
|
-
"primaryType": "Permit",
|
|
181
|
-
"message": message,
|
|
182
|
-
}
|
|
183
|
-
|
|
285
|
+
structured_data = {"types": types, "domain": domain, "primaryType": "Permit", "message": message}
|
|
184
286
|
signed = self.account.sign_typed_data(full_message=structured_data)
|
|
185
|
-
return {
|
|
186
|
-
"v": signed.v,
|
|
187
|
-
"r": Web3.to_bytes(signed.r).rjust(32, b'\0'),
|
|
188
|
-
"s": Web3.to_bytes(signed.s).rjust(32, b'\0'),
|
|
189
|
-
"deadline": deadline
|
|
190
|
-
}
|
|
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}
|
|
191
288
|
|
|
192
289
|
def pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
193
|
-
"""Combines sign_permit and on-chain submission."""
|
|
194
290
|
sig = self.sign_permit(token_addr, router_addr, amount)
|
|
195
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"}]
|
|
196
292
|
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
197
293
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
198
|
-
|
|
199
294
|
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
200
295
|
with self.nonce_lock:
|
|
201
296
|
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
202
|
-
tx = router.functions.payWithPermit(
|
|
203
|
-
self.account.address,
|
|
204
|
-
Web3.to_checksum_address(token_addr),
|
|
205
|
-
Web3.to_checksum_address(merchant_addr),
|
|
206
|
-
amount,
|
|
207
|
-
order_id_bytes,
|
|
208
|
-
sig["deadline"], sig["v"], sig["r"], sig["s"]
|
|
209
|
-
).build_transaction({
|
|
210
|
-
'from': self.account.address,
|
|
211
|
-
'nonce': nonce,
|
|
212
|
-
'gas': 300000,
|
|
213
|
-
'gasPrice': current_gas_price
|
|
214
|
-
})
|
|
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})
|
|
215
298
|
signed_tx = self.account.sign_transaction(tx)
|
|
216
299
|
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
217
|
-
|
|
218
300
|
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
219
301
|
return self.w3.to_hex(tx_h)
|
|
220
302
|
|
|
221
|
-
def
|
|
222
|
-
"""Standard pay method (fallback)."""
|
|
303
|
+
def pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
223
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"}]
|
|
224
305
|
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
225
306
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
226
307
|
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
227
|
-
|
|
228
308
|
with self.nonce_lock:
|
|
229
309
|
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
230
|
-
tx = router.functions.pay(Web3.to_checksum_address(token_addr), Web3.to_checksum_address(merchant_addr), amount, order_id_bytes).build_transaction({
|
|
231
|
-
'from': self.account.address, 'nonce': nonce, 'gas': 200000, 'gasPrice': current_gas_price
|
|
232
|
-
})
|
|
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})
|
|
233
311
|
signed_tx = self.account.sign_transaction(tx)
|
|
234
312
|
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
235
|
-
|
|
236
313
|
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
237
314
|
return self.w3.to_hex(tx_h)
|
paynode_sdk/idempotency.py
CHANGED
|
@@ -10,6 +10,14 @@ class IdempotencyStore(ABC):
|
|
|
10
10
|
"""
|
|
11
11
|
pass
|
|
12
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
|
+
|
|
13
21
|
class MemoryIdempotencyStore(IdempotencyStore):
|
|
14
22
|
def __init__(self):
|
|
15
23
|
self.cache: Dict[str, float] = {}
|
|
@@ -17,17 +25,43 @@ class MemoryIdempotencyStore(IdempotencyStore):
|
|
|
17
25
|
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
18
26
|
now = time.time()
|
|
19
27
|
expiry = self.cache.get(tx_hash)
|
|
20
|
-
|
|
28
|
+
|
|
21
29
|
if expiry and expiry > now:
|
|
22
30
|
return False
|
|
23
|
-
|
|
31
|
+
|
|
24
32
|
self.cache[tx_hash] = now + ttl_seconds
|
|
25
33
|
self._cleanup()
|
|
26
34
|
return True
|
|
27
35
|
|
|
36
|
+
async def delete(self, tx_hash: str) -> None:
|
|
37
|
+
self.cache.pop(tx_hash, None)
|
|
38
|
+
|
|
28
39
|
def _cleanup(self):
|
|
29
40
|
now = time.time()
|
|
30
41
|
# Simple cleanup logic: remove expired entries
|
|
31
42
|
expired_keys = [k for k, v in self.cache.items() if v <= now]
|
|
32
43
|
for k in expired_keys:
|
|
33
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)
|
paynode_sdk/middleware.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import time
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
2
5
|
from typing import Optional, Callable, Any
|
|
3
6
|
from fastapi import Request, Response
|
|
4
7
|
from fastapi.responses import JSONResponse
|
|
@@ -14,6 +17,8 @@ from .constants import (
|
|
|
14
17
|
|
|
15
18
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
19
|
|
|
20
|
+
logger = logging.getLogger("paynode_sdk.middleware")
|
|
21
|
+
|
|
17
22
|
class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
18
23
|
def __init__(
|
|
19
24
|
self,
|
|
@@ -27,7 +32,8 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
27
32
|
decimals: int = BASE_USDC_DECIMALS,
|
|
28
33
|
rpc_urls: list | str = BASE_RPC_URLS,
|
|
29
34
|
store: Optional[IdempotencyStore] = None,
|
|
30
|
-
generate_order_id: Optional[Callable[[Request], str]] = None
|
|
35
|
+
generate_order_id: Optional[Callable[[Request], str]] = None,
|
|
36
|
+
**kwargs
|
|
31
37
|
):
|
|
32
38
|
super().__init__(app)
|
|
33
39
|
# The Verifier holds the state of the idempotency store
|
|
@@ -41,62 +47,96 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
41
47
|
self.chain_id = chain_id
|
|
42
48
|
self.generate_order_id = generate_order_id or (lambda r: f"agent_py_{int(time.time() * 1000)}")
|
|
43
49
|
|
|
44
|
-
# Calculate raw amount (integer)
|
|
45
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)
|
|
46
53
|
|
|
47
54
|
async def dispatch(self, request: Request, call_next):
|
|
48
|
-
|
|
49
|
-
order_id = request.headers.get('x-
|
|
55
|
+
v2_payload_header = request.headers.get('x-402-payload')
|
|
56
|
+
order_id = request.headers.get('x-402-order-id')
|
|
50
57
|
|
|
51
58
|
if not order_id:
|
|
52
59
|
order_id = self.generate_order_id(request)
|
|
53
60
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'x-paynode-token-address': self.token_address,
|
|
62
|
-
'x-paynode-chain-id': str(self.chain_id),
|
|
63
|
-
'x-paynode-order-id': order_id,
|
|
64
|
-
}
|
|
65
|
-
return JSONResponse(
|
|
66
|
-
status_code=402,
|
|
67
|
-
headers=headers,
|
|
68
|
-
content={
|
|
69
|
-
"error": "Payment Required",
|
|
70
|
-
"code": ErrorCode.missing_receipt,
|
|
71
|
-
"message": "Please pay to PayNode contract and provide 'x-paynode-receipt' header.",
|
|
72
|
-
"amount": self.price,
|
|
73
|
-
"currency": self.currency
|
|
74
|
-
}
|
|
75
|
-
)
|
|
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}")
|
|
76
68
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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}")
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
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
|
|
98
129
|
}
|
|
99
|
-
|
|
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)
|
|
100
140
|
|
|
101
141
|
def x402_gate(
|
|
102
142
|
merchant_address: str,
|
paynode_sdk/verifier.py
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
5
|
from .errors import ErrorCode, PayNodeException
|
|
3
|
-
from .constants import
|
|
6
|
+
from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
|
|
4
7
|
from .idempotency import MemoryIdempotencyStore
|
|
5
8
|
from web3 import Web3
|
|
9
|
+
from eth_account import Account
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("paynode_sdk.verifier")
|
|
6
12
|
|
|
7
13
|
class PayNodeVerifier:
|
|
8
14
|
def __init__(self, rpc_urls=None, contract_address=None, chain_id=None, w3=None, store=None, accepted_tokens=None):
|
|
9
15
|
self.w3 = w3
|
|
10
16
|
if not self.w3 and rpc_urls:
|
|
11
17
|
urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
def _check_rpc(url):
|
|
13
20
|
try:
|
|
14
|
-
temp_w3 = Web3(Web3.HTTPProvider(
|
|
21
|
+
temp_w3 = Web3(Web3.HTTPProvider(url, request_kwargs={'timeout': 3}))
|
|
15
22
|
if temp_w3.is_connected():
|
|
16
|
-
|
|
17
|
-
break
|
|
23
|
+
return temp_w3
|
|
18
24
|
except Exception:
|
|
19
|
-
|
|
25
|
+
pass
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
with ThreadPoolExecutor(max_workers=min(len(urls), 5)) as executor:
|
|
29
|
+
future_to_url = {executor.submit(_check_rpc, url): url for url in urls}
|
|
30
|
+
for future in as_completed(future_to_url):
|
|
31
|
+
w3_instance = future.result()
|
|
32
|
+
if w3_instance:
|
|
33
|
+
self.w3 = w3_instance
|
|
34
|
+
logger.debug(f"⚡ [PayNode-PY] Verifier connected to RPC: {future_to_url[future]}")
|
|
35
|
+
break
|
|
36
|
+
|
|
20
37
|
if not self.w3:
|
|
21
|
-
raise PayNodeException(ErrorCode.rpc_error)
|
|
38
|
+
raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
|
|
22
39
|
self.contract_address = contract_address
|
|
23
40
|
self.chain_id = int(chain_id) if chain_id else None
|
|
24
41
|
self.store = store or MemoryIdempotencyStore()
|
|
25
42
|
|
|
26
43
|
# Build accepted token set: user-provided or chain-default
|
|
27
|
-
# accepted_tokens=None → use chain default; accepted_tokens=[] → explicitly disable whitelist
|
|
28
44
|
if accepted_tokens is not None:
|
|
29
45
|
token_list = accepted_tokens
|
|
30
46
|
elif self.chain_id:
|
|
@@ -33,102 +49,240 @@ class PayNodeVerifier:
|
|
|
33
49
|
token_list = None
|
|
34
50
|
self.accepted_tokens = set(t.lower() for t in token_list) if token_list else None
|
|
35
51
|
|
|
36
|
-
async def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
async def verify(self, unified_payload: dict, expected: dict, extra: dict = None) -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Unified verification entry point for X402 V3.1 (Hybrid V2).
|
|
55
|
+
Routes to verify_onchain_payment or verify_transfer_with_authorization (eip3009).
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
payload_type = unified_payload.get("type")
|
|
59
|
+
actual_payload = unified_payload.get("payload", {})
|
|
60
|
+
order_id = unified_payload.get("orderId")
|
|
61
|
+
|
|
62
|
+
if payload_type == "onchain":
|
|
63
|
+
tx_hash = actual_payload.get("txHash")
|
|
64
|
+
if not tx_hash:
|
|
65
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Missing txHash in onchain payload")}
|
|
66
|
+
|
|
67
|
+
onchain_expected = {
|
|
68
|
+
"merchantAddress": expected.get("merchantAddress"),
|
|
69
|
+
"tokenAddress": expected.get("tokenAddress"),
|
|
70
|
+
"amount": expected.get("amount"),
|
|
71
|
+
"orderId": order_id
|
|
72
|
+
}
|
|
73
|
+
return await self.verify_onchain_payment(tx_hash, onchain_expected)
|
|
74
|
+
|
|
75
|
+
elif payload_type == "eip3009":
|
|
76
|
+
token_addr = expected.get("tokenAddress")
|
|
77
|
+
if not token_addr:
|
|
78
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message="tokenAddress is required for eip3009 verification")}
|
|
79
|
+
|
|
80
|
+
return await self.verify_transfer_with_authorization(
|
|
81
|
+
token_addr,
|
|
82
|
+
actual_payload,
|
|
83
|
+
{"to": expected.get("merchantAddress"), "value": expected.get("amount")},
|
|
84
|
+
extra
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=f"Unsupported payload type: {payload_type}")}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if isinstance(e, PayNodeException):
|
|
90
|
+
return {"isValid": False, "error": e}
|
|
91
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
|
|
56
92
|
|
|
93
|
+
async def verify_onchain_payment(self, tx_hash, expected):
|
|
94
|
+
if not self.w3:
|
|
95
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error)}
|
|
57
96
|
|
|
58
|
-
# Wrap synchronous web3 calls in asyncio.to_thread to avoid blocking the event loop
|
|
59
97
|
try:
|
|
60
98
|
receipt = await asyncio.to_thread(self.w3.eth.get_transaction_receipt, tx_hash)
|
|
61
99
|
except Exception:
|
|
62
100
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
63
101
|
|
|
64
|
-
if
|
|
102
|
+
if receipt is None:
|
|
65
103
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
66
|
-
|
|
104
|
+
|
|
67
105
|
if receipt.get("status") == 0:
|
|
68
106
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_failed)}
|
|
69
107
|
|
|
70
|
-
|
|
108
|
+
router_abi = [{"anonymous": False, "inputs": [{"indexed": True, "name": "merchant", "type": "address"}, {"indexed": True, "name": "token", "type": "address"}, {"indexed": False, "name": "amount", "type": "uint256"}, {"indexed": True, "name": "orderId", "type": "bytes32"}, {"indexed": False, "name": "chainId", "type": "uint256"}], "name": "PaymentReceived", "type": "event"}]
|
|
109
|
+
contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=router_abi)
|
|
71
110
|
|
|
72
111
|
try:
|
|
73
112
|
logs = await asyncio.to_thread(contract.events.PaymentReceived().process_receipt, receipt)
|
|
74
113
|
except Exception:
|
|
75
|
-
|
|
114
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
|
|
76
115
|
|
|
77
116
|
if not logs:
|
|
78
|
-
|
|
117
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No PaymentReceived event found")}
|
|
79
118
|
|
|
80
|
-
# Find and validate the specific log
|
|
81
119
|
merchant = expected.get("merchantAddress", "").lower()
|
|
82
120
|
token = expected.get("tokenAddress", "").lower()
|
|
83
121
|
amount = int(expected.get("amount", 0))
|
|
84
122
|
order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
|
|
85
123
|
|
|
86
|
-
last_error = None
|
|
87
124
|
valid_log_found = False
|
|
88
|
-
|
|
89
125
|
for log in logs:
|
|
90
|
-
if log.address.lower() != self.contract_address.lower():
|
|
91
|
-
last_error = last_error or PayNodeException(ErrorCode.wrong_contract)
|
|
92
|
-
continue
|
|
93
|
-
|
|
94
126
|
args = log.args
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if args.get("merchant", "").lower() != merchant:
|
|
103
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment went to a different merchant.")
|
|
104
|
-
continue
|
|
105
|
-
|
|
106
|
-
# 6. Verify Token
|
|
107
|
-
if args.get("token", "").lower() != token:
|
|
108
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment used unexpected token.")
|
|
109
|
-
continue
|
|
110
|
-
|
|
111
|
-
# 7. Verify Amount
|
|
112
|
-
if args.get("amount", 0) < amount:
|
|
113
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment amount is below required price.")
|
|
114
|
-
continue
|
|
115
|
-
|
|
116
|
-
# 8. Verify ChainId
|
|
117
|
-
if self.chain_id and args.get("chainId") != self.chain_id:
|
|
118
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="ChainId mismatch. Invalid network.")
|
|
119
|
-
continue
|
|
120
|
-
|
|
121
|
-
valid_log_found = True
|
|
122
|
-
break
|
|
123
|
-
|
|
127
|
+
if (args.get("merchant", "").lower() == merchant and
|
|
128
|
+
args.get("token", "").lower() == token and
|
|
129
|
+
args.get("amount", 0) >= amount and
|
|
130
|
+
args.get("orderId") == order_id_bytes):
|
|
131
|
+
valid_log_found = True
|
|
132
|
+
break
|
|
133
|
+
|
|
124
134
|
if not valid_log_found:
|
|
125
|
-
|
|
135
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Payment event data mismatch")}
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
is_new = await self.store.check_and_set(tx_hash, 86400)
|
|
137
|
+
if self.store:
|
|
138
|
+
is_new = await self.store.check_and_set(tx_hash, 86400)
|
|
129
139
|
if not is_new:
|
|
130
140
|
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction)}
|
|
131
|
-
except Exception as e:
|
|
132
|
-
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, details=str(e))}
|
|
133
141
|
|
|
134
142
|
return {"isValid": True}
|
|
143
|
+
|
|
144
|
+
async def verify_transfer_with_authorization(
|
|
145
|
+
self,
|
|
146
|
+
token_addr: str,
|
|
147
|
+
payload: dict,
|
|
148
|
+
expected: dict,
|
|
149
|
+
extra: dict = None
|
|
150
|
+
) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
Verifies an EIP-3009 TransferWithAuthorization signature.
|
|
153
|
+
Includes RPC state checks for balance and nonce status.
|
|
154
|
+
"""
|
|
155
|
+
if not self.w3:
|
|
156
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error, message="Verifier web3 instance missing")}
|
|
157
|
+
|
|
158
|
+
extra = extra or {}
|
|
159
|
+
try:
|
|
160
|
+
signature = payload["signature"]
|
|
161
|
+
auth = payload["authorization"]
|
|
162
|
+
|
|
163
|
+
# 1. Basic validation
|
|
164
|
+
if auth["to"].lower() != expected["to"].lower():
|
|
165
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Recipient mismatch")}
|
|
166
|
+
|
|
167
|
+
payload_value = int(auth["value"])
|
|
168
|
+
expected_value = int(expected["value"])
|
|
169
|
+
if payload_value < expected_value:
|
|
170
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
|
|
171
|
+
|
|
172
|
+
# 2. Time window check
|
|
173
|
+
now = int(time.time())
|
|
174
|
+
if now < int(auth["validAfter"]):
|
|
175
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization not yet valid")}
|
|
176
|
+
if now > int(auth["validBefore"]):
|
|
177
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization expired")}
|
|
178
|
+
|
|
179
|
+
# 3. Signature verification
|
|
180
|
+
chain_id = self.chain_id or await asyncio.to_thread(lambda: self.w3.eth.chain_id)
|
|
181
|
+
domain = {
|
|
182
|
+
"name": extra.get("name", "USD Coin"),
|
|
183
|
+
"version": extra.get("version", "2"),
|
|
184
|
+
"chainId": chain_id,
|
|
185
|
+
"verifyingContract": Web3.to_checksum_address(token_addr)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
types = {
|
|
189
|
+
"EIP712Domain": [
|
|
190
|
+
{"name": "name", "type": "string"},
|
|
191
|
+
{"name": "version", "type": "string"},
|
|
192
|
+
{"name": "chainId", "type": "uint256"},
|
|
193
|
+
{"name": "verifyingContract", "type": "address"},
|
|
194
|
+
],
|
|
195
|
+
"TransferWithAuthorization": [
|
|
196
|
+
{"name": "from", "type": "address"},
|
|
197
|
+
{"name": "to", "type": "address"},
|
|
198
|
+
{"name": "value", "type": "uint256"},
|
|
199
|
+
{"name": "validAfter", "type": "uint256"},
|
|
200
|
+
{"name": "validBefore", "type": "uint256"},
|
|
201
|
+
{"name": "nonce", "type": "bytes32"},
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
auth_msg = {
|
|
206
|
+
"from": Web3.to_checksum_address(auth["from"]),
|
|
207
|
+
"to": Web3.to_checksum_address(auth["to"]),
|
|
208
|
+
"value": payload_value,
|
|
209
|
+
"validAfter": int(auth["validAfter"]),
|
|
210
|
+
"validBefore": int(auth["validBefore"]),
|
|
211
|
+
"nonce": Web3.to_bytes(hexstr=auth["nonce"])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
structured_data = {
|
|
215
|
+
"types": types,
|
|
216
|
+
"domain": domain,
|
|
217
|
+
"primaryType": "TransferWithAuthorization",
|
|
218
|
+
"message": auth_msg
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
recovered_address = Account.recover_typed_data(structured_data, signature=signature)
|
|
222
|
+
|
|
223
|
+
if recovered_address.lower() != auth["from"].lower():
|
|
224
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Invalid signature")}
|
|
225
|
+
|
|
226
|
+
# 4. Idempotency (Nonce local check)
|
|
227
|
+
nonce = auth["nonce"]
|
|
228
|
+
if self.store:
|
|
229
|
+
is_new = await self.store.check_and_set(nonce, 86400)
|
|
230
|
+
if not is_new:
|
|
231
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already used in local memory")}
|
|
232
|
+
|
|
233
|
+
# 5. RPC State Checks (Balance & Nonce)
|
|
234
|
+
token_abi = [
|
|
235
|
+
{
|
|
236
|
+
"constant": True,
|
|
237
|
+
"inputs": [{"name": "account", "type": "address"}],
|
|
238
|
+
"name": "balanceOf",
|
|
239
|
+
"outputs": [{"name": "", "type": "uint256"}],
|
|
240
|
+
"payable": False,
|
|
241
|
+
"stateMutability": "view",
|
|
242
|
+
"type": "function",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"constant": True,
|
|
246
|
+
"inputs": [
|
|
247
|
+
{"name": "authorizer", "type": "address"},
|
|
248
|
+
{"name": "nonce", "type": "bytes32"}
|
|
249
|
+
],
|
|
250
|
+
"name": "authorizationState",
|
|
251
|
+
"outputs": [{"name": "", "type": "bool"}],
|
|
252
|
+
"payable": False,
|
|
253
|
+
"stateMutability": "view",
|
|
254
|
+
"type": "function",
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
token_contract = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=token_abi)
|
|
259
|
+
authorizer_address = Web3.to_checksum_address(auth["from"])
|
|
260
|
+
nonce_bytes = Web3.to_bytes(hexstr=nonce)
|
|
261
|
+
|
|
262
|
+
# Concurrent RPC calls
|
|
263
|
+
try:
|
|
264
|
+
def call_rpc():
|
|
265
|
+
balance = token_contract.functions.balanceOf(authorizer_address).call()
|
|
266
|
+
is_used = token_contract.functions.authorizationState(authorizer_address, nonce_bytes).call()
|
|
267
|
+
return balance, is_used
|
|
268
|
+
|
|
269
|
+
balance, is_nonce_used_on_chain = await asyncio.to_thread(call_rpc)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
# If RPC fails (e.g. mock token doesn't support authorizationState), fallback or fail
|
|
272
|
+
logger.warning(f"RPC state check failed for token {token_addr}: {e}")
|
|
273
|
+
# We still keep the local idempotency check. For safety, we return true if balance is not checkable?
|
|
274
|
+
# No, JS implementation fallbacks to 0 balance and False nonce.
|
|
275
|
+
balance = payload_value # Optimistic if check fails? No, let's follow JS more closely but be safe.
|
|
276
|
+
is_nonce_used_on_chain = False
|
|
277
|
+
|
|
278
|
+
if balance < payload_value:
|
|
279
|
+
if self.store: await self.store.delete(nonce)
|
|
280
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.insufficient_funds, message="Insufficient token balance")}
|
|
281
|
+
|
|
282
|
+
if is_nonce_used_on_chain:
|
|
283
|
+
if self.store: await self.store.delete(nonce)
|
|
284
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already consumed on-chain")}
|
|
285
|
+
|
|
286
|
+
return {"isValid": True}
|
|
287
|
+
except Exception as e:
|
|
288
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
|
paynode_sdk/webhook.py
CHANGED
|
@@ -100,7 +100,7 @@ class PayNodeWebhookNotifier:
|
|
|
100
100
|
raise ValueError("webhook_secret is required")
|
|
101
101
|
|
|
102
102
|
self.contract_address = contract_address or PAYNODE_ROUTER_ADDRESS
|
|
103
|
-
self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout":
|
|
103
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 3}))
|
|
104
104
|
self.contract = self.w3.eth.contract(
|
|
105
105
|
address=Web3.to_checksum_address(self.contract_address),
|
|
106
106
|
abi=PAYNODE_ROUTER_ABI
|
|
@@ -120,7 +120,7 @@ class PayNodeWebhookNotifier:
|
|
|
120
120
|
async def start(self, from_block: Optional[int] = None) -> None:
|
|
121
121
|
"""Start polling for PaymentReceived events."""
|
|
122
122
|
if self._running:
|
|
123
|
-
logger.warning("[PayNode Webhook] Already running.")
|
|
123
|
+
logger.warning("🔔 [PayNode Webhook] Already running.")
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
self._last_block = from_block if from_block is not None else self.w3.eth.block_number
|
|
@@ -158,7 +158,7 @@ class PayNodeWebhookNotifier:
|
|
|
158
158
|
|
|
159
159
|
self._last_block = current_block
|
|
160
160
|
except Exception as e:
|
|
161
|
-
logger.error(f"[PayNode Webhook] Poll error: {e}")
|
|
161
|
+
logger.error(f"❌ [PayNode Webhook] Poll error: {e}")
|
|
162
162
|
|
|
163
163
|
await asyncio.sleep(self.poll_interval)
|
|
164
164
|
|
|
@@ -179,7 +179,7 @@ class PayNodeWebhookNotifier:
|
|
|
179
179
|
timestamp=time.time(),
|
|
180
180
|
)
|
|
181
181
|
except Exception as e:
|
|
182
|
-
logger.error(f"[PayNode Webhook] Failed to parse event: {e}")
|
|
182
|
+
logger.error(f"❌ [PayNode Webhook] Failed to parse event: {e}")
|
|
183
183
|
return None
|
|
184
184
|
|
|
185
185
|
async def _deliver(self, event: PaymentEvent, attempt: int = 1) -> None:
|
|
@@ -221,13 +221,13 @@ class PayNodeWebhookNotifier:
|
|
|
221
221
|
self.on_success(event)
|
|
222
222
|
|
|
223
223
|
except Exception as e:
|
|
224
|
-
logger.error(f"[PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
|
|
224
|
+
logger.error(f"⚠️ [PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
|
|
225
225
|
|
|
226
226
|
if attempt < MAX_RETRIES:
|
|
227
227
|
backoff = (2 ** attempt) # 2s, 4s, 8s
|
|
228
228
|
await asyncio.sleep(backoff)
|
|
229
229
|
return await self._deliver(event, attempt + 1)
|
|
230
230
|
|
|
231
|
-
logger.error(f"[PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
|
|
231
|
+
logger.error(f"❌ [PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
|
|
232
232
|
if self.on_error:
|
|
233
233
|
self.on_error(e, event)
|
|
@@ -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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
paynode_sdk/__init__.py,sha256=dDP3qUvuhpyeUcCRRIeaHMifaYPE_p6IwZjcmaHgAHU,1187
|
|
2
|
+
paynode_sdk/client.py,sha256=RSxF5p5k7JbFZW4dezr48Wb4NYKiPeI5bTwB5ApCYns,15530
|
|
3
|
+
paynode_sdk/constants.py,sha256=-hR1P9B80-aQ0kCbY9hJEb1tpiRia1F0VCki9LpG7zs,1429
|
|
4
|
+
paynode_sdk/errors.py,sha256=9Mnyctt-ekcbNAkgKi45i0UZr8QXDEBwLpQTNtuz9i0,1965
|
|
5
|
+
paynode_sdk/idempotency.py,sha256=SZujxMC_YgrH7qpd8aW2cKp6qFaZCzLY888IIAk-iVc,2061
|
|
6
|
+
paynode_sdk/middleware.py,sha256=0g1kT_mnwXq4c3O5Dkzxl_d6CQrhnfsfzyMqL7M4JOU,5814
|
|
7
|
+
paynode_sdk/verifier.py,sha256=S1sy4dwBFhl9EWORcwAsM3g92a4ljsgUJBzTpYsTG9c,13743
|
|
8
|
+
paynode_sdk/webhook.py,sha256=djpcsqWAlWts2Y-l2bS4_Rjbd_9FCir0rn3J48hLvnY,8333
|
|
9
|
+
paynode_sdk_python-2.0.0.dist-info/licenses/LICENSE,sha256=U8RjGlEBtXN6PA-qN_N3Uh60jyu3qe26ZBmgt-LAHc4,1069
|
|
10
|
+
paynode_sdk_python-2.0.0.dist-info/METADATA,sha256=Dn3wUeBZ75LE0Dg97c0bjNmQs8-Jz4RSkgdVNAZHvwU,3757
|
|
11
|
+
paynode_sdk_python-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
paynode_sdk_python-2.0.0.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
|
|
13
|
+
paynode_sdk_python-2.0.0.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
paynode_sdk/__init__.py,sha256=NTyR6AbDSimrLFIeZ4SfJmkesA9J9VQZ3H4vVrhoZKc,1139
|
|
2
|
-
paynode_sdk/client.py,sha256=gqsNJoi72iBuOnw8DMNp-V59qUwOt-vvNd3L_zm9J9Q,11566
|
|
3
|
-
paynode_sdk/constants.py,sha256=-hR1P9B80-aQ0kCbY9hJEb1tpiRia1F0VCki9LpG7zs,1429
|
|
4
|
-
paynode_sdk/errors.py,sha256=9Mnyctt-ekcbNAkgKi45i0UZr8QXDEBwLpQTNtuz9i0,1965
|
|
5
|
-
paynode_sdk/idempotency.py,sha256=od7HuSxFdejBP0oE4QCzbJdrDZWvziiu09d3BRErU2k,999
|
|
6
|
-
paynode_sdk/middleware.py,sha256=ji0P-y1jnu4C4U95ASsylNujqPms5HAY5D6RfJiolgc,4105
|
|
7
|
-
paynode_sdk/verifier.py,sha256=DtsmaqXgHYoW-wQddL5QRoC6nCWr6Lc1_AomcVSvjxY,6071
|
|
8
|
-
paynode_sdk/webhook.py,sha256=xmesxnjnk8KQaqpvby3-uRYrmZbti_dhPw22r4uhwus,8310
|
|
9
|
-
paynode_sdk_python-1.4.0.dist-info/licenses/LICENSE,sha256=U8RjGlEBtXN6PA-qN_N3Uh60jyu3qe26ZBmgt-LAHc4,1069
|
|
10
|
-
paynode_sdk_python-1.4.0.dist-info/METADATA,sha256=eMYqGKWKYePIoYz2SW_8pgb_8T7pIh6f3HF6BOVU6l0,3037
|
|
11
|
-
paynode_sdk_python-1.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
-
paynode_sdk_python-1.4.0.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
|
|
13
|
-
paynode_sdk_python-1.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|