paynode-sdk-python 2.2.0__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.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.5.0}/PKG-INFO +3 -3
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/README.md +2 -2
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk/__init__.py +10 -4
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk/client.py +33 -9
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk/constants.py +3 -1
- {paynode_sdk_python-2.2.0 → 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.0 → 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.0 → paynode_sdk_python-2.5.0}/paynode_sdk/verifier.py +14 -7
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0/paynode_sdk_python.egg-info}/PKG-INFO +3 -3
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/SOURCES.txt +5 -0
- {paynode_sdk_python-2.2.0 → 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.0 → paynode_sdk_python-2.5.0}/LICENSE +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk/idempotency.py +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk/webhook.py +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/setup.cfg +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/tests/test_client.py +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/tests/test_concurrency.py +0 -0
- {paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/tests/test_internals.py +0 -0
- {paynode_sdk_python-2.2.0 → 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.
|
|
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
|
|
@@ -25,7 +25,7 @@ Dynamic: license-file
|
|
|
25
25
|
[](https://docs.paynode.dev)
|
|
26
26
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
27
27
|
|
|
28
|
-
The official Python SDK for the **PayNode Protocol (v2.2.
|
|
28
|
+
The official Python SDK for the **PayNode Protocol (v2.2.1)**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
29
29
|
|
|
30
30
|
## 📖 Read the Docs
|
|
31
31
|
|
|
@@ -56,7 +56,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
56
56
|
print(response.json())
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
### Key Features (v2.2.
|
|
59
|
+
### Key Features (v2.2.1)
|
|
60
60
|
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
61
61
|
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
62
62
|
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://docs.paynode.dev)
|
|
4
4
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
5
5
|
|
|
6
|
-
The official Python SDK for the **PayNode Protocol (v2.2.
|
|
6
|
+
The official Python SDK for the **PayNode Protocol (v2.2.1)**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
7
7
|
|
|
8
8
|
## 📖 Read the Docs
|
|
9
9
|
|
|
@@ -34,7 +34,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
34
34
|
print(response.json())
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
### Key Features (v2.2.
|
|
37
|
+
### Key Features (v2.2.1)
|
|
38
38
|
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
39
39
|
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
40
40
|
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
|
@@ -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):
|
|
@@ -111,12 +117,19 @@ class PayNodeAgentClient:
|
|
|
111
117
|
except Exception as e:
|
|
112
118
|
logger.debug(f"⚠️ [PayNode-PY] Failed to parse 402 JSON body: {e}")
|
|
113
119
|
|
|
114
|
-
|
|
120
|
+
header_body = None
|
|
121
|
+
if b64_required:
|
|
115
122
|
try:
|
|
116
|
-
|
|
123
|
+
header_body = json.loads(base64.b64decode(b64_required).decode())
|
|
117
124
|
except Exception as e:
|
|
118
125
|
logger.warning(f"❌ [PayNode-PY] Failed to decode PAYMENT-REQUIRED header: {e}")
|
|
119
126
|
|
|
127
|
+
if not body and header_body:
|
|
128
|
+
body = header_body
|
|
129
|
+
elif body and header_body and not body.get('x402Version'):
|
|
130
|
+
# Robustness: Merge header info into body if body is missing critical bits
|
|
131
|
+
body.update({k: v for k, v in header_body.items() if k not in body})
|
|
132
|
+
|
|
120
133
|
if body and body.get('x402Version') == 2:
|
|
121
134
|
logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
|
|
122
135
|
if order_id and not body.get('orderId'): body['orderId'] = order_id
|
|
@@ -152,6 +165,7 @@ class PayNodeAgentClient:
|
|
|
152
165
|
raise PayNodeException(ErrorCode.token_not_accepted, message=f"Token {requirement['asset']} is not in the whitelist for chain {chain_id}")
|
|
153
166
|
|
|
154
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')}")
|
|
155
169
|
|
|
156
170
|
# Dust limit check
|
|
157
171
|
if int(requirement['amount']) < MIN_PAYMENT_AMOUNT:
|
|
@@ -208,6 +222,7 @@ class PayNodeAgentClient:
|
|
|
208
222
|
"resource": requirements.get('resource'),
|
|
209
223
|
"accepted": {
|
|
210
224
|
"scheme": requirement.get('scheme'),
|
|
225
|
+
"type": ptype,
|
|
211
226
|
"network": requirement.get('network'),
|
|
212
227
|
"amount": requirement.get('amount'),
|
|
213
228
|
"asset": requirement.get('asset'),
|
|
@@ -217,7 +232,7 @@ class PayNodeAgentClient:
|
|
|
217
232
|
},
|
|
218
233
|
"payload": payload_data,
|
|
219
234
|
"_paynode": {
|
|
220
|
-
"
|
|
235
|
+
"sdkVersion": SDK_VERSION,
|
|
221
236
|
"type": ptype,
|
|
222
237
|
"orderId": order_id
|
|
223
238
|
}
|
|
@@ -231,12 +246,16 @@ class PayNodeAgentClient:
|
|
|
231
246
|
|
|
232
247
|
b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
|
|
233
248
|
|
|
249
|
+
chain_id = self.w3.eth.chain_id
|
|
250
|
+
paynode_network = 'mainnet' if chain_id == 8453 else 'testnet'
|
|
251
|
+
|
|
234
252
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
235
253
|
retry_headers.update({
|
|
236
254
|
'Content-Type': 'application/json',
|
|
237
255
|
'PAYMENT-SIGNATURE': b64_payload,
|
|
238
256
|
'X-402-Payload': b64_payload, # Backward compatibility
|
|
239
|
-
'X-402-Order-Id': order_id
|
|
257
|
+
'X-402-Order-Id': order_id,
|
|
258
|
+
'X-PayNode-Network': paynode_network
|
|
240
259
|
})
|
|
241
260
|
kwargs['headers'] = retry_headers
|
|
242
261
|
return kwargs
|
|
@@ -248,7 +267,12 @@ class PayNodeAgentClient:
|
|
|
248
267
|
settle_header = response.headers.get('PAYMENT-RESPONSE') or response.headers.get('X-PAYMENT-RESPONSE')
|
|
249
268
|
if settle_header:
|
|
250
269
|
try:
|
|
251
|
-
|
|
270
|
+
settle_header_str = settle_header.strip()
|
|
271
|
+
if settle_header_str.startswith('{'):
|
|
272
|
+
decoded = settle_header_str
|
|
273
|
+
else:
|
|
274
|
+
decoded = base64.b64decode(settle_header).decode()
|
|
275
|
+
settle_data = json.loads(decoded)
|
|
252
276
|
if settle_data.get('success'):
|
|
253
277
|
logger.info(f"✅ [PayNode-PY] Settlement confirmed: {settle_data.get('transaction')}")
|
|
254
278
|
else:
|
|
@@ -370,7 +394,7 @@ class PayNodeAgentClient:
|
|
|
370
394
|
sig = self.sign_permit(token_addr, router_addr, amount, version=version)
|
|
371
395
|
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
|
|
372
396
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
373
|
-
current_gas_price =
|
|
397
|
+
current_gas_price = self.w3.eth.gas_price * 120 // 100
|
|
374
398
|
with self.nonce_lock:
|
|
375
399
|
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
376
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})
|
|
@@ -385,7 +409,7 @@ class PayNodeAgentClient:
|
|
|
385
409
|
def __pay_raw(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
386
410
|
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=PAYNODE_ROUTER_ABI)
|
|
387
411
|
order_id_bytes = self.w3.keccak(text=order_id)
|
|
388
|
-
current_gas_price =
|
|
412
|
+
current_gas_price = self.w3.eth.gas_price * 120 // 100
|
|
389
413
|
with self.nonce_lock:
|
|
390
414
|
nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
|
|
391
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.0",
|
|
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 parsed.get('version') == "2.2.0":
|
|
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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paynode-sdk-python
|
|
3
|
-
Version: 2.
|
|
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
|
|
@@ -25,7 +25,7 @@ Dynamic: license-file
|
|
|
25
25
|
[](https://docs.paynode.dev)
|
|
26
26
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
27
27
|
|
|
28
|
-
The official Python SDK for the **PayNode Protocol (v2.2.
|
|
28
|
+
The official Python SDK for the **PayNode Protocol (v2.2.1)**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
29
29
|
|
|
30
30
|
## 📖 Read the Docs
|
|
31
31
|
|
|
@@ -56,7 +56,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
56
56
|
print(response.json())
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
### Key Features (v2.2.
|
|
59
|
+
### Key Features (v2.2.1)
|
|
60
60
|
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
61
61
|
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
62
62
|
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
{paynode_sdk_python-2.2.0 → 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
|
{paynode_sdk_python-2.2.0 → paynode_sdk_python-2.5.0}/paynode_sdk_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{paynode_sdk_python-2.2.0 → 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
|