paynode-sdk-python 1.4.0__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
paynode_sdk/middleware.py CHANGED
@@ -1,4 +1,7 @@
1
1
  import time
2
+ import base64
3
+ import json
4
+ import logging
2
5
  from typing import Optional, Callable, Any
3
6
  from fastapi import Request, Response
4
7
  from fastapi.responses import JSONResponse
@@ -14,6 +17,8 @@ from .constants import (
14
17
 
15
18
  from starlette.middleware.base import BaseHTTPMiddleware
16
19
 
20
+ logger = logging.getLogger("paynode_sdk.middleware")
21
+
17
22
  class PayNodeMiddleware(BaseHTTPMiddleware):
18
23
  def __init__(
19
24
  self,
@@ -27,7 +32,8 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
27
32
  decimals: int = BASE_USDC_DECIMALS,
28
33
  rpc_urls: list | str = BASE_RPC_URLS,
29
34
  store: Optional[IdempotencyStore] = None,
30
- generate_order_id: Optional[Callable[[Request], str]] = None
35
+ generate_order_id: Optional[Callable[[Request], str]] = None,
36
+ **kwargs
31
37
  ):
32
38
  super().__init__(app)
33
39
  # The Verifier holds the state of the idempotency store
@@ -41,62 +47,107 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
41
47
  self.chain_id = chain_id
42
48
  self.generate_order_id = generate_order_id or (lambda r: f"agent_py_{int(time.time() * 1000)}")
43
49
 
44
- # Calculate raw amount (integer)
45
- 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)
58
+ self.description = kwargs.get('description', "Protected Resource")
59
+ self.max_timeout_seconds = kwargs.get('max_timeout_seconds', 3600)
46
60
 
47
61
  async def dispatch(self, request: Request, call_next):
48
- receipt_hash = request.headers.get('x-paynode-receipt')
49
- order_id = request.headers.get('x-paynode-order-id')
62
+ v2_payload_header = request.headers.get('X-402-Payload')
63
+ order_id = request.headers.get('X-402-Order-Id')
50
64
 
51
65
  if not order_id:
52
66
  order_id = self.generate_order_id(request)
53
67
 
54
- # Phase 1: Handshake (402 Payment Required)
55
- if not receipt_hash:
56
- headers = {
57
- 'x-paynode-contract': self.contract_address,
58
- 'x-paynode-merchant': self.merchant_address,
59
- 'x-paynode-amount': str(self.amount_int),
60
- 'x-paynode-currency': self.currency,
61
- 'x-paynode-token-address': self.token_address,
62
- 'x-paynode-chain-id': str(self.chain_id),
63
- 'x-paynode-order-id': order_id,
64
- }
65
- return JSONResponse(
66
- status_code=402,
67
- headers=headers,
68
- content={
69
- "error": "Payment Required",
70
- "code": ErrorCode.missing_receipt,
71
- "message": "Please pay to PayNode contract and provide 'x-paynode-receipt' header.",
72
- "amount": self.price,
73
- "currency": self.currency
74
- }
75
- )
68
+ # Handle x402 v2 Unified Payload
69
+ unified_payload = None
70
+ if v2_payload_header:
71
+ try:
72
+ unified_payload = json.loads(base64.b64decode(v2_payload_header.encode()).decode())
73
+ except Exception as e:
74
+ logger.error(f"❌ [PayNode-Middleware] Failed to decode X-402-Payload header: {e}")
76
75
 
77
- # Phase 2: On-chain Verification
78
- result = await self.verifier.verify_payment(receipt_hash, {
79
- "merchantAddress": self.merchant_address,
80
- "tokenAddress": self.token_address,
81
- "amount": self.amount_int,
82
- "orderId": order_id
83
- })
76
+ if unified_payload:
77
+ try:
78
+ result = await self.verifier.verify(
79
+ unified_payload,
80
+ {
81
+ "merchantAddress": self.merchant_address,
82
+ "tokenAddress": self.token_address,
83
+ "amount": str(self.amount_int),
84
+ "orderId": order_id
85
+ },
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 {}
91
+ )
92
+ if result.get("isValid"):
93
+ request.state.paynode = {"unified_payload": unified_payload, "orderId": order_id}
94
+ return await call_next(request)
95
+ else:
96
+ err = result.get("error")
97
+ return JSONResponse(
98
+ status_code=403,
99
+ content={
100
+ "error": "Forbidden",
101
+ "code": err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt,
102
+ "message": str(err)
103
+ }
104
+ )
105
+ except Exception as e:
106
+ logger.error(f"⚠️ [PayNode-Middleware] Failed to process x402 v2 payload: {e}")
84
107
 
85
- if result.get("isValid"):
86
- # Validation Passed!
87
- return await call_next(request)
88
- else:
89
- # Validation Failed
90
- err = result.get("error")
91
- print(f"❌ [PayNode-PY] Verification Failed for Order: {order_id}. Reason: {str(err)}")
92
- return JSONResponse(
93
- status_code=403,
94
- content={
95
- "error": "Forbidden",
96
- "code": err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt,
97
- "message": str(err)
108
+ # No valid payment found, return 402 with X-402-Required
109
+ v2_response = {
110
+ "x402Version": 2,
111
+ "error": "Payment Required by PayNode",
112
+ "resource": {
113
+ "url": str(request.url),
114
+ "description": self.description,
115
+ "mimeType": request.headers.get("accept", "application/json")
116
+ },
117
+ "accepts": [
118
+ {
119
+ "scheme": "exact",
120
+ "type": "eip3009",
121
+ "network": f"eip155:{self.chain_id}",
122
+ "amount": str(self.amount_int),
123
+ "asset": self.token_address,
124
+ "payTo": self.merchant_address,
125
+ "maxTimeoutSeconds": self.max_timeout_seconds,
126
+ "extra": {
127
+ "name": self.currency,
128
+ "version": "2"
129
+ }
130
+ },
131
+ {
132
+ "scheme": "exact",
133
+ "type": "onchain",
134
+ "network": f"eip155:{self.chain_id}",
135
+ "amount": str(self.amount_int),
136
+ "asset": self.token_address,
137
+ "payTo": self.merchant_address,
138
+ "maxTimeoutSeconds": self.max_timeout_seconds,
139
+ "router": self.contract_address
98
140
  }
99
- )
141
+ ]
142
+ }
143
+
144
+ b64_required = base64.b64encode(json.dumps(v2_response).encode()).decode()
145
+
146
+ headers = {
147
+ 'X-402-Required': b64_required,
148
+ 'X-402-Order-Id': order_id,
149
+ }
150
+ return JSONResponse(status_code=402, headers=headers, content=v2_response)
100
151
 
101
152
  def x402_gate(
102
153
  merchant_address: str,
paynode_sdk/verifier.py CHANGED
@@ -1,30 +1,46 @@
1
1
  import asyncio
2
+ import time
3
+ import logging
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
5
  from .errors import ErrorCode, PayNodeException
3
- from .constants import PAYNODE_ROUTER_ABI, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
6
+ from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
4
7
  from .idempotency import MemoryIdempotencyStore
5
8
  from web3 import Web3
9
+ from eth_account import Account
10
+
11
+ logger = logging.getLogger("paynode_sdk.verifier")
6
12
 
7
13
  class PayNodeVerifier:
8
14
  def __init__(self, rpc_urls=None, contract_address=None, chain_id=None, w3=None, store=None, accepted_tokens=None):
9
15
  self.w3 = w3
10
16
  if not self.w3 and rpc_urls:
11
17
  urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
12
- for rpc in urls:
18
+
19
+ def _check_rpc(url):
13
20
  try:
14
- temp_w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={'timeout': 5}))
21
+ temp_w3 = Web3(Web3.HTTPProvider(url, request_kwargs={'timeout': 3}))
15
22
  if temp_w3.is_connected():
16
- self.w3 = temp_w3
17
- break
23
+ return temp_w3
18
24
  except Exception:
19
- continue
25
+ pass
26
+ return None
27
+
28
+ with ThreadPoolExecutor(max_workers=min(len(urls), 5)) as executor:
29
+ future_to_url = {executor.submit(_check_rpc, url): url for url in urls}
30
+ for future in as_completed(future_to_url):
31
+ w3_instance = future.result()
32
+ if w3_instance:
33
+ self.w3 = w3_instance
34
+ logger.debug(f"⚡ [PayNode-PY] Verifier connected to RPC: {future_to_url[future]}")
35
+ break
36
+
20
37
  if not self.w3:
21
- raise PayNodeException(ErrorCode.rpc_error)
38
+ raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
22
39
  self.contract_address = contract_address
23
40
  self.chain_id = int(chain_id) if chain_id else None
24
41
  self.store = store or MemoryIdempotencyStore()
25
42
 
26
43
  # Build accepted token set: user-provided or chain-default
27
- # accepted_tokens=None → use chain default; accepted_tokens=[] → explicitly disable whitelist
28
44
  if accepted_tokens is not None:
29
45
  token_list = accepted_tokens
30
46
  elif self.chain_id:
@@ -33,102 +49,262 @@ class PayNodeVerifier:
33
49
  token_list = None
34
50
  self.accepted_tokens = set(t.lower() for t in token_list) if token_list else None
35
51
 
36
- async def verify_payment(self, tx_hash, expected):
37
- if not self.w3:
38
- return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error, message="Verifier Provider Missing")}
39
-
40
- # 0. Dust Exploit Check (Minimum Payment)
41
- amount = int(expected.get("amount", 0))
42
- if amount < MIN_PAYMENT_AMOUNT:
43
- return {"isValid": False, "error": PayNodeException(
44
- ErrorCode.amount_too_low,
45
- message=f"Payment amount {amount} is below the minimum threshold of {MIN_PAYMENT_AMOUNT}."
46
- )}
52
+ async def verify(self, unified_payload: dict, expected: dict, extra: dict = None) -> dict:
53
+ """
54
+ Unified verification entry point for X402 V3.1 (Hybrid V2).
55
+ Routes to verify_onchain_payment or verify_transfer_with_authorization (eip3009).
56
+ """
57
+ try:
58
+ # 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)}
47
62
 
48
- # 1. Token Whitelist Check (Anti-FakeToken)
49
- expected_token = expected.get("tokenAddress", "").lower()
50
- if self.accepted_tokens and expected_token not in self.accepted_tokens:
51
- return {"isValid": False, "error": PayNodeException(
52
- ErrorCode.token_not_accepted,
53
- message=f"Token {expected.get('tokenAddress')} is not in the accepted whitelist."
54
- )}
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")}
55
66
 
67
+ payload_type = unified_payload.get("type")
68
+ actual_payload = unified_payload.get("payload", {})
69
+ order_id = unified_payload.get("orderId")
70
+
71
+ if payload_type == "onchain":
72
+ tx_hash = actual_payload.get("txHash")
73
+ if not tx_hash:
74
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Missing txHash in onchain payload")}
75
+
76
+ onchain_expected = {
77
+ "merchantAddress": expected.get("merchantAddress"),
78
+ "tokenAddress": expected.get("tokenAddress"),
79
+ "amount": expected.get("amount"),
80
+ "orderId": order_id
81
+ }
82
+ return await self.verify_onchain_payment(tx_hash, onchain_expected)
83
+
84
+ elif payload_type == "eip3009":
85
+ token_addr = expected.get("tokenAddress")
86
+ if not token_addr:
87
+ return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message="tokenAddress is required for eip3009 verification")}
88
+
89
+ return await self.verify_transfer_with_authorization(
90
+ token_addr,
91
+ actual_payload,
92
+ {"to": expected.get("merchantAddress"), "value": expected.get("amount")},
93
+ extra
94
+ )
95
+ else:
96
+ return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=f"Unsupported payload type: {payload_type}")}
97
+ except Exception as e:
98
+ if isinstance(e, PayNodeException):
99
+ return {"isValid": False, "error": e}
100
+ return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
56
101
 
102
+ async def verify_onchain_payment(self, tx_hash, expected):
103
+ if not self.w3:
104
+ return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error)}
57
105
 
58
- # Wrap synchronous web3 calls in asyncio.to_thread to avoid blocking the event loop
59
106
  try:
60
107
  receipt = await asyncio.to_thread(self.w3.eth.get_transaction_receipt, tx_hash)
61
108
  except Exception:
62
109
  return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
63
110
 
64
- if not receipt:
111
+ if receipt is None:
65
112
  return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
66
-
113
+
67
114
  if receipt.get("status") == 0:
68
115
  return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_failed)}
69
116
 
70
117
  contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=PAYNODE_ROUTER_ABI)
71
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
+
72
125
  try:
73
- 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})
74
127
  except Exception:
75
- return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
128
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
76
129
 
77
- if not logs:
78
- return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No valid 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")}
79
132
 
80
- # Find and validate the specific log
81
133
  merchant = expected.get("merchantAddress", "").lower()
82
134
  token = expected.get("tokenAddress", "").lower()
83
135
  amount = int(expected.get("amount", 0))
84
136
  order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
85
137
 
86
- last_error = None
87
138
  valid_log_found = False
88
-
89
- for log in logs:
90
- if log.address.lower() != self.contract_address.lower():
91
- last_error = last_error or PayNodeException(ErrorCode.wrong_contract)
92
- continue
93
-
139
+ order_id_mismatch_found = False
140
+ for log in processed_logs:
94
141
  args = log.args
95
-
96
- # 4. Verify OrderId
97
- if args.get("orderId") != order_id_bytes:
98
- last_error = PayNodeException(ErrorCode.order_mismatch)
99
- continue
100
-
101
- # 5. Verify Merchant
102
- if args.get("merchant", "").lower() != merchant:
103
- last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment went to a different merchant.")
104
- continue
105
-
106
- # 6. Verify Token
107
- if args.get("token", "").lower() != token:
108
- last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment used unexpected token.")
109
- continue
110
-
111
- # 7. Verify Amount
112
- if args.get("amount", 0) < amount:
113
- last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment amount is below required price.")
114
- continue
115
-
116
- # 8. Verify ChainId
117
- if self.chain_id and args.get("chainId") != self.chain_id:
118
- last_error = PayNodeException(ErrorCode.invalid_receipt, message="ChainId mismatch. Invalid network.")
119
- continue
120
-
121
- valid_log_found = True
122
- break
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
123
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
153
+
124
154
  if not valid_log_found:
125
- return {"isValid": False, "error": last_error or PayNodeException(ErrorCode.invalid_receipt, message="No matching payment event 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")}
157
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Payment event data mismatch")}
126
158
 
127
- try:
128
- is_new = await self.store.check_and_set(tx_hash, 86400) # 24 hour TTL
159
+ if self.store:
160
+ is_new = await self.store.check_and_set(tx_hash, 86400)
129
161
  if not is_new:
130
162
  return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction)}
131
- except Exception as e:
132
- return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, details=str(e))}
133
163
 
134
164
  return {"isValid": True}
165
+
166
+ async def verify_transfer_with_authorization(
167
+ self,
168
+ token_addr: str,
169
+ payload: dict,
170
+ expected: dict,
171
+ extra: dict = None
172
+ ) -> dict:
173
+ """
174
+ Verifies an EIP-3009 TransferWithAuthorization signature.
175
+ Includes RPC state checks for balance and nonce status.
176
+ """
177
+ if not self.w3:
178
+ return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error, message="Verifier web3 instance missing")}
179
+
180
+ extra = extra or {}
181
+ try:
182
+ signature = payload["signature"]
183
+ auth = payload["authorization"]
184
+
185
+ # 1. Basic validation
186
+ if auth["to"].lower() != expected["to"].lower():
187
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Recipient mismatch")}
188
+
189
+ payload_value = int(auth["value"])
190
+ expected_value = int(expected["value"])
191
+ if payload_value < expected_value:
192
+ return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
193
+
194
+ # 2. Time window check
195
+ now = int(time.time())
196
+ if now < int(auth["validAfter"]):
197
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization not yet valid")}
198
+ if now > int(auth["validBefore"]):
199
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization expired")}
200
+
201
+ # 3. Signature verification
202
+ chain_id = self.chain_id or await asyncio.to_thread(lambda: self.w3.eth.chain_id)
203
+ domain = {
204
+ "name": extra.get("name", "USD Coin"),
205
+ "version": extra.get("version", "2"),
206
+ "chainId": chain_id,
207
+ "verifyingContract": Web3.to_checksum_address(token_addr)
208
+ }
209
+
210
+ types = {
211
+ "EIP712Domain": [
212
+ {"name": "name", "type": "string"},
213
+ {"name": "version", "type": "string"},
214
+ {"name": "chainId", "type": "uint256"},
215
+ {"name": "verifyingContract", "type": "address"},
216
+ ],
217
+ "TransferWithAuthorization": [
218
+ {"name": "from", "type": "address"},
219
+ {"name": "to", "type": "address"},
220
+ {"name": "value", "type": "uint256"},
221
+ {"name": "validAfter", "type": "uint256"},
222
+ {"name": "validBefore", "type": "uint256"},
223
+ {"name": "nonce", "type": "bytes32"},
224
+ ]
225
+ }
226
+
227
+ auth_msg = {
228
+ "from": Web3.to_checksum_address(auth["from"]),
229
+ "to": Web3.to_checksum_address(auth["to"]),
230
+ "value": payload_value,
231
+ "validAfter": int(auth["validAfter"]),
232
+ "validBefore": int(auth["validBefore"]),
233
+ "nonce": Web3.to_bytes(hexstr=auth["nonce"])
234
+ }
235
+
236
+ structured_data = {
237
+ "types": types,
238
+ "domain": domain,
239
+ "primaryType": "TransferWithAuthorization",
240
+ "message": auth_msg
241
+ }
242
+
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)
246
+
247
+ if recovered_address.lower() != auth["from"].lower():
248
+ return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Invalid signature")}
249
+
250
+ # 4. Idempotency (Nonce local check)
251
+ nonce = auth["nonce"]
252
+ if self.store:
253
+ is_new = await self.store.check_and_set(nonce, 86400)
254
+ if not is_new:
255
+ return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already used in local memory")}
256
+
257
+ # 5. RPC State Checks (Balance & Nonce)
258
+ token_abi = [
259
+ {
260
+ "constant": True,
261
+ "inputs": [{"name": "account", "type": "address"}],
262
+ "name": "balanceOf",
263
+ "outputs": [{"name": "", "type": "uint256"}],
264
+ "payable": False,
265
+ "stateMutability": "view",
266
+ "type": "function",
267
+ },
268
+ {
269
+ "constant": True,
270
+ "inputs": [
271
+ {"name": "authorizer", "type": "address"},
272
+ {"name": "nonce", "type": "bytes32"}
273
+ ],
274
+ "name": "authorizationState",
275
+ "outputs": [{"name": "", "type": "bool"}],
276
+ "payable": False,
277
+ "stateMutability": "view",
278
+ "type": "function",
279
+ }
280
+ ]
281
+
282
+ token_contract = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=token_abi)
283
+ authorizer_address = Web3.to_checksum_address(auth["from"])
284
+ nonce_bytes = Web3.to_bytes(hexstr=nonce)
285
+
286
+ # Concurrent RPC calls
287
+ try:
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
+ )
292
+ except Exception as e:
293
+ logger.warning(f"RPC state check failed for token {token_addr}: {e}")
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
+ }
299
+
300
+ if balance < payload_value:
301
+ if self.store: await self.store.delete(nonce)
302
+ return {"isValid": False, "error": PayNodeException(ErrorCode.insufficient_funds, message="Insufficient token balance")}
303
+
304
+ if is_nonce_used_on_chain:
305
+ if self.store: await self.store.delete(nonce)
306
+ return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already consumed on-chain")}
307
+
308
+ return {"isValid": True}
309
+ except Exception as e:
310
+ return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
paynode_sdk/webhook.py CHANGED
@@ -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
  )
@@ -100,7 +100,7 @@ class PayNodeWebhookNotifier:
100
100
  raise ValueError("webhook_secret is required")
101
101
 
102
102
  self.contract_address = contract_address or PAYNODE_ROUTER_ADDRESS
103
- self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
103
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 3}))
104
104
  self.contract = self.w3.eth.contract(
105
105
  address=Web3.to_checksum_address(self.contract_address),
106
106
  abi=PAYNODE_ROUTER_ABI
@@ -120,7 +120,7 @@ class PayNodeWebhookNotifier:
120
120
  async def start(self, from_block: Optional[int] = None) -> None:
121
121
  """Start polling for PaymentReceived events."""
122
122
  if self._running:
123
- logger.warning("[PayNode Webhook] Already running.")
123
+ logger.warning("🔔 [PayNode Webhook] Already running.")
124
124
  return
125
125
 
126
126
  self._last_block = from_block if from_block is not None else self.w3.eth.block_number
@@ -158,7 +158,7 @@ class PayNodeWebhookNotifier:
158
158
 
159
159
  self._last_block = current_block
160
160
  except Exception as e:
161
- logger.error(f"[PayNode Webhook] Poll error: {e}")
161
+ logger.error(f"[PayNode Webhook] Poll error: {e}")
162
162
 
163
163
  await asyncio.sleep(self.poll_interval)
164
164
 
@@ -179,7 +179,7 @@ class PayNodeWebhookNotifier:
179
179
  timestamp=time.time(),
180
180
  )
181
181
  except Exception as e:
182
- logger.error(f"[PayNode Webhook] Failed to parse event: {e}")
182
+ logger.error(f"[PayNode Webhook] Failed to parse event: {e}")
183
183
  return None
184
184
 
185
185
  async def _deliver(self, event: PaymentEvent, attempt: int = 1) -> None:
@@ -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
 
@@ -221,13 +221,13 @@ class PayNodeWebhookNotifier:
221
221
  self.on_success(event)
222
222
 
223
223
  except Exception as e:
224
- logger.error(f"[PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
224
+ logger.error(f"⚠️ [PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
225
225
 
226
226
  if attempt < MAX_RETRIES:
227
227
  backoff = (2 ** attempt) # 2s, 4s, 8s
228
228
  await asyncio.sleep(backoff)
229
229
  return await self._deliver(event, attempt + 1)
230
230
 
231
- logger.error(f"[PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
231
+ logger.error(f"[PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
232
232
  if self.on_error:
233
233
  self.on_error(e, event)