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.
- {paynode_sdk_python-2.2.1/paynode_sdk_python.egg-info → paynode_sdk_python-2.5.0}/PKG-INFO +1 -1
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/__init__.py +10 -4
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/client.py +18 -6
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/constants.py +3 -1
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/errors.py +1 -1
- paynode_sdk_python-2.5.0/paynode_sdk/merchant.py +137 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/middleware.py +122 -34
- paynode_sdk_python-2.5.0/paynode_sdk/utils/__init__.py +0 -0
- paynode_sdk_python-2.5.0/paynode_sdk/utils/payload.py +64 -0
- paynode_sdk_python-2.5.0/paynode_sdk/utils/signature.py +67 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/verifier.py +14 -7
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0/paynode_sdk_python.egg-info}/PKG-INFO +1 -1
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/SOURCES.txt +5 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/pyproject.toml +1 -1
- paynode_sdk_python-2.5.0/tests/test_v2_5_alignment.py +189 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/LICENSE +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/README.md +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/idempotency.py +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk/webhook.py +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/setup.cfg +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_client.py +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_concurrency.py +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_internals.py +0 -0
- {paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/tests/test_verifier_logic.py +0 -0
|
@@ -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", "
|
|
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.
|
|
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
|
-
"
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
144
|
-
is_token_match = args.get("token", "").lower()
|
|
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")
|
|
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()
|
|
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()
|
|
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)
|
{paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{paynode_sdk_python-2.2.1 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|