paynode-sdk-python 2.2.1__tar.gz → 2.5.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 (27) hide show
  1. {paynode_sdk_python-2.2.1/paynode_sdk_python.egg-info → paynode_sdk_python-2.5.0}/PKG-INFO +1 -1
  2. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/__init__.py +10 -4
  3. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/client.py +18 -6
  4. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/constants.py +3 -1
  5. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/errors.py +1 -1
  6. paynode_sdk_python-2.5.0/paynode_sdk/merchant.py +137 -0
  7. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/middleware.py +122 -34
  8. paynode_sdk_python-2.5.0/paynode_sdk/utils/__init__.py +0 -0
  9. paynode_sdk_python-2.5.0/paynode_sdk/utils/payload.py +64 -0
  10. paynode_sdk_python-2.5.0/paynode_sdk/utils/signature.py +67 -0
  11. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/verifier.py +14 -7
  12. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0/paynode_sdk_python.egg-info}/PKG-INFO +1 -1
  13. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/SOURCES.txt +5 -0
  14. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/pyproject.toml +1 -1
  15. paynode_sdk_python-2.5.0/tests/test_v2_5_alignment.py +189 -0
  16. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/LICENSE +0 -0
  17. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/README.md +0 -0
  18. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/idempotency.py +0 -0
  19. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/webhook.py +0 -0
  20. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
  21. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
  22. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
  23. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/setup.cfg +0 -0
  24. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_client.py +0 -0
  25. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_concurrency.py +0 -0
  26. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_internals.py +0 -0
  27. {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_verifier_logic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paynode-sdk-python
3
- Version: 2.2.1
3
+ Version: 2.5.0
4
4
  Summary: PayNode Protocol Python SDK for AI Agents
5
5
  Author-email: PayNodeLabs <contact@paynode.dev>
6
6
  License: MIT
@@ -5,27 +5,33 @@ import logging
5
5
  # to ensure a clean experience for PayNode SDK users.
6
6
  warnings.filterwarnings("ignore", category=DeprecationWarning, module="websockets.legacy")
7
7
 
8
- from .middleware import PayNodeMiddleware, x402_gate
8
+ from .middleware import PayNodeMiddleware, x402_gate, PayNodeMerchantMiddleware
9
+ from .merchant import PayNodeMerchant
9
10
  from .verifier import PayNodeVerifier
10
11
  from .errors import ErrorCode, PayNodeException
11
12
  from .idempotency import IdempotencyStore, MemoryIdempotencyStore, RedisIdempotencyStore
12
13
  from .webhook import PayNodeWebhookNotifier, PaymentEvent
13
14
  from .client import PayNodeAgentClient
15
+ from .utils.payload import PayNodePayloadHelper
14
16
  from .constants import (
15
17
  PAYNODE_ROUTER_ADDRESS,
16
18
  PAYNODE_ROUTER_ADDRESS_SANDBOX,
17
19
  BASE_USDC_ADDRESS,
18
20
  BASE_USDC_ADDRESS_SANDBOX,
19
21
  ACCEPTED_TOKENS,
20
- MIN_PAYMENT_AMOUNT
22
+ MIN_PAYMENT_AMOUNT,
23
+ PROTOCOL_VERSION,
24
+ SDK_VERSION
21
25
  )
22
26
 
23
27
  __all__ = [
24
- "PayNodeMiddleware", "x402_gate", "PayNodeVerifier", "ErrorCode", "PayNodeException",
28
+ "PayNodeMiddleware", "x402_gate", "PayNodeMerchantMiddleware",
29
+ "PayNodeMerchant", "PayNodeVerifier", "ErrorCode", "PayNodeException",
25
30
  "IdempotencyStore", "MemoryIdempotencyStore", "RedisIdempotencyStore",
26
31
  "PayNodeWebhookNotifier", "PaymentEvent",
27
32
  "PayNodeAgentClient",
28
33
  "PAYNODE_ROUTER_ADDRESS", "PAYNODE_ROUTER_ADDRESS_SANDBOX",
29
34
  "BASE_USDC_ADDRESS", "BASE_USDC_ADDRESS_SANDBOX",
30
- "ACCEPTED_TOKENS", "MIN_PAYMENT_AMOUNT"
35
+ "ACCEPTED_TOKENS", "MIN_PAYMENT_AMOUNT", "PROTOCOL_VERSION", "SDK_VERSION",
36
+ "PayNodePayloadHelper"
31
37
  ]
@@ -10,14 +10,14 @@ from eth_account.messages import encode_typed_data
10
10
  from web3 import Web3
11
11
  from requests.adapters import HTTPAdapter
12
12
  from urllib3.util.retry import Retry
13
- from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
13
+ from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI, SDK_VERSION
14
14
  from .errors import PayNodeException, ErrorCode
15
15
 
16
16
  logger = logging.getLogger("paynode_sdk.client")
17
17
 
18
18
  class PayNodeAgentClient:
19
19
  """
20
- The main PayNode Client for AI Agents (v2.2.1).
20
+ The main PayNode Client for AI Agents (v2.5.0).
21
21
  Automatically handles the x402 'Payment Required' handshake.
22
22
  Supports RPC redundancy, EIP-2612 Permit, and EIP-3009 Authorization.
23
23
  """
@@ -84,6 +84,12 @@ class PayNodeAgentClient:
84
84
 
85
85
  def request_gate(self, url: str, method: str = "GET", **kwargs):
86
86
  """The high-level autonomous method handling 402 loop."""
87
+ # Inject X-PayNode-Network header (mirrors JS SDK behavior)
88
+ chain_id = self.w3.eth.chain_id
89
+ paynode_network = 'mainnet' if chain_id == 8453 else 'testnet'
90
+ headers = kwargs.get('headers', {}).copy()
91
+ headers.setdefault('X-PayNode-Network', paynode_network)
92
+ kwargs['headers'] = headers
87
93
  return self._request_with_402_retry(method.upper(), url, **kwargs)
88
94
 
89
95
  def get(self, url, **kwargs):
@@ -159,6 +165,7 @@ class PayNodeAgentClient:
159
165
  raise PayNodeException(ErrorCode.token_not_accepted, message=f"Token {requirement['asset']} is not in the whitelist for chain {chain_id}")
160
166
 
161
167
  logger.info(f"💡 [PayNode-PY] Payment request (v2): {requirement['amount']} atomic units of {requirement['asset']} to {requirement['payTo']}")
168
+ logger.info(f"💡 [PayNode-PY] Selected payment method: {requirement.get('type', 'onchain')} on {requirement.get('network')}")
162
169
 
163
170
  # Dust limit check
164
171
  if int(requirement['amount']) < MIN_PAYMENT_AMOUNT:
@@ -215,6 +222,7 @@ class PayNodeAgentClient:
215
222
  "resource": requirements.get('resource'),
216
223
  "accepted": {
217
224
  "scheme": requirement.get('scheme'),
225
+ "type": ptype,
218
226
  "network": requirement.get('network'),
219
227
  "amount": requirement.get('amount'),
220
228
  "asset": requirement.get('asset'),
@@ -224,7 +232,7 @@ class PayNodeAgentClient:
224
232
  },
225
233
  "payload": payload_data,
226
234
  "_paynode": {
227
- "version": "2.2.1",
235
+ "sdkVersion": SDK_VERSION,
228
236
  "type": ptype,
229
237
  "orderId": order_id
230
238
  }
@@ -238,12 +246,16 @@ class PayNodeAgentClient:
238
246
 
239
247
  b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
240
248
 
249
+ chain_id = self.w3.eth.chain_id
250
+ paynode_network = 'mainnet' if chain_id == 8453 else 'testnet'
251
+
241
252
  retry_headers = kwargs.get('headers', {}).copy()
242
253
  retry_headers.update({
243
254
  'Content-Type': 'application/json',
244
255
  'PAYMENT-SIGNATURE': b64_payload,
245
256
  'X-402-Payload': b64_payload, # Backward compatibility
246
- 'X-402-Order-Id': order_id
257
+ 'X-402-Order-Id': order_id,
258
+ 'X-PayNode-Network': paynode_network
247
259
  })
248
260
  kwargs['headers'] = retry_headers
249
261
  return kwargs
@@ -382,7 +394,7 @@ class PayNodeAgentClient:
382
394
  sig = self.sign_permit(token_addr, router_addr, amount, version=version)
383
395
  router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
384
396
  order_id_bytes = self.w3.keccak(text=order_id)
385
- current_gas_price = int(self.w3.eth.gas_price * 1.2)
397
+ current_gas_price = self.w3.eth.gas_price * 120 // 100
386
398
  with self.nonce_lock:
387
399
  nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
388
400
  tx = router.functions.payWithPermit(self.account.address, Web3.to_checksum_address(token_addr), Web3.to_checksum_address(merchant_addr), amount, order_id_bytes, sig["deadline"], sig["v"], sig["r"], sig["s"]).build_transaction({'from': self.account.address, 'nonce': nonce, 'gas': 300000, 'gasPrice': current_gas_price})
@@ -397,7 +409,7 @@ class PayNodeAgentClient:
397
409
  def __pay_raw(self, router_addr, token_addr, merchant_addr, amount, order_id):
398
410
  router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
399
411
  order_id_bytes = self.w3.keccak(text=order_id)
400
- current_gas_price = int(self.w3.eth.gas_price * 1.2)
412
+ current_gas_price = self.w3.eth.gas_price * 120 // 100
401
413
  with self.nonce_lock:
402
414
  nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
403
415
  tx = router.functions.pay(Web3.to_checksum_address(token_addr), Web3.to_checksum_address(merchant_addr), amount, order_id_bytes).build_transaction({'from': self.account.address, 'nonce': nonce, 'gas': 200000, 'gasPrice': current_gas_price})
@@ -1,10 +1,12 @@
1
- # Generated by scripts/sync-config.py
1
+ # Generated by meta/scripts/sync-config.py
2
2
  PAYNODE_ROUTER_ADDRESS = "0x4A73696ccF76E7381b044cB95127B3784369Ed63"
3
3
  PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"
4
4
  BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
5
5
  BASE_USDC_ADDRESS_SANDBOX = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
6
6
  BASE_USDC_DECIMALS = 6
7
7
 
8
+ PROTOCOL_VERSION = 2
9
+ SDK_VERSION = "2.5.0"
8
10
  PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
9
11
  PROTOCOL_FEE_BPS = 100
10
12
  MIN_PAYMENT_AMOUNT = 1000
@@ -1,4 +1,4 @@
1
- # Generated by scripts/sync-config.py
1
+ # Generated by meta/scripts/sync-config.py
2
2
  from enum import Enum
3
3
  from typing import Any, Optional
4
4
 
@@ -0,0 +1,137 @@
1
+ import logging
2
+ import requests
3
+ import time
4
+ from typing import Optional, Dict, Any, Union
5
+ from .utils.signature import verify_market_signature
6
+ from .middleware import PayNodeMiddleware
7
+ from .constants import PROTOCOL_VERSION
8
+
9
+ logger = logging.getLogger("paynode_sdk.merchant")
10
+
11
+ class PayNodeMerchant:
12
+ """
13
+ PayNodeMerchant: The high-level SDK class for Merchant Integration.
14
+ Mirrors JS PayNodeMerchant v2.5.0 baseline.
15
+ """
16
+ def __init__(
17
+ self,
18
+ shared_secret: str,
19
+ market_url: str = "https://mk.paynode.dev",
20
+ quiet: bool = False
21
+ ):
22
+ self.shared_secret = shared_secret
23
+ self.market_url = market_url
24
+ self.quiet = quiet
25
+
26
+ def sync(self, manifest: Dict[str, Any]) -> bool:
27
+ """
28
+ Registers or syncs the API manifest with the PayNode Market.
29
+ This ensures the market shows the correct price and input schema.
30
+ """
31
+ if not self.quiet:
32
+ logger.info(f"[PayNode-SDK] Syncing API manifest for {manifest.get('slug')} to {self.market_url}")
33
+
34
+ try:
35
+ payload = {**manifest, "gateway_url": manifest.get("slug")}
36
+ response = requests.post(
37
+ f"{self.market_url}/api/v1/merchant/apis",
38
+ json=payload,
39
+ timeout=10
40
+ )
41
+
42
+ result = response.json()
43
+ if response.status_code == 200 and result.get("success"):
44
+ if not self.quiet:
45
+ logger.info(f"[PayNode-SDK] Successfully synced {manifest.get('slug')}. ID: {result.get('api_id')}")
46
+ return True
47
+ else:
48
+ error_msg = result.get("error") or response.reason
49
+ if not self.quiet:
50
+ logger.warning(f"[PayNode-SDK] Sync failed for {manifest.get('slug')}: {error_msg}")
51
+ return False
52
+ except Exception as e:
53
+ if not self.quiet:
54
+ logger.error(f"[PayNode-SDK] Network error during sync for {manifest.get('slug')}: {e}")
55
+ return False
56
+
57
+ async def verify(self, request: Any) -> Dict[str, Any]:
58
+ """
59
+ Manual verification for FastAPI or other environments.
60
+ Extracts headers and verifies signature. Returns the unwrapped body and context.
61
+ """
62
+ # Adapt for FastAPI/Starlette request or dict-like object
63
+ if hasattr(request, "headers"):
64
+ headers = request.headers
65
+ else:
66
+ headers = getattr(request, "headers", {})
67
+
68
+ signature = headers.get("X-PayNode-Signature")
69
+ timestamp = headers.get("X-PayNode-Timestamp")
70
+ # Try fallbacks matching JS logic
71
+ request_id = (
72
+ headers.get("X-PayNode-Request-Id") or
73
+ headers.get("X-402-Order-Id") or
74
+ headers.get("PAYMENT-SIGNATURE")
75
+ )
76
+
77
+ is_valid = verify_market_signature(
78
+ signature=signature,
79
+ order_id=request_id,
80
+ timestamp=timestamp,
81
+ shared_secret=self.shared_secret
82
+ )
83
+
84
+ if not is_valid:
85
+ return {"isValid": False, "error": "Invalid PayNode Market Signature"}
86
+
87
+ # Handle Body Unwrap
88
+ body = {}
89
+ try:
90
+ if hasattr(request, "json") and callable(request.json):
91
+ body = await request.json()
92
+ else:
93
+ body = getattr(request, "body", {})
94
+ if isinstance(body, bytes):
95
+ import json
96
+ body = json.loads(body)
97
+ except Exception:
98
+ pass
99
+
100
+ paynode_context = {"orderId": request_id}
101
+
102
+ if isinstance(body, dict) and body.get("payload") and isinstance(body.get("payload"), dict):
103
+ # Enrich context with proxy details
104
+ paynode_context.update({
105
+ "txHash": headers.get("X-PayNode-Transaction-Hash") or body.get("tx_hash"),
106
+ "amount": headers.get("X-PayNode-Amount") or body.get("amount"),
107
+ "network": headers.get("X-PayNode-Network") or body.get("network"),
108
+ "chainId": headers.get("X-PayNode-Chain-Id") or (str(body.get("chain_id")) if body.get("chain_id") else None),
109
+ })
110
+ # Transparently Unwrap Body
111
+ body = body.get("payload")
112
+
113
+ return {
114
+ "isValid": True,
115
+ "body": body,
116
+ "paynodeContext": paynode_context
117
+ }
118
+
119
+ def middleware(self, merchant_address: str, price: str, manifest: Optional[Dict[str, Any]] = None, **kwargs):
120
+ """
121
+ Creates a unified middleware that handles:
122
+ 1. Market Proxy (Strict Signature Check + Body Unwrap)
123
+ 2. Auto-Discovery (Market Sync Probe)
124
+
125
+ Usage for FastAPI:
126
+ merchant = PayNodeMerchant(shared_secret="...")
127
+ app.add_middleware(merchant.middleware, merchant_address="0x...", price="0.01")
128
+ """
129
+ from .middleware import PayNodeMerchantMiddleware
130
+ return lambda app: PayNodeMerchantMiddleware(
131
+ app=app,
132
+ merchant=self,
133
+ merchant_address=merchant_address,
134
+ price=price,
135
+ manifest=manifest,
136
+ **kwargs
137
+ )
@@ -12,8 +12,13 @@ from .constants import (
12
12
  BASE_RPC_URLS,
13
13
  PAYNODE_ROUTER_ADDRESS,
14
14
  BASE_USDC_ADDRESS,
15
- BASE_USDC_DECIMALS
15
+ BASE_USDC_DECIMALS,
16
+ PROTOCOL_VERSION,
17
+ SDK_VERSION
16
18
  )
19
+ from datetime import datetime, timezone
20
+ from typing import Optional, Callable, Any, Dict
21
+ from .utils.payload import PayNodePayloadHelper
17
22
 
18
23
  from starlette.middleware.base import BaseHTTPMiddleware
19
24
 
@@ -69,39 +74,8 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
69
74
  unified_payload = None
70
75
  if v2_payload_header:
71
76
  try:
72
- parsed = json.loads(base64.b64decode(v2_payload_header.encode()).decode())
73
-
74
- if parsed.get('x402Version') == 2 and parsed.get('accepted'):
75
- # Official X402 V2 format - convert to internal format
76
- internal_order_id = parsed.get('_paynode', {}).get('orderId') \
77
- or order_id \
78
- or f"auto_{int(time.time() * 1000)}"
79
-
80
- # Infer type from payload content if missing
81
- payload_content = parsed.get('payload', {})
82
- inferred_type = 'onchain'
83
- if payload_content.get('signature') or payload_content.get('authorization'):
84
- inferred_type = 'eip3009'
85
- elif payload_content.get('txHash'):
86
- inferred_type = 'onchain'
87
-
88
- p_type = parsed.get('_paynode', {}).get('type') or inferred_type
89
-
90
- unified_payload = {
91
- "version": "2.2.1",
92
- "type": p_type,
93
- "orderId": internal_order_id,
94
- "router": parsed.get('accepted', {}).get('router'),
95
- "payload": parsed.get('payload')
96
- }
97
- order_id = internal_order_id
98
- elif isinstance(parsed.get('version'), str) and parsed.get('version').startswith(("2.3", "2.2")):
99
- # Legacy PayNode format
100
- unified_payload = parsed
101
- if 'orderId' in unified_payload:
102
- order_id = unified_payload['orderId']
103
- elif 'order_id' in unified_payload:
104
- order_id = unified_payload['order_id']
77
+ unified_payload = PayNodePayloadHelper.normalize(v2_payload_header, order_id)
78
+ order_id = unified_payload.get("orderId") or order_id
105
79
  except Exception as e:
106
80
  logger.error(f"❌ [PayNode-Middleware] Failed to decode payment payload header: {e}")
107
81
 
@@ -170,6 +144,7 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
170
144
  v2_response = {
171
145
  "x402Version": 2,
172
146
  "error": "Payment Required by PayNode",
147
+ "orderId": order_id,
173
148
  "resource": {
174
149
  "url": str(request.url),
175
150
  "description": self.description,
@@ -211,6 +186,119 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
211
186
  }
212
187
  return JSONResponse(status_code=402, headers=headers, content=v2_response)
213
188
 
189
+
190
+ class PayNodeMerchantMiddleware(BaseHTTPMiddleware):
191
+ """
192
+ Unified PayNode Merchant Middleware
193
+ Handles:
194
+ 1. Market Proxy (Strict HMAC Signature + Body Unwrapping)
195
+ 2. Discovery Probes (Auto-respond with API Manifest)
196
+
197
+ Note: Standalone direct X402 payment flow should be handled
198
+ via x402_gate.
199
+ """
200
+ def __init__(
201
+ self,
202
+ app: Any,
203
+ merchant: Any, # PayNodeMerchant instance
204
+ merchant_address: str,
205
+ price: str,
206
+ manifest: Optional[Dict[str, Any]] = None,
207
+ **kwargs
208
+ ):
209
+ super().__init__(app)
210
+ self.merchant = merchant
211
+ self.merchant_address = merchant_address
212
+ self.price = price
213
+ self.manifest = manifest or {}
214
+ self.quiet = getattr(merchant, "quiet", False)
215
+
216
+ async def dispatch(self, request: Request, call_next):
217
+ # 1. Check for Market Proxy Headers
218
+ headers = request.headers
219
+ signature = headers.get("X-PayNode-Signature")
220
+ timestamp = headers.get("X-PayNode-Timestamp")
221
+ request_id = headers.get("X-PayNode-Request-Id") or headers.get("X-402-Order-Id")
222
+ is_discovery = headers.get("X-PayNode-Discovery") == "true"
223
+
224
+ if signature and request_id and timestamp:
225
+ # ✅ Verify Signature from PayNode Market
226
+ from .utils.signature import verify_market_signature
227
+ is_valid = verify_market_signature(
228
+ signature=signature,
229
+ order_id=request_id,
230
+ timestamp=timestamp,
231
+ shared_secret=self.merchant.shared_secret
232
+ )
233
+
234
+ if not is_valid:
235
+ if not self.quiet:
236
+ logger.error(f"[PayNode-SDK] Invalid Market Proxy Signature for request {request_id}")
237
+ return JSONResponse(
238
+ status_code=401,
239
+ content={"error": "unauthorized", "message": "PayNode Market Signature verification failed."}
240
+ )
241
+
242
+ # --- Scene A: Discovery Probe ---
243
+ if is_discovery:
244
+ return JSONResponse(
245
+ status_code=200,
246
+ content={
247
+ "status": "DISCOVERED",
248
+ "x402Version": PROTOCOL_VERSION,
249
+ "manifest": self.manifest,
250
+ "last_synced": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
251
+ }
252
+ )
253
+
254
+ # --- Scene B: Proxy Flow - Context Enrichment ---
255
+ # In FastAPI, we store the unwrapped data in request.state
256
+ # Transparently unwrapping req.body is complex in BaseHTTPMiddleware,
257
+ # so we provide the unwrapped body in request.state.paynode_body
258
+
259
+ paynode_context = {"orderId": request_id}
260
+
261
+ # Simple body check (may consume stream, so we use request.state to cache if needed)
262
+ # For now, we mirror JS context enrichment
263
+ try:
264
+ # We assume the Market Proxy always sends JSON
265
+ body = await request.json()
266
+ if isinstance(body, dict) and body.get("payload"):
267
+ metadata = {k: v for k, v in body.items() if k != "payload"}
268
+
269
+ request.state.paynode = {
270
+ "orderId": request_id,
271
+ "txHash": headers.get("X-PayNode-Transaction-Hash") or body.get("tx_hash"),
272
+ "amount": headers.get("X-PayNode-Amount") or body.get("amount"),
273
+ "network": headers.get("X-PayNode-Network") or body.get("network"),
274
+ "chainId": headers.get("X-PayNode-Chain-Id") or (str(body.get("chain_id")) if body.get("chain_id") else None),
275
+ "proxyMetadata": metadata
276
+ }
277
+ # Store the unwrapped body for the handler
278
+ request.state.paynode_body = body.get("payload")
279
+
280
+ # NOTE: To truly "unwrap" req.body for downstream handlers (so request.json() works),
281
+ # we would need to override the request.receive channel.
282
+ # For this SDK, we recommend handlers check request.state.paynode_body if accessed via Proxy.
283
+ else:
284
+ request.state.paynode = {"orderId": request_id}
285
+ request.state.paynode_body = body
286
+ except Exception:
287
+ request.state.paynode = {"orderId": request_id}
288
+ request.state.paynode_body = None
289
+
290
+ return await call_next(request)
291
+
292
+ # 2. Scene C: Direct Agent Call (Rejected)
293
+ # PayNodeMerchant requires Market Proxy for verification.
294
+ return JSONResponse(
295
+ status_code=403,
296
+ content={
297
+ "error": "forbidden",
298
+ "message": "PayNode Market Auth required. This API must be accessed via PayNode Market Proxy for verification."
299
+ }
300
+ )
301
+
214
302
  def x402_gate(
215
303
  merchant_address: str,
216
304
  price: str,
File without changes
@@ -0,0 +1,64 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ from typing import Optional, Dict, Any
5
+ from ..constants import SDK_VERSION
6
+
7
+ logger = logging.getLogger("paynode_sdk.payload")
8
+
9
+ class PayNodePayloadHelper:
10
+ """
11
+ Normalizes raw payment payloads into the Unified format.
12
+ Mirrors JS X402PayloadHelper.
13
+ """
14
+
15
+ @staticmethod
16
+ def normalize(auth_header: str, fallback_order_id: str = "") -> Dict[str, Any]:
17
+ """
18
+ Normalizes a raw payment payload (from PAYMENT-SIGNATURE or X-402-Payload headers).
19
+ """
20
+ try:
21
+ decoded = base64.b64decode(auth_header).decode('utf-8')
22
+ parsed = json.loads(decoded)
23
+
24
+ # 1. Handle Official X402 V2 Standard Format
25
+ if parsed.get('x402Version') == 2 and parsed.get('accepted'):
26
+ inferred_type = "onchain"
27
+ payload_content = parsed.get('payload', {})
28
+
29
+ if payload_content.get('signature') or payload_content.get('authorization'):
30
+ inferred_type = "eip3009"
31
+ elif payload_content.get('txHash'):
32
+ inferred_type = "onchain"
33
+
34
+ return {
35
+ "x402Version": 2,
36
+ "type": parsed.get('_paynode', {}).get('type') or inferred_type,
37
+ "orderId": parsed.get('_paynode', {}).get('orderId') or fallback_order_id,
38
+ "router": parsed.get('accepted', {}).get('router') or parsed.get('router'),
39
+ "payload": parsed.get('payload'),
40
+ "_paynode": {
41
+ "sdkVersion": SDK_VERSION
42
+ }
43
+ }
44
+
45
+ # 2. Handle Legacy or already Unified Format
46
+ version = parsed.get('version')
47
+ if isinstance(version, str) and (version.startswith("2.2") or version.startswith("2.3") or version.startswith("2.4")):
48
+ return {
49
+ "x402Version": parsed.get('x402Version', 2),
50
+ "type": parsed.get('type'),
51
+ "orderId": parsed.get('orderId') or parsed.get('order_id') or fallback_order_id,
52
+ "router": parsed.get('router'),
53
+ "payload": parsed.get('payload'),
54
+ "_paynode": parsed.get('_paynode') or {
55
+ "sdkVersion": version
56
+ }
57
+ }
58
+
59
+ # 3. Fallback
60
+ return parsed
61
+
62
+ except Exception as e:
63
+ logger.error(f"Failed to normalize PayNode payload: {e}")
64
+ raise ValueError(f"Failed to normalize PayNode payload: {e}")
@@ -0,0 +1,67 @@
1
+ import hmac
2
+ import hashlib
3
+ import time
4
+ from datetime import datetime
5
+ import logging
6
+
7
+ logger = logging.getLogger("paynode_sdk.signature")
8
+
9
+ def verify_market_signature(
10
+ signature: str,
11
+ order_id: str,
12
+ timestamp: str,
13
+ shared_secret: str,
14
+ now: float = None,
15
+ drift_window: int = 300 # 5 minutes in seconds
16
+ ) -> bool:
17
+ """
18
+ Verifies the HMAC-SHA256 signature from PayNode Market Proxy.
19
+
20
+ Args:
21
+ signature: Received hex signature
22
+ order_id: The order ID or request ID
23
+ timestamp: ISO string or millisecond timestamp
24
+ shared_secret: Merchant-specific shared secret
25
+ now: Current time in seconds (for testing)
26
+ drift_window: Allowed drift in seconds
27
+
28
+ Returns:
29
+ bool: True if signature is valid and within drift window
30
+ """
31
+ if not all([signature, order_id, timestamp, shared_secret]):
32
+ return False
33
+
34
+ try:
35
+ # 1. Parse timestamp
36
+ check_time = now or time.time()
37
+
38
+ try:
39
+ # Try ISO format (matching JS new Date(timestamp))
40
+ ts_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
41
+ ts_seconds = ts_dt.timestamp()
42
+ except ValueError:
43
+ # Try milliseconds (JS numeric timestamp)
44
+ try:
45
+ ts_seconds = int(timestamp) / 1000.0
46
+ except ValueError:
47
+ return False
48
+
49
+ # 2. Check for timestamp drift
50
+ drift = abs(check_time - ts_seconds)
51
+ if drift > drift_window:
52
+ logger.warning(f"[PayNode-SDK] Signature timestamp drift too high: {int(drift * 1000)}ms")
53
+ return False
54
+
55
+ # 3. Calculate expected signature
56
+ # Formula: orderId + ":" + timestamp
57
+ msg = f"{order_id}:{timestamp}".encode("utf-8")
58
+ key = shared_secret.encode("utf-8")
59
+
60
+ expected_sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
61
+
62
+ # 4. Constant-time comparison
63
+ return hmac.compare_digest(signature.lower(), expected_sig.lower())
64
+
65
+ except Exception as e:
66
+ logger.error(f"[PayNode-SDK] Error verifying market signature: {e}")
67
+ return False
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import time
3
3
  import logging
4
+ import hmac
4
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
5
6
  from .errors import ErrorCode, PayNodeException
6
7
  from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
@@ -47,7 +48,13 @@ class PayNodeVerifier:
47
48
  token_list = ACCEPTED_TOKENS.get(self.chain_id)
48
49
  else:
49
50
  token_list = None
50
- self.accepted_tokens = set(t.lower() for t in token_list) if token_list else None
51
+
52
+ if not token_list:
53
+ raise PayNodeException(
54
+ ErrorCode.internal_error,
55
+ message="Verifier requires either a valid chain_id or accepted_tokens to initialize its whitelist"
56
+ )
57
+ self.accepted_tokens = set(t.lower() for t in token_list)
51
58
 
52
59
  async def verify(self, unified_payload: dict, expected: dict, extra: dict = None) -> dict:
53
60
  """
@@ -61,7 +68,7 @@ class PayNodeVerifier:
61
68
  return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
62
69
 
63
70
  # 2. Security: Token Whitelist Check
64
- if self.accepted_tokens and expected.get("tokenAddress", "").lower() not in self.accepted_tokens:
71
+ if expected.get("tokenAddress", "").lower() not in self.accepted_tokens:
65
72
  return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message=f"Token {expected.get('tokenAddress')} not allowed")}
66
73
 
67
74
  payload_type = unified_payload.get("type")
@@ -140,10 +147,10 @@ class PayNodeVerifier:
140
147
  order_id_mismatch_found = False
141
148
  for log in processed_logs:
142
149
  args = log.args
143
- is_merchant_match = args.get("merchant", "").lower() == merchant
144
- is_token_match = args.get("token", "").lower() == token
150
+ is_merchant_match = hmac.compare_digest(args.get("merchant", "").lower(), merchant)
151
+ is_token_match = hmac.compare_digest(args.get("token", "").lower(), token)
145
152
  is_amount_match = args.get("amount", 0) >= amount
146
- is_order_match = args.get("orderId") == order_id_bytes
153
+ is_order_match = hmac.compare_digest(args.get("orderId"), order_id_bytes)
147
154
 
148
155
  if is_merchant_match and is_token_match and is_amount_match:
149
156
  if is_order_match:
@@ -185,7 +192,7 @@ class PayNodeVerifier:
185
192
  auth = payload["authorization"]
186
193
 
187
194
  # 1. Basic validation
188
- if auth["to"].lower() != expected["to"].lower():
195
+ if not hmac.compare_digest(auth["to"].lower(), expected["to"].lower()):
189
196
  return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Recipient mismatch")}
190
197
 
191
198
  payload_value = int(auth["value"])
@@ -246,7 +253,7 @@ class PayNodeVerifier:
246
253
  signable_msg = encode_typed_data(full_message=structured_data)
247
254
  recovered_address = Account.recover_message(signable_msg, signature=signature)
248
255
 
249
- if recovered_address.lower() != auth["from"].lower():
256
+ if not hmac.compare_digest(recovered_address.lower(), auth["from"].lower()):
250
257
  return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Invalid signature")}
251
258
 
252
259
  # 4. Idempotency (Nonce local check)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paynode-sdk-python
3
- Version: 2.2.1
3
+ Version: 2.5.0
4
4
  Summary: PayNode Protocol Python SDK for AI Agents
5
5
  Author-email: PayNodeLabs <contact@paynode.dev>
6
6
  License: MIT
@@ -6,9 +6,13 @@ paynode_sdk/client.py
6
6
  paynode_sdk/constants.py
7
7
  paynode_sdk/errors.py
8
8
  paynode_sdk/idempotency.py
9
+ paynode_sdk/merchant.py
9
10
  paynode_sdk/middleware.py
10
11
  paynode_sdk/verifier.py
11
12
  paynode_sdk/webhook.py
13
+ paynode_sdk/utils/__init__.py
14
+ paynode_sdk/utils/payload.py
15
+ paynode_sdk/utils/signature.py
12
16
  paynode_sdk_python.egg-info/PKG-INFO
13
17
  paynode_sdk_python.egg-info/SOURCES.txt
14
18
  paynode_sdk_python.egg-info/dependency_links.txt
@@ -17,4 +21,5 @@ paynode_sdk_python.egg-info/top_level.txt
17
21
  tests/test_client.py
18
22
  tests/test_concurrency.py
19
23
  tests/test_internals.py
24
+ tests/test_v2_5_alignment.py
20
25
  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.2.1"
7
+ version = "2.5.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" }]
@@ -0,0 +1,189 @@
1
+ import unittest
2
+ import json
3
+ import base64
4
+ import hmac
5
+ import hashlib
6
+ from datetime import datetime, timezone, timedelta
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.testclient import TestClient
9
+ from paynode_sdk import (
10
+ PayNodeAgentClient,
11
+ PayNodeMiddleware,
12
+ PayNodeMerchant,
13
+ PayNodeMerchantMiddleware,
14
+ PayNodePayloadHelper,
15
+ SDK_VERSION
16
+ )
17
+
18
+ class TestSDKAlignment(unittest.IsolatedAsyncioTestCase):
19
+ def setUp(self):
20
+ self.merchant_addr = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
21
+ self.shared_secret = "test_secret_123"
22
+ self.api_key = "test_key"
23
+
24
+ def test_version_alignment(self):
25
+ """Ensure SDK_VERSION is 2.5.0 and globally consistent."""
26
+ self.assertEqual(SDK_VERSION, "2.5.0")
27
+
28
+ def test_payload_normalization(self):
29
+ """Test PayNodePayloadHelper.normalize against X402 V2 standard format."""
30
+ v2_payload = {
31
+ "x402Version": 2,
32
+ "accepted": {"scheme": "exact", "type": "onchain", "router": "0x123"},
33
+ "payload": {"txHash": "0xabc"},
34
+ "_paynode": {
35
+ "sdkVersion": "2.5.0",
36
+ "type": "onchain",
37
+ "orderId": "req_123"
38
+ }
39
+ }
40
+ b64_payload = base64.b64encode(json.dumps(v2_payload).encode()).decode()
41
+
42
+ normalized = PayNodePayloadHelper.normalize(b64_payload, fallback_order_id="fallback")
43
+
44
+ self.assertEqual(normalized["orderId"], "req_123")
45
+ self.assertEqual(normalized["type"], "onchain")
46
+ self.assertEqual(normalized["_paynode"]["sdkVersion"], "2.5.0")
47
+
48
+ async def test_402_challenge_format(self):
49
+ """Verify middleware includes orderId in JSON response body (v2.5.0 feature)."""
50
+ app = FastAPI()
51
+ app.add_middleware(
52
+ PayNodeMiddleware,
53
+ merchant_address=self.merchant_addr,
54
+ price="0.01"
55
+ )
56
+
57
+ @app.get("/premium")
58
+ async def premium():
59
+ return {"data": "secret"}
60
+
61
+ client = TestClient(app)
62
+ response = client.get("/premium")
63
+
64
+ self.assertEqual(response.status_code, 402)
65
+ body = response.json()
66
+ self.assertEqual(body["x402Version"], 2)
67
+ self.assertIn("orderId", body)
68
+ self.assertTrue(body["orderId"].startswith("agent_py_"))
69
+
70
+ async def test_market_proxy_hmac(self):
71
+ """Verify PayNodeMerchant HMAC verification (aligned with JS)."""
72
+ merchant = PayNodeMerchant(shared_secret=self.shared_secret)
73
+ import time
74
+ order_id = "test_order_999"
75
+ timestamp = str(int(time.time() * 1000))
76
+ msg = f"{order_id}:{timestamp}".encode("utf-8")
77
+ expected_sig = hmac.new(self.shared_secret.encode(), msg, hashlib.sha256).hexdigest()
78
+
79
+ # Use TestClient for consistent header behavior
80
+ app = FastAPI()
81
+ @app.get("/verify")
82
+ async def verify_endpoint(request: Request):
83
+ return await merchant.verify(request)
84
+
85
+ client = TestClient(app)
86
+
87
+ headers = {
88
+ "X-PayNode-Signature": expected_sig,
89
+ "X-PayNode-Request-Id": order_id,
90
+ "X-PayNode-Timestamp": timestamp
91
+ }
92
+
93
+ response = client.get("/verify", headers=headers)
94
+ self.assertEqual(response.status_code, 200)
95
+ result = response.json()
96
+ self.assertTrue(result["isValid"], result.get("error"))
97
+
98
+ # Verify invalid
99
+ response_invalid = client.get("/verify", headers={"X-PayNode-Signature": "wrong"})
100
+ self.assertFalse(response_invalid.json()["isValid"])
101
+
102
+ # Verify drift
103
+ old_ts = (datetime.now(timezone.utc) - timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
104
+ headers_old = {**headers, "X-PayNode-Timestamp": old_ts}
105
+ response_old = client.get("/verify", headers=headers_old)
106
+ self.assertFalse(response_old.json()["isValid"])
107
+
108
+ async def test_merchant_middleware_discovery(self):
109
+ """Test PayNodeMerchantMiddleware discovery probe."""
110
+ merchant = PayNodeMerchant(shared_secret=self.shared_secret)
111
+ app = FastAPI()
112
+ manifest = {"name": "Test Tool", "price": "0.1"}
113
+ app.add_middleware(
114
+ PayNodeMerchantMiddleware,
115
+ merchant=merchant,
116
+ merchant_address=self.merchant_addr,
117
+ price="0.1",
118
+ manifest=manifest
119
+ )
120
+
121
+ @app.post("/tool")
122
+ async def tool():
123
+ return {"ok": True}
124
+
125
+ client = TestClient(app)
126
+
127
+ # Signed Discovery Probe
128
+ order_id = "probe_1"
129
+ import time
130
+ timestamp = str(int(time.time() * 1000))
131
+ msg = f"{order_id}:{timestamp}".encode("utf-8")
132
+ sig = hmac.new(self.shared_secret.encode(), msg, hashlib.sha256).hexdigest()
133
+
134
+ headers = {
135
+ "X-PayNode-Signature": sig,
136
+ "X-PayNode-Timestamp": timestamp,
137
+ "X-PayNode-Request-Id": order_id,
138
+ "X-PayNode-Discovery": "true"
139
+ }
140
+
141
+ response = client.post("/tool", headers=headers)
142
+ self.assertEqual(response.status_code, 200)
143
+ data = response.json()
144
+ self.assertEqual(data["status"], "DISCOVERED")
145
+ self.assertEqual(data["manifest"]["name"], "Test Tool")
146
+
147
+ async def test_merchant_middleware_unwrap(self):
148
+ """Test PayNodeMerchantMiddleware body unwrapping."""
149
+ merchant = PayNodeMerchant(shared_secret=self.shared_secret)
150
+ app = FastAPI()
151
+ app.add_middleware(
152
+ PayNodeMerchantMiddleware,
153
+ merchant=merchant,
154
+ merchant_address=self.merchant_addr,
155
+ price="0.1"
156
+ )
157
+
158
+ from fastapi import Request
159
+ @app.post("/tool")
160
+ async def tool(request: Request):
161
+ # Access unwrapped body from state
162
+ return {"echo": request.state.paynode_body}
163
+
164
+ client = TestClient(app)
165
+
166
+ order_id = "req_unwrap"
167
+ import time
168
+ timestamp = str(int(time.time() * 1000))
169
+ sig = hmac.new(self.shared_secret.encode(), f"{order_id}:{timestamp}".encode(), hashlib.sha256).hexdigest()
170
+
171
+ # Wrapped payload
172
+ wrapped_body = {
173
+ "payload": {"query": "hello world"},
174
+ "tx_hash": "0x123",
175
+ "amount": "100000"
176
+ }
177
+
178
+ headers = {
179
+ "X-PayNode-Signature": sig,
180
+ "X-PayNode-Timestamp": timestamp,
181
+ "X-PayNode-Request-Id": order_id
182
+ }
183
+
184
+ response = client.post("/tool", headers=headers, json=wrapped_body)
185
+ self.assertEqual(response.status_code, 200)
186
+ self.assertEqual(response.json()["echo"]["query"], "hello world")
187
+
188
+ if __name__ == "__main__":
189
+ unittest.main()