paynode-sdk-python 2.1.0__tar.gz → 2.2.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.1.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.2.0}/PKG-INFO +5 -3
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/README.md +2 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/client.py +49 -16
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/constants.py +2 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/errors.py +1 -1
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/middleware.py +68 -6
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/verifier.py +4 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0/paynode_sdk_python.egg-info}/PKG-INFO +5 -3
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/requires.txt +3 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/pyproject.toml +4 -1
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_client.py +52 -11
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/LICENSE +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/__init__.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/idempotency.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/webhook.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/SOURCES.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/setup.cfg +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_concurrency.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_internals.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.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.2.0
|
|
4
4
|
Summary: PayNode Protocol Python SDK for AI Agents
|
|
5
5
|
Author-email: PayNodeLabs <contact@paynode.dev>
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,8 @@ Provides-Extra: test
|
|
|
16
16
|
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
17
17
|
Requires-Dist: responses>=0.23.0; extra == "test"
|
|
18
18
|
Requires-Dist: pytest-mock>=3.10.0; extra == "test"
|
|
19
|
+
Provides-Extra: webhook
|
|
20
|
+
Requires-Dist: aiohttp>=3.9.0; extra == "webhook"
|
|
19
21
|
Dynamic: license-file
|
|
20
22
|
|
|
21
23
|
# PayNode Python SDK
|
|
@@ -23,7 +25,7 @@ Dynamic: license-file
|
|
|
23
25
|
[](https://docs.paynode.dev)
|
|
24
26
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
25
27
|
|
|
26
|
-
The official Python SDK for the **PayNode Protocol (
|
|
28
|
+
The official Python SDK for the **PayNode Protocol (v2.2.0)**. 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).
|
|
27
29
|
|
|
28
30
|
## 📖 Read the Docs
|
|
29
31
|
|
|
@@ -54,7 +56,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
54
56
|
print(response.json())
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
### Key Features (v2.
|
|
59
|
+
### Key Features (v2.2.0)
|
|
58
60
|
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
59
61
|
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
60
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 (
|
|
6
|
+
The official Python SDK for the **PayNode Protocol (v2.2.0)**. 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.
|
|
37
|
+
### Key Features (v2.2.0)
|
|
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.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import base64
|
|
1
3
|
import time
|
|
2
4
|
import logging
|
|
3
5
|
import threading
|
|
@@ -15,7 +17,7 @@ logger = logging.getLogger("paynode_sdk.client")
|
|
|
15
17
|
|
|
16
18
|
class PayNodeAgentClient:
|
|
17
19
|
"""
|
|
18
|
-
The main PayNode Client for AI Agents (
|
|
20
|
+
The main PayNode Client for AI Agents (v2.2.0).
|
|
19
21
|
Automatically handles the x402 'Payment Required' handshake.
|
|
20
22
|
Supports RPC redundancy, EIP-2612 Permit, and EIP-3009 Authorization.
|
|
21
23
|
"""
|
|
@@ -93,13 +95,13 @@ class PayNodeAgentClient:
|
|
|
93
95
|
def _request_with_402_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
|
94
96
|
response = None
|
|
95
97
|
for attempt in range(max_retries):
|
|
96
|
-
response = self.
|
|
98
|
+
response = self._request_with_settlement_check(method, url, **kwargs)
|
|
97
99
|
if response.status_code == 402:
|
|
98
100
|
logger.info(f"💡 [PayNode-PY] 402 Detected (Attempt {attempt+1}/{max_retries}). Analyzing protocol version...")
|
|
99
101
|
|
|
100
|
-
# Check for x402 v2 (JSON body or
|
|
102
|
+
# Check for x402 v2 (JSON body or official PAYMENT-REQUIRED header)
|
|
101
103
|
content_type = response.headers.get('Content-Type', '')
|
|
102
|
-
b64_required = response.headers.get('X-402-Required')
|
|
104
|
+
b64_required = response.headers.get('PAYMENT-REQUIRED') or response.headers.get('X-402-Required')
|
|
103
105
|
order_id = response.headers.get('X-402-Order-Id')
|
|
104
106
|
|
|
105
107
|
body = None
|
|
@@ -111,15 +113,13 @@ class PayNodeAgentClient:
|
|
|
111
113
|
|
|
112
114
|
if not body and b64_required:
|
|
113
115
|
try:
|
|
114
|
-
import base64
|
|
115
|
-
import json
|
|
116
116
|
body = json.loads(base64.b64decode(b64_required).decode())
|
|
117
117
|
except Exception as e:
|
|
118
|
-
logger.warning(f"❌ [PayNode-PY] Failed to decode
|
|
118
|
+
logger.warning(f"❌ [PayNode-PY] Failed to decode PAYMENT-REQUIRED header: {e}")
|
|
119
119
|
|
|
120
120
|
if body and body.get('x402Version') == 2:
|
|
121
121
|
logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
|
|
122
|
-
if order_id: body['orderId'] = order_id
|
|
122
|
+
if order_id and not body.get('orderId'): body['orderId'] = order_id
|
|
123
123
|
kwargs = self._handle_x402_v2(url, body, **kwargs)
|
|
124
124
|
continue
|
|
125
125
|
|
|
@@ -202,29 +202,62 @@ class PayNodeAgentClient:
|
|
|
202
202
|
|
|
203
203
|
payload_data = {"txHash": tx_hash}
|
|
204
204
|
|
|
205
|
-
#
|
|
205
|
+
# Official X402 V2 Payload with PayNode extensions
|
|
206
206
|
payment_payload = {
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
|
|
207
|
+
"x402Version": 2,
|
|
208
|
+
"resource": requirements.get('resource'),
|
|
209
|
+
"accepted": {
|
|
210
|
+
"scheme": requirement.get('scheme'),
|
|
211
|
+
"network": requirement.get('network'),
|
|
212
|
+
"amount": requirement.get('amount'),
|
|
213
|
+
"asset": requirement.get('asset'),
|
|
214
|
+
"payTo": requirement.get('payTo'),
|
|
215
|
+
"maxTimeoutSeconds": requirement.get('maxTimeoutSeconds'),
|
|
216
|
+
"extra": requirement.get('extra', {})
|
|
217
|
+
},
|
|
218
|
+
"payload": payload_data,
|
|
219
|
+
"_paynode": {
|
|
220
|
+
"version": "2.2.0",
|
|
221
|
+
"type": ptype,
|
|
222
|
+
"orderId": order_id
|
|
223
|
+
}
|
|
211
224
|
}
|
|
225
|
+
|
|
226
|
+
# Add router for onchain
|
|
227
|
+
if ptype == 'onchain':
|
|
228
|
+
payment_payload["accepted"]["router"] = requirement.get('router')
|
|
212
229
|
|
|
213
230
|
logger.info(f"✅ [PayNode-PY] {ptype} payment prepared. Retrying request...")
|
|
214
231
|
|
|
215
|
-
import json
|
|
216
|
-
import base64
|
|
217
232
|
b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
|
|
218
233
|
|
|
219
234
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
220
235
|
retry_headers.update({
|
|
221
236
|
'Content-Type': 'application/json',
|
|
222
|
-
'
|
|
237
|
+
'PAYMENT-SIGNATURE': b64_payload,
|
|
238
|
+
'X-402-Payload': b64_payload, # Backward compatibility
|
|
223
239
|
'X-402-Order-Id': order_id
|
|
224
240
|
})
|
|
225
241
|
kwargs['headers'] = retry_headers
|
|
226
242
|
return kwargs
|
|
227
243
|
|
|
244
|
+
def _request_with_settlement_check(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
245
|
+
"""Wrapper to check for PAYMENT-RESPONSE after retry."""
|
|
246
|
+
response = self.session.request(method, url, **kwargs)
|
|
247
|
+
|
|
248
|
+
settle_header = response.headers.get('PAYMENT-RESPONSE') or response.headers.get('X-PAYMENT-RESPONSE')
|
|
249
|
+
if settle_header:
|
|
250
|
+
try:
|
|
251
|
+
settle_data = json.loads(base64.b64decode(settle_header).decode())
|
|
252
|
+
if settle_data.get('success'):
|
|
253
|
+
logger.info(f"✅ [PayNode-PY] Settlement confirmed: {settle_data.get('transaction')}")
|
|
254
|
+
else:
|
|
255
|
+
logger.warning(f"⚠️ [PayNode-PY] Settlement failed: {settle_data.get('errorReason', 'Unknown error')}")
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.warning(f"⚠️ [PayNode-PY] Failed to parse settlement response: {e}")
|
|
258
|
+
|
|
259
|
+
return response
|
|
260
|
+
|
|
228
261
|
def sign_transfer_with_authorization(self, token_addr, to, amount, valid_after, valid_before, nonce, extra=None):
|
|
229
262
|
extra = extra or {}
|
|
230
263
|
token_addr = Web3.to_checksum_address(token_addr)
|
|
@@ -9,8 +9,8 @@ PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
|
|
|
9
9
|
PROTOCOL_FEE_BPS = 100
|
|
10
10
|
MIN_PAYMENT_AMOUNT = 1000
|
|
11
11
|
|
|
12
|
-
BASE_RPC_URLS = [
|
|
13
|
-
BASE_RPC_URLS_SANDBOX = [
|
|
12
|
+
BASE_RPC_URLS = ['https://mainnet.base.org', 'https://base.meowrpc.com', 'https://1rpc.io/base']
|
|
13
|
+
BASE_RPC_URLS_SANDBOX = ['https://sepolia.base.org', 'https://base-sepolia-rpc.publicnode.com']
|
|
14
14
|
|
|
15
15
|
ACCEPTED_TOKENS = {
|
|
16
16
|
8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
|
|
@@ -28,7 +28,7 @@ ERROR_MESSAGES = {
|
|
|
28
28
|
ErrorCode.transaction_not_found: "Transaction not found on-chain.",
|
|
29
29
|
ErrorCode.wrong_contract: "Payment event was not emitted by the official PayNode contract.",
|
|
30
30
|
ErrorCode.order_mismatch: "OrderId in receipt does not match requested ID.",
|
|
31
|
-
ErrorCode.missing_receipt: "Please pay to PayNode contract and provide '
|
|
31
|
+
ErrorCode.missing_receipt: "Please pay to PayNode contract and provide 'PAYMENT-SIGNATURE' header.",
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
class PayNodeException(Exception):
|
|
@@ -59,19 +59,51 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
59
59
|
self.max_timeout_seconds = kwargs.get('max_timeout_seconds', 3600)
|
|
60
60
|
|
|
61
61
|
async def dispatch(self, request: Request, call_next):
|
|
62
|
-
v2_payload_header = request.headers.get('X-402-Payload')
|
|
62
|
+
v2_payload_header = request.headers.get('PAYMENT-SIGNATURE') or request.headers.get('X-402-Payload')
|
|
63
63
|
order_id = request.headers.get('X-402-Order-Id')
|
|
64
64
|
|
|
65
65
|
if not order_id:
|
|
66
|
-
order_id = self.generate_order_id(request)
|
|
66
|
+
order_id = (self.generate_order_id)(request)
|
|
67
67
|
|
|
68
68
|
# Handle x402 v2 Unified Payload
|
|
69
69
|
unified_payload = None
|
|
70
70
|
if v2_payload_header:
|
|
71
71
|
try:
|
|
72
|
-
|
|
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.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']
|
|
73
105
|
except Exception as e:
|
|
74
|
-
logger.error(f"❌ [PayNode-Middleware] Failed to decode
|
|
106
|
+
logger.error(f"❌ [PayNode-Middleware] Failed to decode payment payload header: {e}")
|
|
75
107
|
|
|
76
108
|
if unified_payload:
|
|
77
109
|
try:
|
|
@@ -91,14 +123,43 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
91
123
|
)
|
|
92
124
|
if result.get("isValid"):
|
|
93
125
|
request.state.paynode = {"unified_payload": unified_payload, "orderId": order_id}
|
|
94
|
-
|
|
126
|
+
|
|
127
|
+
# Construct settlement response header
|
|
128
|
+
settle_response = {
|
|
129
|
+
"success": True,
|
|
130
|
+
"transaction": unified_payload.get("payload", {}).get("txHash") or "",
|
|
131
|
+
"network": f"eip155:{self.chain_id}",
|
|
132
|
+
"payer": result.get("payer", "")
|
|
133
|
+
}
|
|
134
|
+
b64_settle = base64.b64encode(json.dumps(settle_response).encode()).decode()
|
|
135
|
+
|
|
136
|
+
response = await call_next(request)
|
|
137
|
+
response.headers["PAYMENT-RESPONSE"] = b64_settle
|
|
138
|
+
response.headers["X-PAYMENT-RESPONSE"] = b64_settle # Compatibility
|
|
139
|
+
return response
|
|
95
140
|
else:
|
|
96
141
|
err = result.get("error")
|
|
142
|
+
error_code = err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt
|
|
143
|
+
|
|
144
|
+
# Also include PAYMENT-RESPONSE header on failure for protocol symmetry
|
|
145
|
+
settle_fail = {
|
|
146
|
+
"success": False,
|
|
147
|
+
"errorReason": error_code,
|
|
148
|
+
"transaction": "",
|
|
149
|
+
"network": f"eip155:{self.chain_id}"
|
|
150
|
+
}
|
|
151
|
+
b64_settle_fail = base64.b64encode(json.dumps(settle_fail).encode()).decode()
|
|
152
|
+
|
|
153
|
+
headers = {
|
|
154
|
+
"PAYMENT-RESPONSE": b64_settle_fail,
|
|
155
|
+
"X-PAYMENT-RESPONSE": b64_settle_fail
|
|
156
|
+
}
|
|
97
157
|
return JSONResponse(
|
|
98
158
|
status_code=403,
|
|
159
|
+
headers=headers,
|
|
99
160
|
content={
|
|
100
161
|
"error": "Forbidden",
|
|
101
|
-
"code":
|
|
162
|
+
"code": error_code,
|
|
102
163
|
"message": str(err)
|
|
103
164
|
}
|
|
104
165
|
)
|
|
@@ -144,6 +205,7 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
144
205
|
b64_required = base64.b64encode(json.dumps(v2_response).encode()).decode()
|
|
145
206
|
|
|
146
207
|
headers = {
|
|
208
|
+
'PAYMENT-REQUIRED': b64_required,
|
|
147
209
|
'X-402-Required': b64_required,
|
|
148
210
|
'X-402-Order-Id': order_id,
|
|
149
211
|
}
|
|
@@ -136,6 +136,7 @@ class PayNodeVerifier:
|
|
|
136
136
|
order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
|
|
137
137
|
|
|
138
138
|
valid_log_found = False
|
|
139
|
+
found_payer = None
|
|
139
140
|
order_id_mismatch_found = False
|
|
140
141
|
for log in processed_logs:
|
|
141
142
|
args = log.args
|
|
@@ -147,6 +148,7 @@ class PayNodeVerifier:
|
|
|
147
148
|
if is_merchant_match and is_token_match and is_amount_match:
|
|
148
149
|
if is_order_match:
|
|
149
150
|
valid_log_found = True
|
|
151
|
+
found_payer = args.get("payer")
|
|
150
152
|
break
|
|
151
153
|
else:
|
|
152
154
|
order_id_mismatch_found = True
|
|
@@ -161,7 +163,7 @@ class PayNodeVerifier:
|
|
|
161
163
|
if not is_new:
|
|
162
164
|
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction)}
|
|
163
165
|
|
|
164
|
-
return {"isValid": True}
|
|
166
|
+
return {"isValid": True, "payer": found_payer}
|
|
165
167
|
|
|
166
168
|
async def verify_transfer_with_authorization(
|
|
167
169
|
self,
|
|
@@ -305,6 +307,6 @@ class PayNodeVerifier:
|
|
|
305
307
|
if self.store: await self.store.delete(nonce)
|
|
306
308
|
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already consumed on-chain")}
|
|
307
309
|
|
|
308
|
-
return {"isValid": True}
|
|
310
|
+
return {"isValid": True, "payer": auth["from"]}
|
|
309
311
|
except Exception as e:
|
|
310
312
|
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paynode-sdk-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: PayNode Protocol Python SDK for AI Agents
|
|
5
5
|
Author-email: PayNodeLabs <contact@paynode.dev>
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,8 @@ Provides-Extra: test
|
|
|
16
16
|
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
17
17
|
Requires-Dist: responses>=0.23.0; extra == "test"
|
|
18
18
|
Requires-Dist: pytest-mock>=3.10.0; extra == "test"
|
|
19
|
+
Provides-Extra: webhook
|
|
20
|
+
Requires-Dist: aiohttp>=3.9.0; extra == "webhook"
|
|
19
21
|
Dynamic: license-file
|
|
20
22
|
|
|
21
23
|
# PayNode Python SDK
|
|
@@ -23,7 +25,7 @@ Dynamic: license-file
|
|
|
23
25
|
[](https://docs.paynode.dev)
|
|
24
26
|
[](https://pypi.org/project/paynode-sdk-python/)
|
|
25
27
|
|
|
26
|
-
The official Python SDK for the **PayNode Protocol (
|
|
28
|
+
The official Python SDK for the **PayNode Protocol (v2.2.0)**. 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).
|
|
27
29
|
|
|
28
30
|
## 📖 Read the Docs
|
|
29
31
|
|
|
@@ -54,7 +56,7 @@ response = agent.request_gate("https://api.merchant.com/premium-data", method="P
|
|
|
54
56
|
print(response.json())
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
### Key Features (v2.
|
|
59
|
+
### Key Features (v2.2.0)
|
|
58
60
|
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`, allowing for gasless or relayer-mediated settlement.
|
|
59
61
|
- **X402 V2 Protocol**: JSON-based handshake for more structured and machine-readable payment instructions.
|
|
60
62
|
- **Dual Flow**: Automatic fallback to V1 (on-chain receipts) for legacy merchant support.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paynode-sdk-python"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.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" }]
|
|
@@ -23,6 +23,9 @@ test = [
|
|
|
23
23
|
"responses>=0.23.0",
|
|
24
24
|
"pytest-mock>=3.10.0"
|
|
25
25
|
]
|
|
26
|
+
webhook = [
|
|
27
|
+
"aiohttp>=3.9.0"
|
|
28
|
+
]
|
|
26
29
|
|
|
27
30
|
[project.urls]
|
|
28
31
|
Homepage = "https://github.com/PayNodeLabs/paynode-sdk-python"
|
|
@@ -8,9 +8,9 @@ from paynode_sdk import PayNodeAgentClient, PayNodeException, ErrorCode
|
|
|
8
8
|
# Standard Base Sepolia Mock Values
|
|
9
9
|
MOCK_PRIVATE_KEY = "0x" + "1" * 64
|
|
10
10
|
MOCK_RPC = "https://sepolia.base.org"
|
|
11
|
-
MOCK_MERCHANT = "
|
|
11
|
+
MOCK_MERCHANT = "0x" + "7" * 40
|
|
12
12
|
MOCK_TOKEN = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
|
|
13
|
-
MOCK_ROUTER = "
|
|
13
|
+
MOCK_ROUTER = "0x" + "9" * 40
|
|
14
14
|
MOCK_ORDER_ID = "order_12345"
|
|
15
15
|
MOCK_TX_HASH = "0x6f3e1a0000000000000000000000000000000000000000000000000000000000"
|
|
16
16
|
|
|
@@ -71,11 +71,12 @@ def test_402_v2_onchain_handshake(client):
|
|
|
71
71
|
assert response.status_code == 200
|
|
72
72
|
assert response.json()["data"] == "Premium Secret Content"
|
|
73
73
|
|
|
74
|
-
# Verify
|
|
74
|
+
# Verify PAYMENT-SIGNATURE was sent
|
|
75
75
|
retry_request = responses.calls[1].request
|
|
76
|
-
assert '
|
|
77
|
-
payload = json.loads(base64.b64decode(retry_request.headers['
|
|
78
|
-
assert payload['
|
|
76
|
+
assert 'PAYMENT-SIGNATURE' in retry_request.headers
|
|
77
|
+
payload = json.loads(base64.b64decode(retry_request.headers['PAYMENT-SIGNATURE']).decode())
|
|
78
|
+
assert payload['x402Version'] == 2
|
|
79
|
+
assert payload['_paynode']['type'] == 'onchain'
|
|
79
80
|
assert payload['payload']['txHash'] == MOCK_TX_HASH
|
|
80
81
|
|
|
81
82
|
def test_dust_limit_protection(client):
|
|
@@ -133,7 +134,8 @@ def test_insufficient_funds_on_chain(client):
|
|
|
133
134
|
})
|
|
134
135
|
|
|
135
136
|
with patch.object(client, '_get_allowance', return_value=1000000), \
|
|
136
|
-
patch.object(client, 'pay', side_effect=PayNodeException(ErrorCode.transaction_failed))
|
|
137
|
+
patch.object(client, 'pay', side_effect=PayNodeException(ErrorCode.transaction_failed)), \
|
|
138
|
+
patch.object(client, 'pay_with_permit', side_effect=PayNodeException(ErrorCode.transaction_failed)):
|
|
137
139
|
|
|
138
140
|
with pytest.raises(PayNodeException) as exc:
|
|
139
141
|
client.get(target_url)
|
|
@@ -182,9 +184,48 @@ def test_402_v2_eip3009_handshake(client):
|
|
|
182
184
|
assert response.status_code == 200
|
|
183
185
|
assert mock_sign.called
|
|
184
186
|
|
|
185
|
-
# Verify
|
|
187
|
+
# Verify PAYMENT-SIGNATURE
|
|
186
188
|
retry_request = responses.calls[1].request
|
|
187
|
-
assert '
|
|
188
|
-
payload = json.loads(base64.b64decode(retry_request.headers['
|
|
189
|
-
assert payload['
|
|
189
|
+
assert 'PAYMENT-SIGNATURE' in retry_request.headers
|
|
190
|
+
payload = json.loads(base64.b64decode(retry_request.headers['PAYMENT-SIGNATURE']).decode())
|
|
191
|
+
assert payload['x402Version'] == 2
|
|
192
|
+
assert payload['_paynode']['type'] == 'eip3009'
|
|
190
193
|
assert payload['payload']['signature'] == '0xsig'
|
|
194
|
+
|
|
195
|
+
@responses.activate
|
|
196
|
+
def test_settlement_confirmation_logging(client):
|
|
197
|
+
"""
|
|
198
|
+
Ensures the client correctly reads and logs the PAYMENT-RESPONSE header.
|
|
199
|
+
"""
|
|
200
|
+
target_url = "http://api.agent/settle"
|
|
201
|
+
|
|
202
|
+
# 1. Mock the 402 response
|
|
203
|
+
v2_req = {
|
|
204
|
+
"x402Version": 2,
|
|
205
|
+
"accepts": [{"type": "onchain", "network": "eip155:84532", "amount": "1000", "asset": MOCK_TOKEN, "payTo": MOCK_MERCHANT, "router": MOCK_ROUTER}]
|
|
206
|
+
}
|
|
207
|
+
b64_req = base64.b64encode(json.dumps(v2_req).encode()).decode()
|
|
208
|
+
responses.add(responses.GET, target_url, status=402, headers={'X-402-Required': b64_req})
|
|
209
|
+
|
|
210
|
+
# 2. Mock settlement header in success response
|
|
211
|
+
settle_data = {"success": True, "transaction": MOCK_TX_HASH}
|
|
212
|
+
b64_settle = base64.b64encode(json.dumps(settle_data).encode()).decode()
|
|
213
|
+
|
|
214
|
+
responses.add(
|
|
215
|
+
responses.GET, target_url,
|
|
216
|
+
status=200,
|
|
217
|
+
headers={'PAYMENT-RESPONSE': b64_settle},
|
|
218
|
+
json={"data": "OK"}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
with patch.object(client, 'pay', return_value=MOCK_TX_HASH), \
|
|
222
|
+
patch.object(client, '_get_allowance', return_value=1000000), \
|
|
223
|
+
patch('paynode_sdk.client.logger') as mock_logger:
|
|
224
|
+
|
|
225
|
+
response = client.get(target_url)
|
|
226
|
+
assert response.status_code == 200
|
|
227
|
+
|
|
228
|
+
# Verify logger was called with confirmation
|
|
229
|
+
# Since logger.info is called directly from the module logger, we need to handle that.
|
|
230
|
+
# But here we patched it specifically.
|
|
231
|
+
mock_logger.info.assert_any_call(f"✅ [PayNode-PY] Settlement confirmed: {MOCK_TX_HASH}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.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
|