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.
- {paynode_sdk_python-2.0.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.1.0}/PKG-INFO +2 -2
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/README.md +1 -1
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/client.py +66 -18
- paynode_sdk_python-2.1.0/paynode_sdk/constants.py +20 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/errors.py +1 -1
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/idempotency.py +20 -9
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/middleware.py +16 -5
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/verifier.py +47 -25
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/webhook.py +5 -5
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0/paynode_sdk_python.egg-info}/PKG-INFO +2 -2
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/SOURCES.txt +1 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/pyproject.toml +1 -1
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/tests/test_client.py +2 -2
- paynode_sdk_python-2.1.0/tests/test_concurrency.py +75 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/tests/test_internals.py +3 -3
- paynode_sdk_python-2.1.0/tests/test_verifier_logic.py +282 -0
- paynode_sdk_python-2.0.0/paynode_sdk/constants.py +0 -35
- paynode_sdk_python-2.0.0/tests/test_verifier_logic.py +0 -71
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/LICENSE +0 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk/__init__.py +0 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
- {paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
290
|
-
sig = self.sign_permit(token_addr, router_addr, amount)
|
|
291
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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 '
|
|
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
|
-
|
|
27
|
-
|
|
30
|
+
with self.lock:
|
|
31
|
+
now = time.time()
|
|
32
|
+
expiry = self.cache.get(tx_hash)
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
if expiry and expiry > now:
|
|
35
|
+
return False
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
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('
|
|
56
|
-
order_id = request.headers.get('
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
+
order_id_mismatch_found = False
|
|
140
|
+
for log in processed_logs:
|
|
126
141
|
args = log.args
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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:
|
|
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="
|
|
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
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paynode-sdk-python"
|
|
7
|
-
version = "2.
|
|
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 = "
|
|
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': "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{paynode_sdk_python-2.0.0 → paynode_sdk_python-2.1.0}/paynode_sdk_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|