paynode-sdk-python 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {paynode_sdk_python-2.0.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.1.0}/PKG-INFO +2 -2
  2. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/README.md +1 -1
  3. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/client.py +66 -18
  4. paynode_sdk_python-2.1.0/paynode_sdk/constants.py +20 -0
  5. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/errors.py +1 -1
  6. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/idempotency.py +20 -9
  7. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/middleware.py +16 -5
  8. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/verifier.py +47 -25
  9. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/webhook.py +5 -5
  10. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0/paynode_sdk_python.egg-info}/PKG-INFO +2 -2
  11. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/SOURCES.txt +1 -0
  12. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/pyproject.toml +1 -1
  13. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/tests/test_client.py +2 -2
  14. paynode_sdk_python-2.1.0/tests/test_concurrency.py +75 -0
  15. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/tests/test_internals.py +3 -3
  16. paynode_sdk_python-2.1.0/tests/test_verifier_logic.py +282 -0
  17. paynode_sdk_python-2.0.0/paynode_sdk/constants.py +0 -35
  18. paynode_sdk_python-2.0.0/tests/test_verifier_logic.py +0 -71
  19. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/LICENSE +0 -0
  20. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/__init__.py +0 -0
  21. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
  22. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
  23. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
  24. {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paynode-sdk-python
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: PayNode Protocol Python SDK for AI Agents
5
5
  Author-email: PayNodeLabs <contact@paynode.dev>
6
6
  License: MIT
@@ -54,7 +54,7 @@ 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)
57
+ ### Key Features (v2.1)
58
58
  - **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
59
59
  - **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
60
60
  - **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
@@ -34,7 +34,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
34
34
  print(response.json())
35
35
  ```
36
36
 
37
- ### Key Features (v2.0)
37
+ ### Key Features (v2.1)
38
38
  - **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
39
39
  - **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
40
40
  - **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
@@ -8,7 +8,7 @@ from eth_account.messages import encode_typed_data
8
8
  from web3 import Web3
9
9
  from requests.adapters import HTTPAdapter
10
10
  from urllib3.util.retry import Retry
11
- from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
11
+ from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
12
12
  from .errors import PayNodeException, ErrorCode
13
13
 
14
14
  logger = logging.getLogger("paynode_sdk.client")
@@ -22,6 +22,7 @@ class PayNodeAgentClient:
22
22
  def __init__(self, private_key: str, rpc_urls: list | str = BASE_RPC_URLS):
23
23
  self.rpc_urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
24
24
  self.w3 = self._init_w3()
25
+ self.current_rpc_index = 0
25
26
 
26
27
  # Initialize account and discard private key string
27
28
  self.account = self.w3.eth.account.from_key(private_key)
@@ -61,6 +62,24 @@ class PayNodeAgentClient:
61
62
 
62
63
  raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
63
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)):
75
+ try:
76
+ return func(*args, **kwargs)
77
+ except Exception as e:
78
+ if attempt < len(self.rpc_urls) - 1:
79
+ self._rotate_rpc()
80
+ else:
81
+ raise e
82
+
64
83
  def request_gate(self, url: str, method: str = "GET", **kwargs):
65
84
  """The high-level autonomous method handling 402 loop."""
66
85
  return self._request_with_402_retry(method.upper(), url, **kwargs)
@@ -71,12 +90,12 @@ class PayNodeAgentClient:
71
90
  def post(self, url, **kwargs):
72
91
  return self.request_gate(url, "POST", **kwargs)
73
92
 
74
- def _request_with_402_retry(self, method, url, max_retries=3, **kwargs):
93
+ def _request_with_402_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
75
94
  response = None
76
- for _ in range(max_retries):
95
+ for attempt in range(max_retries):
77
96
  response = self.session.request(method, url, **kwargs)
78
97
  if response.status_code == 402:
79
- logger.info("💡 [PayNode-PY] 402 Detected. Analyzing protocol version...")
98
+ logger.info(f"💡 [PayNode-PY] 402 Detected (Attempt {attempt+1}/{max_retries}). Analyzing protocol version...")
80
99
 
81
100
  # Check for x402 v2 (JSON body or X-402-Required header)
82
101
  content_type = response.headers.get('Content-Type', '')
@@ -101,14 +120,22 @@ class PayNodeAgentClient:
101
120
  if body and body.get('x402Version') == 2:
102
121
  logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
103
122
  if order_id: body['orderId'] = order_id
104
- kwargs = self._handle_x402_v2(body, **kwargs)
123
+ kwargs = self._handle_x402_v2(url, body, **kwargs)
105
124
  continue
106
125
 
107
126
  raise PayNodeException(ErrorCode.internal_error, message="Unsupported or malformed 402 response")
127
+
108
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.")
109
132
  return response
110
133
 
111
- def _handle_x402_v2(self, requirements, **kwargs):
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
+ """
112
139
  chain_id = self.w3.eth.chain_id
113
140
  caip2_chain_id = f"eip155:{chain_id}"
114
141
 
@@ -119,13 +146,18 @@ class PayNodeAgentClient:
119
146
  if not requirement:
120
147
  raise PayNodeException(ErrorCode.internal_error, message=f"No compatible payment requirement found for network {caip2_chain_id}")
121
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
+
122
154
  logger.info(f"💡 [PayNode-PY] Payment request (v2): {requirement['amount']} atomic units of {requirement['asset']} to {requirement['payTo']}")
123
155
 
124
156
  # Dust limit check
125
157
  if int(requirement['amount']) < MIN_PAYMENT_AMOUNT:
126
158
  raise PayNodeException(ErrorCode.amount_too_low, message=f"Payment amount {requirement['amount']} is below the minimum dust limit of {MIN_PAYMENT_AMOUNT}")
127
159
 
128
- order_id = requirement.get('orderId') or requirements.get('orderId') or urlparse(kwargs.get('url', '')).path
160
+ order_id = requirement.get('orderId') or requirements.get('orderId') or urlparse(url).path
129
161
 
130
162
  payload_data = {}
131
163
  ptype = requirement.get('type', 'onchain')
@@ -160,9 +192,13 @@ class PayNodeAgentClient:
160
192
  allowance = self._get_allowance(asset, router_addr)
161
193
 
162
194
  if allowance >= amount:
163
- tx_hash = self.pay(router_addr, asset, requirement['payTo'], amount, order_id)
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'))
164
200
  else:
165
- tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id)
201
+ tx_hash = self.pay_with_permit(router_addr, asset, requirement['payTo'], amount, order_id, version=requirement.get('extra', {}).get('version', '2'))
166
202
 
167
203
  payload_data = {"txHash": tx_hash}
168
204
 
@@ -249,11 +285,14 @@ class PayNodeAgentClient:
249
285
  }
250
286
 
251
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):
252
291
  abi = [{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"}]
253
292
  token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=abi)
254
293
  return token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
255
294
 
256
- def sign_permit(self, token_addr, spender_addr, amount, deadline=None):
295
+ def sign_permit(self, token_addr: str, spender_addr: str, amount: int, deadline: int = None, version: str = "2"):
257
296
  if deadline is None:
258
297
  deadline = int(time.time()) + 3600
259
298
 
@@ -269,7 +308,7 @@ class PayNodeAgentClient:
269
308
  name = token.functions.name().call()
270
309
  chain_id = self.w3.eth.chain_id
271
310
 
272
- domain = {"name": name, "version": "1", "chainId": chain_id, "verifyingContract": token_addr}
311
+ domain = {"name": name, "version": version, "chainId": chain_id, "verifyingContract": token_addr}
273
312
  message = {"owner": self.account.address, "spender": spender_addr, "value": amount, "nonce": nonce, "deadline": deadline}
274
313
  types = {
275
314
  "EIP712Domain": [
@@ -284,12 +323,19 @@ class PayNodeAgentClient:
284
323
  }
285
324
  structured_data = {"types": types, "domain": domain, "primaryType": "Permit", "message": message}
286
325
  signed = self.account.sign_typed_data(full_message=structured_data)
287
- return {"v": signed.v, "r": Web3.to_bytes(signed.r).rjust(32, b'\0'), "s": Web3.to_bytes(signed.s).rjust(32, b'\0'), "deadline": deadline}
326
+
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')
330
+
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)
288
335
 
289
- def pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id):
290
- sig = self.sign_permit(token_addr, router_addr, amount)
291
- router_abi = [{"inputs": [{"name": "payer", "type": "address"}, {"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}, {"name": "v", "type": "uint8"}, {"name": "r", "type": "bytes32"}, {"name": "s", "type": "bytes32"}], "name": "payWithPermit", "outputs": [], "stateMutability": "nonpayable", "type": "function"}]
292
- router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
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)
293
339
  order_id_bytes = self.w3.keccak(text=order_id)
294
340
  current_gas_price = int(self.w3.eth.gas_price * 1.2)
295
341
  with self.nonce_lock:
@@ -301,8 +347,10 @@ class PayNodeAgentClient:
301
347
  return self.w3.to_hex(tx_h)
302
348
 
303
349
  def pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
304
- router_abi = [{"inputs": [{"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}], "name": "pay", "outputs": [], "stateMutability": "nonpayable", "type": "function"}]
305
- router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
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)
306
354
  order_id_bytes = self.w3.keccak(text=order_id)
307
355
  current_gas_price = int(self.w3.eth.gas_price * 1.2)
308
356
  with self.nonce_lock:
@@ -0,0 +1,20 @@
1
+ # Generated by scripts/sync-config.py
2
+ PAYNODE_ROUTER_ADDRESS = "0x4A73696ccF76E7381b044cB95127B3784369Ed63"
3
+ PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"
4
+ BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
5
+ BASE_USDC_ADDRESS_SANDBOX = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
6
+ BASE_USDC_DECIMALS = 6
7
+
8
+ PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
9
+ PROTOCOL_FEE_BPS = 100
10
+ MIN_PAYMENT_AMOUNT = 1000
11
+
12
+ BASE_RPC_URLS = ["https://mainnet.base.org", "https://base.meowrpc.com", "https://1rpc.io/base"]
13
+ BASE_RPC_URLS_SANDBOX = ["https://sepolia.base.org", "https://base-sepolia-rpc.publicnode.com"]
14
+
15
+ ACCEPTED_TOKENS = {
16
+ 8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
17
+ 84532: ["0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"]
18
+ }
19
+
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': []}]
@@ -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):
@@ -18,27 +18,38 @@ class IdempotencyStore(ABC):
18
18
  """
19
19
  pass
20
20
 
21
+ import threading
22
+
21
23
  class MemoryIdempotencyStore(IdempotencyStore):
22
24
  def __init__(self):
23
25
  self.cache: Dict[str, float] = {}
26
+ self.last_cleanup = time.time()
27
+ self.lock = threading.Lock()
24
28
 
25
29
  async def check_and_set(self, tx_hash: str, ttl_seconds: int) -> bool:
26
- now = time.time()
27
- expiry = self.cache.get(tx_hash)
30
+ with self.lock:
31
+ now = time.time()
32
+ expiry = self.cache.get(tx_hash)
28
33
 
29
- if expiry and expiry > now:
30
- return False
34
+ if expiry and expiry > now:
35
+ return False
31
36
 
32
- self.cache[tx_hash] = now + ttl_seconds
33
- self._cleanup()
34
- return True
37
+ self.cache[tx_hash] = now + ttl_seconds
38
+
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
35
45
 
36
46
  async def delete(self, tx_hash: str) -> None:
37
- self.cache.pop(tx_hash, None)
47
+ with self.lock:
48
+ self.cache.pop(tx_hash, None)
38
49
 
39
50
  def _cleanup(self):
51
+ # Already inside lock when called from check_and_set
40
52
  now = time.time()
41
- # Simple cleanup logic: remove expired entries
42
53
  expired_keys = [k for k, v in self.cache.items() if v <= now]
43
54
  for k in expired_keys:
44
55
  del self.cache[k]
@@ -47,13 +47,20 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
47
47
  self.chain_id = chain_id
48
48
  self.generate_order_id = generate_order_id or (lambda r: f"agent_py_{int(time.time() * 1000)}")
49
49
 
50
- self.amount_int = int(float(price) * (10 ** decimals))
50
+ # DEV-2 FIX: Avoid float precision risks by using integer arithmetic or decimal string parsing
51
+ if "." in price:
52
+ parts = price.split(".")
53
+ integer_part = parts[0]
54
+ fraction_part = parts[1][:decimals].ljust(decimals, "0")
55
+ self.amount_int = int(integer_part + fraction_part)
56
+ else:
57
+ self.amount_int = int(price) * (10 ** decimals)
51
58
  self.description = kwargs.get('description', "Protected Resource")
52
59
  self.max_timeout_seconds = kwargs.get('max_timeout_seconds', 3600)
53
60
 
54
61
  async def dispatch(self, request: Request, call_next):
55
- v2_payload_header = request.headers.get('x-402-payload')
56
- order_id = request.headers.get('x-402-order-id')
62
+ v2_payload_header = request.headers.get('X-402-Payload')
63
+ order_id = request.headers.get('X-402-Order-Id')
57
64
 
58
65
  if not order_id:
59
66
  order_id = self.generate_order_id(request)
@@ -76,10 +83,14 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
76
83
  "amount": str(self.amount_int),
77
84
  "orderId": order_id
78
85
  },
79
- unified_payload.get("payload", {}).get("extra", {}) if unified_payload.get("type") == "eip3009" else {}
86
+ # BUG-1 FIX: extra should come from our own config (v2Response schema), not the agent's payload
87
+ {
88
+ "name": self.currency,
89
+ "version": "2" # USDC v2
90
+ } if unified_payload.get("type") == "eip3009" else {}
80
91
  )
81
92
  if result.get("isValid"):
82
- request.state.paynode = {"unified_payload": unified_payload, "order_id": order_id}
93
+ request.state.paynode = {"unified_payload": unified_payload, "orderId": order_id}
83
94
  return await call_next(request)
84
95
  else:
85
96
  err = result.get("error")
@@ -3,7 +3,7 @@ import time
3
3
  import logging
4
4
  from concurrent.futures import ThreadPoolExecutor, as_completed
5
5
  from .errors import ErrorCode, PayNodeException
6
- from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
6
+ from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
7
7
  from .idempotency import MemoryIdempotencyStore
8
8
  from web3 import Web3
9
9
  from eth_account import Account
@@ -55,6 +55,15 @@ class PayNodeVerifier:
55
55
  Routes to verify_onchain_payment or verify_transfer_with_authorization (eip3009).
56
56
  """
57
57
  try:
58
+ # 1. Double-check Protocol Dust Limit (>= 1000)
59
+ expected_amount = int(expected.get("amount", 0))
60
+ if expected_amount < MIN_PAYMENT_AMOUNT:
61
+ return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
62
+
63
+ # 2. Security: Token Whitelist Check
64
+ if self.accepted_tokens and expected.get("tokenAddress", "").lower() not in self.accepted_tokens:
65
+ return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message=f"Token {expected.get('tokenAddress')} not allowed")}
66
+
58
67
  payload_type = unified_payload.get("type")
59
68
  actual_payload = unified_payload.get("payload", {})
60
69
  order_id = unified_payload.get("orderId")
@@ -105,16 +114,21 @@ class PayNodeVerifier:
105
114
  if receipt.get("status") == 0:
106
115
  return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_failed)}
107
116
 
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)
117
+ contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=PAYNODE_ROUTER_ABI)
110
118
 
119
+ # 1. Check if the router was even involved (against 'WrongContract' vs 'InvalidReceipt')
120
+ # Filter logs for current contract
121
+ relevant_logs = [log for log in receipt.get("logs", []) if log.get("address", "").lower() == self.contract_address.lower()]
122
+ if not relevant_logs:
123
+ return {"isValid": False, "error": PayNodeException(ErrorCode.wrong_contract, message="Transaction did not interact with the expected PayNodeRouter contract")}
124
+
111
125
  try:
112
- logs = await asyncio.to_thread(contract.events.PaymentReceived().process_receipt, receipt)
126
+ processed_logs = await asyncio.to_thread(contract.events.PaymentReceived().process_receipt, {"logs": relevant_logs})
113
127
  except Exception:
114
128
  return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
115
129
 
116
- if not logs:
117
- return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No PaymentReceived event found")}
130
+ if not processed_logs:
131
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No PaymentReceived event found in router logs")}
118
132
 
119
133
  merchant = expected.get("merchantAddress", "").lower()
120
134
  token = expected.get("tokenAddress", "").lower()
@@ -122,16 +136,24 @@ class PayNodeVerifier:
122
136
  order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
123
137
 
124
138
  valid_log_found = False
125
- for log in logs:
139
+ order_id_mismatch_found = False
140
+ for log in processed_logs:
126
141
  args = log.args
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
142
+ is_merchant_match = args.get("merchant", "").lower() == merchant
143
+ is_token_match = args.get("token", "").lower() == token
144
+ is_amount_match = args.get("amount", 0) >= amount
145
+ is_order_match = args.get("orderId") == order_id_bytes
146
+
147
+ if is_merchant_match and is_token_match and is_amount_match:
148
+ if is_order_match:
149
+ valid_log_found = True
150
+ break
151
+ else:
152
+ order_id_mismatch_found = True
133
153
 
134
154
  if not valid_log_found:
155
+ if order_id_mismatch_found:
156
+ return {"isValid": False, "error": PayNodeException(ErrorCode.order_mismatch, message="Payment log found but orderId does not match")}
135
157
  return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Payment event data mismatch")}
136
158
 
137
159
  if self.store:
@@ -218,7 +240,9 @@ class PayNodeVerifier:
218
240
  "message": auth_msg
219
241
  }
220
242
 
221
- recovered_address = Account.recover_typed_data(structured_data, signature=signature)
243
+ from eth_account.messages import encode_typed_data
244
+ signable_msg = encode_typed_data(full_message=structured_data)
245
+ recovered_address = Account.recover_message(signable_msg, signature=signature)
222
246
 
223
247
  if recovered_address.lower() != auth["from"].lower():
224
248
  return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Invalid signature")}
@@ -261,19 +285,17 @@ class PayNodeVerifier:
261
285
 
262
286
  # Concurrent RPC calls
263
287
  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)
288
+ balance, is_nonce_used_on_chain = await asyncio.gather(
289
+ asyncio.to_thread(token_contract.functions.balanceOf(authorizer_address).call),
290
+ asyncio.to_thread(token_contract.functions.authorizationState(authorizer_address, nonce_bytes).call)
291
+ )
270
292
  except Exception as e:
271
- # If RPC fails (e.g. mock token doesn't support authorizationState), fallback or fail
272
293
  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
294
+ if self.store: await self.store.delete(nonce)
295
+ return {
296
+ "isValid": False,
297
+ "error": PayNodeException(ErrorCode.rpc_error, message=f"Cannot verify on-chain state: {e}")
298
+ }
277
299
 
278
300
  if balance < payload_value:
279
301
  if self.store: await self.store.delete(nonce)
@@ -3,7 +3,7 @@ PayNode Webhook Notifier — monitors on-chain PaymentReceived events
3
3
  and delivers structured webhook POSTs to a merchant's endpoint.
4
4
 
5
5
  Features:
6
- - HMAC-SHA256 signature for authenticity (header: x-paynode-signature)
6
+ - HMAC-SHA256 signature for authenticity (header: X-402-Signature)
7
7
  - Configurable polling interval
8
8
  - Automatic retry with exponential backoff (3 attempts)
9
9
  - Async-first design
@@ -73,7 +73,7 @@ class PayNodeWebhookNotifier:
73
73
  Usage:
74
74
  notifier = PayNodeWebhookNotifier(
75
75
  rpc_url="https://mainnet.base.org",
76
- contract_address="0x92e20164FC457a2aC35f53D06268168e6352b200",
76
+ contract_address="0x4A73696ccF76E7381b044cB95127B3784369Ed63",
77
77
  webhook_url="https://myshop.com/api/paynode-webhook",
78
78
  webhook_secret="whsec_mysecretkey123",
79
79
  )
@@ -201,9 +201,9 @@ class PayNodeWebhookNotifier:
201
201
 
202
202
  headers = {
203
203
  "Content-Type": "application/json",
204
- "x-paynode-signature": f"sha256={signature}",
205
- "x-paynode-event": "payment.received",
206
- "x-paynode-delivery-id": f"{event.tx_hash}-{attempt}",
204
+ "X-402-Signature": f"sha256={signature}",
205
+ "X-402-Event": "payment.received",
206
+ "X-402-Delivery-Id": f"{event.tx_hash}-{attempt}",
207
207
  **self.custom_headers,
208
208
  }
209
209
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paynode-sdk-python
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: PayNode Protocol Python SDK for AI Agents
5
5
  Author-email: PayNodeLabs <contact@paynode.dev>
6
6
  License: MIT
@@ -54,7 +54,7 @@ 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)
57
+ ### Key Features (v2.1)
58
58
  - **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
59
59
  - **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
60
60
  - **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
@@ -15,5 +15,6 @@ paynode_sdk_python.egg-info/dependency_links.txt
15
15
  paynode_sdk_python.egg-info/requires.txt
16
16
  paynode_sdk_python.egg-info/top_level.txt
17
17
  tests/test_client.py
18
+ tests/test_concurrency.py
18
19
  tests/test_internals.py
19
20
  tests/test_verifier_logic.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "paynode-sdk-python"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "PayNode Protocol Python SDK for AI Agents"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "PayNodeLabs", email = "contact@paynode.dev" }]
@@ -9,7 +9,7 @@ from paynode_sdk import PayNodeAgentClient, PayNodeException, ErrorCode
9
9
  MOCK_PRIVATE_KEY = "0x" + "1" * 64
10
10
  MOCK_RPC = "https://sepolia.base.org"
11
11
  MOCK_MERCHANT = "0xMerchantWalletAddress789"
12
- MOCK_TOKEN = "0x109AEddD656Ed2761d1e210E179329105039c784"
12
+ MOCK_TOKEN = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
13
13
  MOCK_ROUTER = "0xPayNodeRouterAddress123"
14
14
  MOCK_ORDER_ID = "order_12345"
15
15
  MOCK_TX_HASH = "0x6f3e1a0000000000000000000000000000000000000000000000000000000000"
@@ -93,7 +93,7 @@ def test_dust_limit_protection(client):
93
93
  }
94
94
 
95
95
  with pytest.raises(PayNodeException) as exc:
96
- client._handle_x402_v2(v2_req)
96
+ client._handle_x402_v2("http://example.com", v2_req)
97
97
  assert exc.value.code == ErrorCode.amount_too_low
98
98
 
99
99
  def test_rpc_failover_logic():
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+ import pytest
3
+ from unittest.mock import MagicMock, patch
4
+ from paynode_sdk import PayNodeVerifier, ErrorCode
5
+
6
+ @pytest.fixture
7
+ def verifier():
8
+ with patch('paynode_sdk.verifier.Web3') as mock_w3:
9
+ mock_instance = mock_w3.return_value
10
+ mock_instance.is_connected.return_value = True
11
+ mock_instance.eth = MagicMock()
12
+ return PayNodeVerifier(
13
+ rpc_urls="http://localhost",
14
+ contract_address="0x" + "a" * 40,
15
+ chain_id=84532
16
+ )
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_concurrent_double_spend_eip3009(verifier):
20
+ """
21
+ Simultaneously triggers multiple verification requests with the same nonce.
22
+ Ensures only ONE succeeds and the others fail with duplicate_transaction.
23
+ """
24
+ mock_from = "0x" + "b" * 40
25
+ mock_to = "0x" + "c" * 40
26
+ mock_nonce = "0x" + "d" * 64
27
+ mock_amount = 2000
28
+ mock_token = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
29
+
30
+ payload = {
31
+ "signature": "0x" + "1" * 130,
32
+ "authorization": {
33
+ "from": mock_from,
34
+ "to": mock_to,
35
+ "value": str(mock_amount),
36
+ "validAfter": "0",
37
+ "validBefore": "9999999999",
38
+ "nonce": mock_nonce
39
+ }
40
+ }
41
+
42
+ # 1. Mock Signature Recovery
43
+ with patch('paynode_sdk.verifier.Account.recover_message') as mock_recover:
44
+ mock_recover.return_value = mock_from
45
+
46
+ # 2. Mock RPC State (Valid balance, Not used on-chain)
47
+ # In verifier.py, these are called via token_contract.functions.X.call()
48
+ mock_contract = MagicMock()
49
+ mock_contract.functions.balanceOf().call.return_value = 5000
50
+ mock_contract.functions.authorizationState().call.return_value = False
51
+ verifier.w3.eth.contract.return_value = mock_contract
52
+
53
+ # 3. Simulate High Concurrency (10 simultaneous requests)
54
+ tasks = []
55
+ for _ in range(10):
56
+ tasks.append(verifier.verify_transfer_with_authorization(
57
+ mock_token,
58
+ payload,
59
+ {"to": mock_to, "value": mock_amount}
60
+ ))
61
+
62
+ results = await asyncio.gather(*tasks)
63
+
64
+ # 4. Analyze Results
65
+ success_count = sum(1 for r in results if r["isValid"] is True)
66
+ duplicate_count = sum(1 for r in results if (
67
+ r["isValid"] is False and
68
+ r["error"].code == ErrorCode.duplicate_transaction
69
+ ))
70
+
71
+ # EXACTLY ONE should be valid
72
+ assert success_count == 1, f"Expected 1 success, got {success_count}"
73
+ # THE OTHER 9 should be duplicates
74
+ assert duplicate_count == 9, f"Expected 9 duplicates, got {duplicate_count}"
75
+ assert len(results) == 10
@@ -79,7 +79,7 @@ def test_handle_402_decision_logic(client):
79
79
  'accepts': [{
80
80
  'type': 'onchain',
81
81
  'network': 'eip155:84532',
82
- 'asset': "0x109AEddD656Ed2761d1e210E179329105039c784",
82
+ 'asset': "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
83
83
  'amount': '2000',
84
84
  'payTo': "0xMerchant",
85
85
  'router': "0xRouter"
@@ -89,11 +89,11 @@ def test_handle_402_decision_logic(client):
89
89
  # Case 1: Sufficient allowance -> calls pay
90
90
  with patch.object(client, '_get_allowance', return_value=5000), \
91
91
  patch.object(client, 'pay', return_value="0xHashPay") as mock_pay:
92
- client._handle_x402_v2(requirements)
92
+ client._handle_x402_v2("http://example.com", requirements)
93
93
  mock_pay.assert_called_once()
94
94
 
95
95
  # Case 2: Insufficient allowance -> calls pay_with_permit
96
96
  with patch.object(client, '_get_allowance', return_value=0), \
97
97
  patch.object(client, 'pay_with_permit', return_value="0xHashPermit") as mock_permit:
98
- client._handle_x402_v2(requirements)
98
+ client._handle_x402_v2("http://example.com", requirements)
99
99
  mock_permit.assert_called_once()
@@ -0,0 +1,282 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch
3
+ from web3 import Web3
4
+ from paynode_sdk import PayNodeVerifier, ErrorCode, PayNodeException
5
+
6
+ @pytest.fixture
7
+ def verifier():
8
+ with patch('paynode_sdk.verifier.Web3') as mock_w3:
9
+ mock_instance = mock_w3.return_value
10
+ mock_instance.is_connected.return_value = True
11
+ # Mock eth provider structure
12
+ mock_instance.eth = MagicMock()
13
+ mock_instance.keccak.side_effect = Web3.keccak
14
+ return PayNodeVerifier(
15
+ rpc_urls="http://localhost",
16
+ contract_address="0x" + "a" * 40,
17
+ chain_id=84532
18
+ )
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_verify_payment_invalid_receipt(verifier):
22
+ """Checks handling of missing transaction receipt."""
23
+ verifier.w3.eth.get_transaction_receipt.return_value = None
24
+ result = await verifier.verify_onchain_payment("0xHash", {
25
+ "merchantAddress": "0xMerchant",
26
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
27
+ "amount": 2000
28
+ })
29
+ assert result["isValid"] is False
30
+ assert result["error"].code == ErrorCode.transaction_not_found
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_verify_payment_wrong_contract(verifier):
34
+ """Checks rejection of logs from unauthorized contract addresses or no logs."""
35
+ mock_receipt = {"id": "0x123", "status": 1, "logs": []}
36
+
37
+ # Mock behavior of process_receipt when no logs are provided
38
+ mock_contract = MagicMock()
39
+ mock_contract.events.PaymentReceived().process_receipt.return_value = [] # No valid logs
40
+ verifier.w3.eth.contract.return_value = mock_contract
41
+ verifier.w3.eth.get_transaction_receipt.return_value = mock_receipt
42
+
43
+ payload = {"type": "onchain", "payload": {"txHash": "0xHash"}}
44
+ result = await verifier.verify(payload, {
45
+ "merchantAddress": "0xMerchant",
46
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
47
+ "amount": 2000
48
+ })
49
+ assert result["isValid"] is False
50
+ assert result["error"].code == ErrorCode.wrong_contract
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_dust_limit_rejection(verifier):
54
+ """Checks rejection of dust payments (<1000 atomic units)."""
55
+ # Test through unify verify() endpoint
56
+ payload = {
57
+ "type": "onchain",
58
+ "orderId": "test-dust",
59
+ "payload": {"txHash": "0xHash"}
60
+ }
61
+ result = await verifier.verify(payload, {
62
+ "merchantAddress": "0xMerchant",
63
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
64
+ "amount": 500 # Below 1000
65
+ })
66
+ assert result["isValid"] is False
67
+ assert result["error"].code == ErrorCode.amount_too_low
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_verify_payment_non_whitelisted_token(verifier):
71
+ """Checks rejection of non-whitelisted tokens."""
72
+ # verifier.accepted_tokens is initialized based on chain_id 84532 or default
73
+ payload = {"type": "onchain", "payload": {"txHash": "0xHash"}}
74
+ result = await verifier.verify(payload, {
75
+ "merchantAddress": "0xMerchant",
76
+ "tokenAddress": "0x" + "f" * 40, # Random non-whitelisted token
77
+ "amount": 2000
78
+ })
79
+ assert result["isValid"] is False
80
+ assert result["error"].code == ErrorCode.token_not_accepted
81
+ @pytest.mark.asyncio
82
+ async def test_verify_payment_order_mismatch(verifier):
83
+ """Checks ErrorCode.order_mismatch when PaymentReceived log found but orderId doesn't match."""
84
+ # 🧪 Prepare logs that match merchant/token/amount but have WRONG orderId
85
+ mock_log = MagicMock()
86
+ mock_log.args = {
87
+ "merchant": "0xMerchant",
88
+ "token": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
89
+ "amount": 2000,
90
+ "orderId": b"wrong_order_id_bytes_32" # Does not match expected (which will be keccak('test-order'))
91
+ }
92
+
93
+ mock_contract = MagicMock()
94
+ mock_contract.events.PaymentReceived().process_receipt.return_value = [mock_log]
95
+
96
+ # Setup w3 mocks
97
+ verifier.w3.eth.contract.return_value = mock_contract
98
+ verifier.w3.eth.get_transaction_receipt.return_value = {
99
+ "status": 1,
100
+ "logs": [{"address": verifier.contract_address.lower()}] # At least one log to trigger WrongContract check passing
101
+ }
102
+
103
+ payload = {
104
+ "type": "onchain",
105
+ "orderId": "test-order",
106
+ "payload": {"txHash": "0xHash"}
107
+ }
108
+ result = await verifier.verify(payload, {
109
+ "merchantAddress": "0xMerchant",
110
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
111
+ "amount": 2000
112
+ })
113
+
114
+ assert result["isValid"] is False
115
+ assert result["error"].code == ErrorCode.order_mismatch
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_verify_valid_payment(verifier):
119
+ """Checks success path for on-chain payment."""
120
+ mock_log = MagicMock()
121
+ mock_log.args = {
122
+ "merchant": "0xMerchant",
123
+ "token": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
124
+ "amount": 2000,
125
+ "orderId": Web3.keccak(text="test-order")
126
+ }
127
+
128
+ mock_contract = MagicMock()
129
+ mock_contract.events.PaymentReceived().process_receipt.return_value = [mock_log]
130
+ verifier.w3.eth.contract.return_value = mock_contract
131
+ verifier.w3.eth.get_transaction_receipt.return_value = {
132
+ "status": 1,
133
+ "logs": [{"address": verifier.contract_address.lower()}]
134
+ }
135
+
136
+ payload = {
137
+ "type": "onchain",
138
+ "orderId": "test-order",
139
+ "payload": {"txHash": "0xValidHash"}
140
+ }
141
+ result = await verifier.verify(payload, {
142
+ "merchantAddress": "0xMerchant",
143
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
144
+ "amount": 2000
145
+ })
146
+ assert result["isValid"] is True
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_duplicate_transaction(verifier):
150
+ """Checks idempotency by ensuring the same hash cannot be verified twice."""
151
+ mock_log = MagicMock()
152
+ mock_log.args = {
153
+ "merchant": "0xMerchant",
154
+ "token": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
155
+ "amount": 2000,
156
+ "orderId": Web3.keccak(text="test-order")
157
+ }
158
+ mock_contract = MagicMock()
159
+ mock_contract.events.PaymentReceived().process_receipt.return_value = [mock_log]
160
+ verifier.w3.eth.contract.return_value = mock_contract
161
+ verifier.w3.eth.get_transaction_receipt.return_value = {
162
+ "status": 1,
163
+ "logs": [{"address": verifier.contract_address.lower()}]
164
+ }
165
+
166
+ payload = {"type": "onchain", "orderId": "test-order", "payload": {"txHash": "0xDup"}}
167
+ # Manual set to simulate previous success
168
+ await verifier.store.check_and_set("0xDup", 86400)
169
+
170
+ result = await verifier.verify(payload, {
171
+ "merchantAddress": "0xMerchant",
172
+ "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0",
173
+ "amount": 2000
174
+ })
175
+ assert result["isValid"] is False
176
+ assert result["error"].code == ErrorCode.duplicate_transaction
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_verify_eip3009_valid(verifier):
180
+ """Checks success path for EIP-3009 (Transfer with Authorization)."""
181
+ from eth_account import Account
182
+
183
+ # Setup test account
184
+ priv_key = "0x" + "1" * 64
185
+ account = Account.from_key(priv_key)
186
+ from_addr = account.address
187
+ to_addr = "0x" + "3" * 40
188
+ token_addr = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
189
+ nonce = "0x" + "2" * 64
190
+
191
+ auth = {
192
+ "from": from_addr,
193
+ "to": to_addr,
194
+ "value": 2000,
195
+ "validAfter": 0,
196
+ "validBefore": 2000000000,
197
+ "nonce": nonce
198
+ }
199
+
200
+ # Mock chain_id
201
+ verifier.chain_id = 84532
202
+
203
+ # Mock RPC state calls
204
+ mock_token = MagicMock()
205
+ mock_token.functions.balanceOf().call.return_value = 5000
206
+ mock_token.functions.authorizationState().call.return_value = False
207
+ verifier.w3.eth.contract.return_value = mock_token
208
+
209
+ # Sign payload
210
+ domain = {
211
+ "name": "USD Coin",
212
+ "version": "2",
213
+ "chainId": 84532,
214
+ "verifyingContract": Web3.to_checksum_address(token_addr)
215
+ }
216
+ types = {
217
+ "EIP712Domain": [
218
+ {"name": "name", "type": "string"},
219
+ {"name": "version", "type": "string"},
220
+ {"name": "chainId", "type": "uint256"},
221
+ {"name": "verifyingContract", "type": "address"},
222
+ ],
223
+ "TransferWithAuthorization": [
224
+ {"name": "from", "type": "address"},
225
+ {"name": "to", "type": "address"},
226
+ {"name": "value", "type": "uint256"},
227
+ {"name": "validAfter", "type": "uint256"},
228
+ {"name": "validBefore", "type": "uint256"},
229
+ {"name": "nonce", "type": "bytes32"},
230
+ ]
231
+ }
232
+ structured_data = {
233
+ "types": types,
234
+ "domain": domain,
235
+ "primaryType": "TransferWithAuthorization",
236
+ "message": auth
237
+ }
238
+ # Fix: Use full_message to match the SDK expectation
239
+ from eth_account.messages import encode_typed_data
240
+ signable_msg = encode_typed_data(full_message=structured_data)
241
+ signature = account.sign_message(signable_msg).signature.hex()
242
+
243
+ payload = {
244
+ "type": "eip3009",
245
+ "payload": {
246
+ "signature": signature,
247
+ "authorization": auth
248
+ }
249
+ }
250
+
251
+ result = await verifier.verify(payload, {
252
+ "merchantAddress": to_addr,
253
+ "tokenAddress": token_addr,
254
+ "amount": 2000
255
+ })
256
+
257
+ assert result["isValid"] is True
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_verify_eip3009_insufficient_balance(verifier):
261
+ """Checks EIP-3009 failure due to low authorizer balance."""
262
+ # Use valid hex for nonce
263
+ nonce = "0x" + "f" * 64
264
+ from_addr = "0x" + "d" * 40
265
+ to_addr = "0x" + "e" * 40
266
+ auth = {
267
+ "from": from_addr, "to": to_addr, "value": 2000, "validAfter": 0, "validBefore": 2000000000, "nonce": nonce
268
+ }
269
+ # Mock low balance
270
+ mock_token = MagicMock()
271
+ mock_token.functions.balanceOf().call.return_value = 500 # Less than 2000
272
+ mock_token.functions.authorizationState().call.return_value = False
273
+ verifier.w3.eth.contract.return_value = mock_token
274
+
275
+ # Skip signature check by wrapping the inner logic or mocking recover
276
+ with patch('eth_account.Account.recover_message', return_value=from_addr):
277
+ payload = {"type": "eip3009", "payload": {"signature": "0x" + "s" * 130, "authorization": auth}}
278
+ result = await verifier.verify(payload, {
279
+ "merchantAddress": to_addr, "tokenAddress": "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0", "amount": 2000
280
+ })
281
+ assert result["isValid"] is False
282
+ assert result["error"].code == ErrorCode.insufficient_funds
@@ -1,35 +0,0 @@
1
- # Generated by scripts/sync-config.py
2
- PAYNODE_ROUTER_ADDRESS = "0x4A73696ccF76E7381b044cB95127B3784369Ed63"
3
- PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"
4
- BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
5
- BASE_USDC_ADDRESS_SANDBOX = "0x109AEddD656Ed2761d1e210E179329105039c784"
6
- BASE_USDC_DECIMALS = 6
7
-
8
- PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
9
- PROTOCOL_FEE_BPS = 100
10
- MIN_PAYMENT_AMOUNT = 1000
11
-
12
- BASE_RPC_URLS = ["https://mainnet.base.org", "https://base.meowrpc.com", "https://1rpc.io/base"]
13
- BASE_RPC_URLS_SANDBOX = ["https://sepolia.base.org", "https://base-sepolia-rpc.publicnode.com"]
14
-
15
- ACCEPTED_TOKENS = {
16
- 8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
17
- 84532: ["0x109AEddD656Ed2761d1e210E179329105039c784"]
18
- }
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
- ]
@@ -1,71 +0,0 @@
1
- import pytest
2
- from unittest.mock import MagicMock, patch
3
- from web3 import Web3
4
- from paynode_sdk import PayNodeVerifier, ErrorCode, PayNodeException
5
-
6
- @pytest.fixture
7
- def verifier():
8
- with patch('paynode_sdk.verifier.Web3') as mock_w3:
9
- mock_instance = mock_w3.return_value
10
- mock_instance.is_connected.return_value = True
11
- # Mock eth provider structure
12
- mock_instance.eth = MagicMock()
13
- mock_instance.keccak.side_effect = Web3.keccak
14
- return PayNodeVerifier(
15
- rpc_urls="http://localhost",
16
- contract_address="0x" + "a" * 40,
17
- chain_id=84532
18
- )
19
-
20
- @pytest.mark.asyncio
21
- async def test_verify_payment_invalid_receipt(verifier):
22
- """Checks handling of missing transaction receipt."""
23
- verifier.w3.eth.get_transaction_receipt.return_value = None
24
- result = await verifier.verify_onchain_payment("0xHash", {
25
- "merchantAddress": "0xMerchant",
26
- "tokenAddress": "0x109AEddD656Ed2761d1e210E179329105039c784",
27
- "amount": 2000
28
- })
29
- assert result["isValid"] is False
30
- assert result["error"].code == ErrorCode.transaction_not_found
31
-
32
- @pytest.mark.asyncio
33
- async def test_verify_payment_wrong_contract(verifier):
34
- """Checks rejection of logs from unauthorized contract addresses or no logs."""
35
- mock_receipt = {"id": "0x123", "status": 1, "logs": []}
36
-
37
- # Mock behavior of process_receipt when no logs are provided
38
- mock_contract = MagicMock()
39
- mock_contract.events.PaymentReceived().process_receipt.return_value = [] # No valid logs
40
- verifier.w3.eth.contract.return_value = mock_contract
41
- verifier.w3.eth.get_transaction_receipt.return_value = mock_receipt
42
-
43
- result = await verifier.verify_onchain_payment("0xHash", {
44
- "merchantAddress": "0xMerchant",
45
- "tokenAddress": "0x109AEddD656Ed2761d1e210E179329105039c784",
46
- "amount": 2000
47
- })
48
- assert result["isValid"] is False
49
- assert result["error"].code == ErrorCode.invalid_receipt
50
-
51
- @pytest.mark.asyncio
52
- async def test_dust_limit_rejection(verifier):
53
- """Checks rejection of dust payments (<1000 atomic units)."""
54
- # Verifier itself doesn't check MIN_PAYMENT_AMOUNT in verify_onchain_payment
55
- # but the construction of payload in verify() might.
56
- # We'll test the unified entry point.
57
- payload = {
58
- "type": "onchain",
59
- "orderId": "test",
60
- "payload": {"txHash": "0xHash"}
61
- }
62
- result = await verifier.verify(payload, {
63
- "merchantAddress": "0xMerchant",
64
- "tokenAddress": "0xToken",
65
- "amount": 500 # Below 1000
66
- })
67
- # Since verifier.py doesn't currently check MIN_PAYMENT_AMOUNT in verify() either,
68
- # this test might fail or we should add the check to verifier.py.
69
- # The Client implements it, let's see if Verifier should too.
70
- # For now, let's just make it call the valid method.
71
- pass