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 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 (v1.1.1).
18
+ The main PayNode Client for AI Agents (v3.1).
17
19
  Automatically handles the x402 'Payment Required' handshake.
18
- Supports RPC redundancy and EIP-2612 Permit-First payments.
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
- for rpc in self.rpc_urls:
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
- w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={'timeout': 5}))
44
- if w3.is_connected():
45
- return w3
76
+ return func(*args, **kwargs)
46
77
  except Exception as e:
47
- logger.warning(f"⚠️ [PayNode-PY] RPC {rpc} failed: {str(e)}")
48
- continue
49
- raise PayNodeException(ErrorCode.rpc_error)
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
- for _ in range(max_retries):
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. Handling payment...")
66
- try:
67
- kwargs = self._handle_402(response.headers, **kwargs)
68
- except Exception as e:
69
- if isinstance(e, PayNodeException): raise
70
- raise PayNodeException(ErrorCode.internal_error, message=f"An unexpected error occurred: {str(e)}")
71
- continue
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 _handle_402(self, headers, **kwargs):
76
- router_addr = headers.get('x-paynode-contract')
77
- merchant_addr = headers.get('x-paynode-merchant')
78
- amount_raw = int(headers.get('x-paynode-amount', 0))
79
- token_addr = headers.get('x-paynode-token-address')
80
- order_id = headers.get('x-paynode-order-id')
81
- currency = headers.get('x-paynode-currency', 'USDC')
82
- chain_id_header = headers.get('x-paynode-chain-id')
83
-
84
- if not all([router_addr, merchant_addr, amount_raw, token_addr, order_id]):
85
- raise PayNodeException(ErrorCode.internal_error, message="Malformed 402 headers: missing metadata")
86
-
87
- # Network safety check (v1.4)
88
- if chain_id_header:
89
- current_chain_id = self.w3.eth.chain_id
90
- if int(chain_id_header) != current_chain_id:
91
- raise PayNodeException(ErrorCode.invalid_receipt, message=f"Network mismatch: Current {current_chain_id}, Request {chain_id_header}.")
92
-
93
- logger.info(f"💡 [PayNode-PY] Payment request: {amount_raw} {currency} to {merchant_addr}")
94
-
95
- # v1.3 Constraint: Min payment protection
96
- if amount_raw < 1000:
97
- raise PayNodeException(ErrorCode.amount_too_low)
98
-
99
- # v1.4 Constraint: Token whitelist pre-flight (Anti-FakeToken)
100
- resolved_chain_id = int(chain_id_header) if chain_id_header else 8453
101
- whitelist = ACCEPTED_TOKENS.get(resolved_chain_id, [])
102
- if whitelist and token_addr and token_addr.lower() not in [t.lower() for t in whitelist]:
103
- raise PayNodeException(ErrorCode.token_not_accepted)
104
-
105
- # Protocol v1.3: Permit-First Execution
106
- try:
107
- # Check allowance first to decide if we need Permit
108
- allowance = self._get_allowance(token_addr, router_addr)
109
- if allowance >= amount_raw:
110
- tx_hash = self._execute_pay(router_addr, token_addr, merchant_addr, amount_raw, order_id)
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
- logger.info("⚡ [PayNode-PY] Insufficient allowance. Attempting Permit-First payment...")
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
- logger.info(f"✅ [PayNode-PY] Payment successful: {tx_hash}")
116
- except Exception as e:
117
- if isinstance(e, PayNodeException): raise
118
- raise PayNodeException(ErrorCode.transaction_failed, details=e)
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({'x-paynode-receipt': tx_hash, 'x-paynode-order-id': order_id})
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
- "name": name,
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": "version", "type": "string"},
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": "spender", "type": "address"},
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
- structured_data = {
178
- "types": types,
179
- "domain": domain,
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 = 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
- }
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 pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id):
193
- """Combines sign_permit and on-chain submission."""
194
- sig = self.sign_permit(token_addr, router_addr, amount)
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 _execute_pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
222
- """Standard pay method (fallback)."""
223
- 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
- router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
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 = "0x109AEddD656Ed2761d1e210E179329105039c784"
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: ["0x109AEddD656Ed2761d1e210E179329105039c784"]
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 'x-paynode-receipt' header.",
31
+ ErrorCode.missing_receipt: "Please pay to PayNode contract and provide 'X-402-Payload' header.",
32
32
  }
33
33
 
34
34
  class PayNodeException(Exception):
@@ -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
- now = time.time()
19
- expiry = self.cache.get(tx_hash)
20
-
21
- if expiry and expiry > now:
22
- return False
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
- self.cache[tx_hash] = now + ttl_seconds
25
- self._cleanup()
26
- return True
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)