paynode-sdk-python 1.4.0__py3-none-any.whl → 2.1.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 +254 -129
- paynode_sdk/constants.py +3 -18
- paynode_sdk/errors.py +1 -1
- paynode_sdk/idempotency.py +54 -9
- paynode_sdk/middleware.py +99 -48
- paynode_sdk/verifier.py +250 -74
- paynode_sdk/webhook.py +11 -11
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/METADATA +13 -2
- paynode_sdk_python-2.1.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.1.0.dist-info}/WHEEL +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.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,25 +1,28 @@
|
|
|
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, PAYNODE_ROUTER_ABI
|
|
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]
|
|
22
24
|
self.w3 = self._init_w3()
|
|
25
|
+
self.current_rpc_index = 0
|
|
23
26
|
|
|
24
27
|
# Initialize account and discard private key string
|
|
25
28
|
self.account = self.w3.eth.account.from_key(private_key)
|
|
@@ -37,16 +40,45 @@ class PayNodeAgentClient:
|
|
|
37
40
|
self.session.mount("http://", adapter)
|
|
38
41
|
|
|
39
42
|
def _init_w3(self):
|
|
40
|
-
"""Finds a working RPC from the list."""
|
|
41
|
-
|
|
43
|
+
"""Finds a working RPC from the list concurrently (Faster initialization)."""
|
|
44
|
+
|
|
45
|
+
def _check_rpc(rpc_url):
|
|
46
|
+
try:
|
|
47
|
+
temp_w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={'timeout': 3}))
|
|
48
|
+
if temp_w3.is_connected():
|
|
49
|
+
return temp_w3
|
|
50
|
+
return None
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
with ThreadPoolExecutor(max_workers=min(len(self.rpc_urls), 5)) as executor:
|
|
55
|
+
future_to_url = {executor.submit(_check_rpc, url): url for url in self.rpc_urls}
|
|
56
|
+
# Return the first one that succeeds
|
|
57
|
+
for future in as_completed(future_to_url):
|
|
58
|
+
w3_instance = future.result()
|
|
59
|
+
if w3_instance:
|
|
60
|
+
logger.debug(f"⚡ [PayNode-PY] Connected to RPC: {future_to_url[future]}")
|
|
61
|
+
return w3_instance
|
|
62
|
+
|
|
63
|
+
raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
|
|
64
|
+
|
|
65
|
+
def _rotate_rpc(self):
|
|
66
|
+
"""Switches to the next available RPC node in the list."""
|
|
67
|
+
self.current_rpc_index = (self.current_rpc_index + 1) % len(self.rpc_urls)
|
|
68
|
+
new_url = self.rpc_urls[self.current_rpc_index]
|
|
69
|
+
logger.warning(f"⚠️ [PayNode-PY] RPC failure detected. Rotating to: {new_url}")
|
|
70
|
+
self.w3 = Web3(Web3.HTTPProvider(new_url, request_kwargs={'timeout': 10}))
|
|
71
|
+
|
|
72
|
+
def _call_with_failover(self, func, *args, **kwargs):
|
|
73
|
+
"""Wrapper to retry a web3 call with RPC failover."""
|
|
74
|
+
for attempt in range(len(self.rpc_urls)):
|
|
42
75
|
try:
|
|
43
|
-
|
|
44
|
-
if w3.is_connected():
|
|
45
|
-
return w3
|
|
76
|
+
return func(*args, **kwargs)
|
|
46
77
|
except Exception as e:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
if attempt < len(self.rpc_urls) - 1:
|
|
79
|
+
self._rotate_rpc()
|
|
80
|
+
else:
|
|
81
|
+
raise e
|
|
50
82
|
|
|
51
83
|
def request_gate(self, url: str, method: str = "GET", **kwargs):
|
|
52
84
|
"""The high-level autonomous method handling 402 loop."""
|
|
@@ -58,84 +90,215 @@ class PayNodeAgentClient:
|
|
|
58
90
|
def post(self, url, **kwargs):
|
|
59
91
|
return self.request_gate(url, "POST", **kwargs)
|
|
60
92
|
|
|
61
|
-
def _request_with_402_retry(self, method, url, max_retries=3, **kwargs):
|
|
62
|
-
|
|
93
|
+
def _request_with_402_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
|
94
|
+
response = None
|
|
95
|
+
for attempt in range(max_retries):
|
|
63
96
|
response = self.session.request(method, url, **kwargs)
|
|
64
97
|
if response.status_code == 402:
|
|
65
|
-
logger.info("💡 [PayNode-PY] 402 Detected.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
logger.info(f"💡 [PayNode-PY] 402 Detected (Attempt {attempt+1}/{max_retries}). Analyzing protocol version...")
|
|
99
|
+
|
|
100
|
+
# Check for x402 v2 (JSON body or X-402-Required header)
|
|
101
|
+
content_type = response.headers.get('Content-Type', '')
|
|
102
|
+
b64_required = response.headers.get('X-402-Required')
|
|
103
|
+
order_id = response.headers.get('X-402-Order-Id')
|
|
104
|
+
|
|
105
|
+
body = None
|
|
106
|
+
if 'application/json' in content_type:
|
|
107
|
+
try:
|
|
108
|
+
body = response.json()
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.debug(f"⚠️ [PayNode-PY] Failed to parse 402 JSON body: {e}")
|
|
111
|
+
|
|
112
|
+
if not body and b64_required:
|
|
113
|
+
try:
|
|
114
|
+
import base64
|
|
115
|
+
import json
|
|
116
|
+
body = json.loads(base64.b64decode(b64_required).decode())
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"❌ [PayNode-PY] Failed to decode X-402-Required header: {e}")
|
|
119
|
+
|
|
120
|
+
if body and body.get('x402Version') == 2:
|
|
121
|
+
logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
|
|
122
|
+
if order_id: body['orderId'] = order_id
|
|
123
|
+
kwargs = self._handle_x402_v2(url, body, **kwargs)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
raise PayNodeException(ErrorCode.internal_error, message="Unsupported or malformed 402 response")
|
|
127
|
+
|
|
72
128
|
return response
|
|
129
|
+
|
|
130
|
+
if response and response.status_code == 402:
|
|
131
|
+
raise PayNodeException(ErrorCode.internal_error, message="Still 402 after all payment attempts. The server may have rejected the payment or authorization.")
|
|
73
132
|
return response
|
|
74
133
|
|
|
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
|
-
|
|
134
|
+
def _handle_x402_v2(self, url: str, requirements: dict, **kwargs) -> dict:
|
|
135
|
+
"""
|
|
136
|
+
Internal handler for X402 V2/V3.1 protocol.
|
|
137
|
+
Analyzes requirements, executes payment, and returns updated kwargs for retrying the request.
|
|
138
|
+
"""
|
|
139
|
+
chain_id = self.w3.eth.chain_id
|
|
140
|
+
caip2_chain_id = f"eip155:{chain_id}"
|
|
141
|
+
|
|
142
|
+
# Select suitable requirement
|
|
143
|
+
requirement = next((req for req in requirements.get('accepts', [])
|
|
144
|
+
if req.get('network') == caip2_chain_id), None)
|
|
145
|
+
|
|
146
|
+
if not requirement:
|
|
147
|
+
raise PayNodeException(ErrorCode.internal_error, message=f"No compatible payment requirement found for network {caip2_chain_id}")
|
|
148
|
+
|
|
149
|
+
# 🛡️ Token Whitelist Check
|
|
150
|
+
chain_tokens = ACCEPTED_TOKENS.get(chain_id, [])
|
|
151
|
+
if chain_tokens and requirement.get('asset').lower() not in [t.lower() for t in chain_tokens]:
|
|
152
|
+
raise PayNodeException(ErrorCode.token_not_accepted, message=f"Token {requirement['asset']} is not in the whitelist for chain {chain_id}")
|
|
153
|
+
|
|
154
|
+
logger.info(f"💡 [PayNode-PY] Payment request (v2): {requirement['amount']} atomic units of {requirement['asset']} to {requirement['payTo']}")
|
|
155
|
+
|
|
156
|
+
# Dust limit check
|
|
157
|
+
if int(requirement['amount']) < MIN_PAYMENT_AMOUNT:
|
|
158
|
+
raise PayNodeException(ErrorCode.amount_too_low, message=f"Payment amount {requirement['amount']} is below the minimum dust limit of {MIN_PAYMENT_AMOUNT}")
|
|
159
|
+
|
|
160
|
+
order_id = requirement.get('orderId') or requirements.get('orderId') or urlparse(url).path
|
|
161
|
+
|
|
162
|
+
payload_data = {}
|
|
163
|
+
ptype = requirement.get('type', 'onchain')
|
|
164
|
+
|
|
165
|
+
if ptype == 'eip3009':
|
|
166
|
+
valid_after = int(time.time()) - 60
|
|
167
|
+
valid_before = int(time.time()) + requirement.get('maxTimeoutSeconds', 3600)
|
|
168
|
+
import os
|
|
169
|
+
nonce = "0x" + os.urandom(32).hex()
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
payload_data = self.sign_transfer_with_authorization(
|
|
173
|
+
requirement['asset'],
|
|
174
|
+
requirement['payTo'],
|
|
175
|
+
int(requirement['amount']),
|
|
176
|
+
valid_after,
|
|
177
|
+
valid_before,
|
|
178
|
+
nonce,
|
|
179
|
+
requirement.get('extra', {})
|
|
180
|
+
)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise PayNodeException(ErrorCode.transaction_failed, message="Failed to sign payment authorization", details=e)
|
|
183
|
+
else:
|
|
184
|
+
# type: 'onchain'
|
|
185
|
+
router_addr = requirement.get('router')
|
|
186
|
+
if not router_addr:
|
|
187
|
+
raise PayNodeException(ErrorCode.internal_error, message="On-chain payment required but no router address provided.")
|
|
188
|
+
|
|
189
|
+
logger.info(f"⚡ [PayNode-PY] Executing on-chain payment to {requirement['payTo']}...")
|
|
190
|
+
amount = int(requirement['amount'])
|
|
191
|
+
asset = requirement['asset']
|
|
192
|
+
allowance = self._get_allowance(asset, router_addr)
|
|
193
|
+
|
|
194
|
+
if allowance >= amount:
|
|
195
|
+
try:
|
|
196
|
+
tx_hash = self.pay(router_addr, asset, requirement['payTo'], amount, order_id)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.warning(f"⚠️ [PayNode-PY] Direct pay failed (possibly allowance race), falling back to permit: {e}")
|
|
199
|
+
tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id, version=requirement.get('extra', {}).get('version', '2'))
|
|
111
200
|
else:
|
|
112
|
-
|
|
113
|
-
tx_hash = self.pay_with_permit(router_addr, token_addr, merchant_addr, amount_raw, order_id)
|
|
201
|
+
tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id, version=requirement.get('extra', {}).get('version', '2'))
|
|
114
202
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
203
|
+
payload_data = {"txHash": tx_hash}
|
|
204
|
+
|
|
205
|
+
# Unified Payload for v3.1
|
|
206
|
+
payment_payload = {
|
|
207
|
+
"version": "3.1",
|
|
208
|
+
"type": ptype,
|
|
209
|
+
"orderId": order_id,
|
|
210
|
+
"payload": payload_data
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
logger.info(f"✅ [PayNode-PY] {ptype} payment prepared. Retrying request...")
|
|
214
|
+
|
|
215
|
+
import json
|
|
216
|
+
import base64
|
|
217
|
+
b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
|
|
119
218
|
|
|
120
219
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
121
|
-
retry_headers.update({
|
|
220
|
+
retry_headers.update({
|
|
221
|
+
'Content-Type': 'application/json',
|
|
222
|
+
'X-402-Payload': b64_payload,
|
|
223
|
+
'X-402-Order-Id': order_id
|
|
224
|
+
})
|
|
122
225
|
kwargs['headers'] = retry_headers
|
|
123
226
|
return kwargs
|
|
124
227
|
|
|
228
|
+
def sign_transfer_with_authorization(self, token_addr, to, amount, valid_after, valid_before, nonce, extra=None):
|
|
229
|
+
extra = extra or {}
|
|
230
|
+
token_addr = Web3.to_checksum_address(token_addr)
|
|
231
|
+
to = Web3.to_checksum_address(to)
|
|
232
|
+
|
|
233
|
+
domain = {
|
|
234
|
+
"name": extra.get("name", "USD Coin"),
|
|
235
|
+
"version": extra.get("version", "2"),
|
|
236
|
+
"chainId": self.w3.eth.chain_id,
|
|
237
|
+
"verifyingContract": token_addr,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
types = {
|
|
241
|
+
"EIP712Domain": [
|
|
242
|
+
{"name": "name", "type": "string"},
|
|
243
|
+
{"name": "version", "type": "string"},
|
|
244
|
+
{"name": "chainId", "type": "uint256"},
|
|
245
|
+
{"name": "verifyingContract", "type": "address"},
|
|
246
|
+
],
|
|
247
|
+
"TransferWithAuthorization": [
|
|
248
|
+
{"name": "from", "type": "address"},
|
|
249
|
+
{"name": "to", "type": "address"},
|
|
250
|
+
{"name": "value", "type": "uint256"},
|
|
251
|
+
{"name": "validAfter", "type": "uint256"},
|
|
252
|
+
{"name": "validBefore", "type": "uint256"},
|
|
253
|
+
{"name": "nonce", "type": "bytes32"},
|
|
254
|
+
],
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
message = {
|
|
258
|
+
"from": self.account.address,
|
|
259
|
+
"to": to,
|
|
260
|
+
"value": int(amount),
|
|
261
|
+
"validAfter": int(valid_after),
|
|
262
|
+
"validBefore": int(valid_before),
|
|
263
|
+
"nonce": Web3.to_bytes(hexstr=nonce),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
structured_data = {
|
|
267
|
+
"types": types,
|
|
268
|
+
"domain": domain,
|
|
269
|
+
"primaryType": "TransferWithAuthorization",
|
|
270
|
+
"message": message,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
signed = self.account.sign_typed_data(full_message=structured_data)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"signature": signed.signature.hex(),
|
|
277
|
+
"authorization": {
|
|
278
|
+
"from": self.account.address,
|
|
279
|
+
"to": to,
|
|
280
|
+
"value": str(amount),
|
|
281
|
+
"validAfter": str(valid_after),
|
|
282
|
+
"validBefore": str(valid_before),
|
|
283
|
+
"nonce": nonce
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
125
287
|
def _get_allowance(self, token_addr, spender_addr):
|
|
288
|
+
return self._call_with_failover(self.__get_allowance_raw, token_addr, spender_addr)
|
|
289
|
+
|
|
290
|
+
def __get_allowance_raw(self, token_addr, spender_addr):
|
|
126
291
|
abi = [{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"}]
|
|
127
292
|
token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=abi)
|
|
128
293
|
return token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
|
|
129
294
|
|
|
130
|
-
def sign_permit(self, token_addr, spender_addr, amount, deadline=None):
|
|
131
|
-
"""Signs EIP-2612 Permit data."""
|
|
295
|
+
def sign_permit(self, token_addr: str, spender_addr: str, amount: int, deadline: int = None, version: str = "2"):
|
|
132
296
|
if deadline is None:
|
|
133
297
|
deadline = int(time.time()) + 3600
|
|
134
298
|
|
|
135
299
|
token_addr = Web3.to_checksum_address(token_addr)
|
|
136
300
|
spender_addr = Web3.to_checksum_address(spender_addr)
|
|
137
301
|
|
|
138
|
-
# Get nonce and domain separator
|
|
139
302
|
abi = [
|
|
140
303
|
{"inputs": [{"name": "o", "type": "address"}], "name": "nonces", "outputs": [{"name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
141
304
|
{"inputs": [], "name": "name", "outputs": [{"name": "", "type": "string"}], "stateMutability": "view", "type": "function"}
|
|
@@ -145,93 +308,55 @@ class PayNodeAgentClient:
|
|
|
145
308
|
name = token.functions.name().call()
|
|
146
309
|
chain_id = self.w3.eth.chain_id
|
|
147
310
|
|
|
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
|
-
}
|
|
311
|
+
domain = {"name": name, "version": version, "chainId": chain_id, "verifyingContract": token_addr}
|
|
312
|
+
message = {"owner": self.account.address, "spender": spender_addr, "value": amount, "nonce": nonce, "deadline": deadline}
|
|
161
313
|
types = {
|
|
162
314
|
"EIP712Domain": [
|
|
163
|
-
{"name": "name", "type": "string"},
|
|
164
|
-
{"name": "
|
|
165
|
-
{"name": "chainId", "type": "uint256"},
|
|
166
|
-
{"name": "verifyingContract", "type": "address"},
|
|
315
|
+
{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
|
|
316
|
+
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"},
|
|
167
317
|
],
|
|
168
318
|
"Permit": [
|
|
169
|
-
{"name": "owner", "type": "address"},
|
|
170
|
-
{"name": "
|
|
171
|
-
{"name": "value", "type": "uint256"},
|
|
172
|
-
{"name": "nonce", "type": "uint256"},
|
|
319
|
+
{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
|
|
320
|
+
{"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"},
|
|
173
321
|
{"name": "deadline", "type": "uint256"},
|
|
174
322
|
],
|
|
175
323
|
}
|
|
324
|
+
structured_data = {"types": types, "domain": domain, "primaryType": "Permit", "message": message}
|
|
325
|
+
signed = self.account.sign_typed_data(full_message=structured_data)
|
|
176
326
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"primaryType": "Permit",
|
|
181
|
-
"message": message,
|
|
182
|
-
}
|
|
327
|
+
# NOTE: r/s padding to 32 bytes ensures bytes32 compatibility
|
|
328
|
+
r_bytes = Web3.to_bytes(signed.r).rjust(32, b'\0')
|
|
329
|
+
s_bytes = Web3.to_bytes(signed.s).rjust(32, b'\0')
|
|
183
330
|
|
|
184
|
-
signed
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"s": Web3.to_bytes(signed.s).rjust(32, b'\0'),
|
|
189
|
-
"deadline": deadline
|
|
190
|
-
}
|
|
331
|
+
return {"v": signed.v, "r": r_bytes, "s": s_bytes, "deadline": deadline}
|
|
332
|
+
|
|
333
|
+
def pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id, version="2"):
|
|
334
|
+
return self._call_with_failover(self.__pay_with_permit_raw, router_addr, token_addr, merchant_addr, amount, order_id, version)
|
|
191
335
|
|
|
192
|
-
def
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
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
|
-
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
336
|
+
def __pay_with_permit_raw(self, router_addr, token_addr, merchant_addr, amount, order_id, version="2"):
|
|
337
|
+
sig = self.sign_permit(token_addr, router_addr, amount, version=version)
|
|
338
|
+
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
|
|
197
339
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
198
|
-
|
|
199
340
|
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
200
341
|
with self.nonce_lock:
|
|
201
342
|
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
|
-
})
|
|
343
|
+
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
344
|
signed_tx = self.account.sign_transaction(tx)
|
|
216
345
|
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
217
|
-
|
|
218
346
|
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
219
347
|
return self.w3.to_hex(tx_h)
|
|
220
348
|
|
|
221
|
-
def
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
349
|
+
def pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
350
|
+
return self._call_with_failover(self.__pay_raw, router_addr, token_addr, merchant_addr, amount, order_id)
|
|
351
|
+
|
|
352
|
+
def __pay_raw(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
353
|
+
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
|
|
225
354
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
226
355
|
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
227
|
-
|
|
228
356
|
with self.nonce_lock:
|
|
229
357
|
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
|
-
})
|
|
358
|
+
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
359
|
signed_tx = self.account.sign_transaction(tx)
|
|
234
360
|
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
235
|
-
|
|
236
361
|
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
237
362
|
return self.w3.to_hex(tx_h)
|
paynode_sdk/constants.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
PAYNODE_ROUTER_ADDRESS = "0x4A73696ccF76E7381b044cB95127B3784369Ed63"
|
|
3
3
|
PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"
|
|
4
4
|
BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
5
|
-
BASE_USDC_ADDRESS_SANDBOX = "
|
|
5
|
+
BASE_USDC_ADDRESS_SANDBOX = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
|
|
6
6
|
BASE_USDC_DECIMALS = 6
|
|
7
7
|
|
|
8
8
|
PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
|
|
@@ -14,22 +14,7 @@ BASE_RPC_URLS_SANDBOX = ["https://sepolia.base.org", "https://base-sepolia-rpc.p
|
|
|
14
14
|
|
|
15
15
|
ACCEPTED_TOKENS = {
|
|
16
16
|
8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
|
|
17
|
-
84532: ["
|
|
17
|
+
84532: ["0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"]
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
PAYNODE_ROUTER_ABI = [
|
|
21
|
-
{
|
|
22
|
-
"anonymous": False,
|
|
23
|
-
"inputs": [
|
|
24
|
-
{"indexed": True, "name": "orderId", "type": "bytes32"},
|
|
25
|
-
{"indexed": True, "name": "merchant", "type": "address"},
|
|
26
|
-
{"indexed": True, "name": "payer", "type": "address"},
|
|
27
|
-
{"indexed": False, "name": "token", "type": "address"},
|
|
28
|
-
{"indexed": False, "name": "amount", "type": "uint256"},
|
|
29
|
-
{"indexed": False, "name": "fee", "type": "uint256"},
|
|
30
|
-
{"indexed": False, "name": "chainId", "type": "uint256"}
|
|
31
|
-
],
|
|
32
|
-
"name": "PaymentReceived",
|
|
33
|
-
"type": "event"
|
|
34
|
-
}
|
|
35
|
-
]
|
|
20
|
+
PAYNODE_ROUTER_ABI = [{'type': 'constructor', 'inputs': [{'name': '_protocolTreasury', 'type': 'address', 'internalType': 'address'}], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'MAX_BPS', 'inputs': [], 'outputs': [{'name': '', 'type': 'uint256', 'internalType': 'uint256'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'MIN_PAYMENT_AMOUNT', 'inputs': [], 'outputs': [{'name': '', 'type': 'uint256', 'internalType': 'uint256'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'PROTOCOL_FEE_BPS', 'inputs': [], 'outputs': [{'name': '', 'type': 'uint256', 'internalType': 'uint256'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'acceptOwnership', 'inputs': [], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'owner', 'inputs': [], 'outputs': [{'name': '', 'type': 'address', 'internalType': 'address'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'pause', 'inputs': [], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'paused', 'inputs': [], 'outputs': [{'name': '', 'type': 'bool', 'internalType': 'bool'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'pay', 'inputs': [{'name': 'token', 'type': 'address', 'internalType': 'address'}, {'name': 'merchant', 'type': 'address', 'internalType': 'address'}, {'name': 'amount', 'type': 'uint256', 'internalType': 'uint256'}, {'name': 'orderId', 'type': 'bytes32', 'internalType': 'bytes32'}], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'payWithPermit', 'inputs': [{'name': 'payer', 'type': 'address', 'internalType': 'address'}, {'name': 'token', 'type': 'address', 'internalType': 'address'}, {'name': 'merchant', 'type': 'address', 'internalType': 'address'}, {'name': 'amount', 'type': 'uint256', 'internalType': 'uint256'}, {'name': 'orderId', 'type': 'bytes32', 'internalType': 'bytes32'}, {'name': 'deadline', 'type': 'uint256', 'internalType': 'uint256'}, {'name': 'v', 'type': 'uint8', 'internalType': 'uint8'}, {'name': 'r', 'type': 'bytes32', 'internalType': 'bytes32'}, {'name': 's', 'type': 'bytes32', 'internalType': 'bytes32'}], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'pendingOwner', 'inputs': [], 'outputs': [{'name': '', 'type': 'address', 'internalType': 'address'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'protocolTreasury', 'inputs': [], 'outputs': [{'name': '', 'type': 'address', 'internalType': 'address'}], 'stateMutability': 'view'}, {'type': 'function', 'name': 'renounceOwnership', 'inputs': [], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'transferOwnership', 'inputs': [{'name': 'newOwner', 'type': 'address', 'internalType': 'address'}], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'unpause', 'inputs': [], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'function', 'name': 'updateTreasury', 'inputs': [{'name': '_newTreasury', 'type': 'address', 'internalType': 'address'}], 'outputs': [], 'stateMutability': 'nonpayable'}, {'type': 'event', 'name': 'OwnershipTransferStarted', 'inputs': [{'name': 'previousOwner', 'type': 'address', 'indexed': True, 'internalType': 'address'}, {'name': 'newOwner', 'type': 'address', 'indexed': True, 'internalType': 'address'}], 'anonymous': False}, {'type': 'event', 'name': 'OwnershipTransferred', 'inputs': [{'name': 'previousOwner', 'type': 'address', 'indexed': True, 'internalType': 'address'}, {'name': 'newOwner', 'type': 'address', 'indexed': True, 'internalType': 'address'}], 'anonymous': False}, {'type': 'event', 'name': 'Paused', 'inputs': [{'name': 'account', 'type': 'address', 'indexed': False, 'internalType': 'address'}], 'anonymous': False}, {'type': 'event', 'name': 'PaymentReceived', 'inputs': [{'name': 'orderId', 'type': 'bytes32', 'indexed': True, 'internalType': 'bytes32'}, {'name': 'merchant', 'type': 'address', 'indexed': True, 'internalType': 'address'}, {'name': 'payer', 'type': 'address', 'indexed': True, 'internalType': 'address'}, {'name': 'token', 'type': 'address', 'indexed': False, 'internalType': 'address'}, {'name': 'amount', 'type': 'uint256', 'indexed': False, 'internalType': 'uint256'}, {'name': 'fee', 'type': 'uint256', 'indexed': False, 'internalType': 'uint256'}, {'name': 'chainId', 'type': 'uint256', 'indexed': False, 'internalType': 'uint256'}], 'anonymous': False}, {'type': 'event', 'name': 'TreasuryUpdated', 'inputs': [{'name': 'oldTreasury', 'type': 'address', 'indexed': True, 'internalType': 'address'}, {'name': 'newTreasury', 'type': 'address', 'indexed': True, 'internalType': 'address'}], 'anonymous': False}, {'type': 'event', 'name': 'Unpaused', 'inputs': [{'name': 'account', 'type': 'address', 'indexed': False, 'internalType': 'address'}], 'anonymous': False}, {'type': 'error', 'name': 'AmountTooLow', 'inputs': []}, {'type': 'error', 'name': 'EnforcedPause', 'inputs': []}, {'type': 'error', 'name': 'ExpectedPause', 'inputs': []}, {'type': 'error', 'name': 'InvalidAddress', 'inputs': []}, {'type': 'error', 'name': 'OwnableInvalidOwner', 'inputs': [{'name': 'owner', 'type': 'address', 'internalType': 'address'}]}, {'type': 'error', 'name': 'OwnableUnauthorizedAccount', 'inputs': [{'name': 'account', 'type': 'address', 'internalType': 'address'}]}, {'type': 'error', 'name': 'SafeERC20FailedOperation', 'inputs': [{'name': 'token', 'type': 'address', 'internalType': 'address'}]}, {'type': 'error', 'name': 'UnauthorizedCaller', 'inputs': []}]
|
paynode_sdk/errors.py
CHANGED
|
@@ -28,7 +28,7 @@ ERROR_MESSAGES = {
|
|
|
28
28
|
ErrorCode.transaction_not_found: "Transaction not found on-chain.",
|
|
29
29
|
ErrorCode.wrong_contract: "Payment event was not emitted by the official PayNode contract.",
|
|
30
30
|
ErrorCode.order_mismatch: "OrderId in receipt does not match requested ID.",
|
|
31
|
-
ErrorCode.missing_receipt: "Please pay to PayNode contract and provide '
|
|
31
|
+
ErrorCode.missing_receipt: "Please pay to PayNode contract and provide 'X-402-Payload' header.",
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
class PayNodeException(Exception):
|
paynode_sdk/idempotency.py
CHANGED
|
@@ -10,24 +10,69 @@ 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
|
+
|
|
21
|
+
import threading
|
|
22
|
+
|
|
13
23
|
class MemoryIdempotencyStore(IdempotencyStore):
|
|
14
24
|
def __init__(self):
|
|
15
25
|
self.cache: Dict[str, float] = {}
|
|
26
|
+
self.last_cleanup = time.time()
|
|
27
|
+
self.lock = threading.Lock()
|
|
16
28
|
|
|
17
29
|
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
with self.lock:
|
|
31
|
+
now = time.time()
|
|
32
|
+
expiry = self.cache.get(tx_hash)
|
|
33
|
+
|
|
34
|
+
if expiry and expiry > now:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
self.cache[tx_hash] = now + ttl_seconds
|
|
23
38
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
39
|
+
# BUG-5 FIX: Only cleanup periodically to avoid O(n) overhead on every call.
|
|
40
|
+
if now - self.last_cleanup > 60:
|
|
41
|
+
self._cleanup()
|
|
42
|
+
self.last_cleanup = now
|
|
43
|
+
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
async def delete(self, tx_hash: str) -> None:
|
|
47
|
+
with self.lock:
|
|
48
|
+
self.cache.pop(tx_hash, None)
|
|
27
49
|
|
|
28
50
|
def _cleanup(self):
|
|
51
|
+
# Already inside lock when called from check_and_set
|
|
29
52
|
now = time.time()
|
|
30
|
-
# Simple cleanup logic: remove expired entries
|
|
31
53
|
expired_keys = [k for k, v in self.cache.items() if v <= now]
|
|
32
54
|
for k in expired_keys:
|
|
33
55
|
del self.cache[k]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RedisIdempotencyStore(IdempotencyStore):
|
|
59
|
+
"""
|
|
60
|
+
Production-ready implementation using Redis.
|
|
61
|
+
Uses `SET txHash 1 NX EX ttlSeconds` for atomic check-and-set.
|
|
62
|
+
|
|
63
|
+
Requires: pip install redis
|
|
64
|
+
Usage:
|
|
65
|
+
import redis
|
|
66
|
+
store = RedisIdempotencyStore(redis.Redis(host='localhost', port=6379))
|
|
67
|
+
"""
|
|
68
|
+
def __init__(self, redis_client, prefix: str = "paynode:tx:"):
|
|
69
|
+
self.redis = redis_client
|
|
70
|
+
self.prefix = prefix
|
|
71
|
+
|
|
72
|
+
async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
|
|
73
|
+
key = f"{self.prefix}{tx_hash}"
|
|
74
|
+
return bool(self.redis.set(key, 1, ex=ttl_seconds, nx=True))
|
|
75
|
+
|
|
76
|
+
async def delete(self, tx_hash: str) -> None:
|
|
77
|
+
key = f"{self.prefix}{tx_hash}"
|
|
78
|
+
self.redis.delete(key)
|