paynode-sdk-python 2.1.0__tar.gz → 2.2.1__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.1}/PKG-INFO +5 -3
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/README.md +2 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/client.py +63 -18
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/constants.py +2 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/errors.py +1 -1
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/middleware.py +68 -6
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/verifier.py +4 -2
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1/paynode_sdk_python.egg-info}/PKG-INFO +5 -3
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/requires.txt +3 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/pyproject.toml +4 -1
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_client.py +52 -11
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/LICENSE +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/__init__.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/idempotency.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/webhook.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/SOURCES.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/setup.cfg +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_concurrency.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_internals.py +0 -0
- {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/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.1
|
|
3
|
+
Version: 2.2.1
|
|
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.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).
|
|
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.1)
|
|
59
|
+
### Key Features (v2.2.1)
|
|
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.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.1)
|
|
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.
|
|
@@ -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.1).
|
|
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
|
|
@@ -109,17 +111,22 @@ class PayNodeAgentClient:
|
|
|
109
111
|
except Exception as e:
|
|
110
112
|
logger.debug(f"⚠️ [PayNode-PY] Failed to parse 402 JSON body: {e}")
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
header_body = None
|
|
115
|
+
if b64_required:
|
|
113
116
|
try:
|
|
114
|
-
|
|
115
|
-
import json
|
|
116
|
-
body = json.loads(base64.b64decode(b64_required).decode())
|
|
117
|
+
header_body = json.loads(base64.b64decode(b64_required).decode())
|
|
117
118
|
except Exception as e:
|
|
118
|
-
logger.warning(f"❌ [PayNode-PY] Failed to decode
|
|
119
|
+
logger.warning(f"❌ [PayNode-PY] Failed to decode PAYMENT-REQUIRED header: {e}")
|
|
120
|
+
|
|
121
|
+
if not body and header_body:
|
|
122
|
+
body = header_body
|
|
123
|
+
elif body and header_body and not body.get('x402Version'):
|
|
124
|
+
# Robustness: Merge header info into body if body is missing critical bits
|
|
125
|
+
body.update({k: v for k, v in header_body.items() if k not in body})
|
|
119
126
|
|
|
120
127
|
if body and body.get('x402Version') == 2:
|
|
121
128
|
logger.info("🚀 [PayNode-PY] x402 v2 detected. Handling autonomous payment...")
|
|
122
|
-
if order_id: body['orderId'] = order_id
|
|
129
|
+
if order_id and not body.get('orderId'): body['orderId'] = order_id
|
|
123
130
|
kwargs = self._handle_x402_v2(url, body, **kwargs)
|
|
124
131
|
continue
|
|
125
132
|
|
|
@@ -202,29 +209,67 @@ class PayNodeAgentClient:
|
|
|
202
209
|
|
|
203
210
|
payload_data = {"txHash": tx_hash}
|
|
204
211
|
|
|
205
|
-
#
|
|
212
|
+
# Official X402 V2 Payload with PayNode extensions
|
|
206
213
|
payment_payload = {
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
|
|
214
|
+
"x402Version": 2,
|
|
215
|
+
"resource": requirements.get('resource'),
|
|
216
|
+
"accepted": {
|
|
217
|
+
"scheme": requirement.get('scheme'),
|
|
218
|
+
"network": requirement.get('network'),
|
|
219
|
+
"amount": requirement.get('amount'),
|
|
220
|
+
"asset": requirement.get('asset'),
|
|
221
|
+
"payTo": requirement.get('payTo'),
|
|
222
|
+
"maxTimeoutSeconds": requirement.get('maxTimeoutSeconds'),
|
|
223
|
+
"extra": requirement.get('extra', {})
|
|
224
|
+
},
|
|
225
|
+
"payload": payload_data,
|
|
226
|
+
"_paynode": {
|
|
227
|
+
"version": "2.2.1",
|
|
228
|
+
"type": ptype,
|
|
229
|
+
"orderId": order_id
|
|
230
|
+
}
|
|
211
231
|
}
|
|
232
|
+
|
|
233
|
+
# Add router for onchain
|
|
234
|
+
if ptype == 'onchain':
|
|
235
|
+
payment_payload["accepted"]["router"] = requirement.get('router')
|
|
212
236
|
|
|
213
237
|
logger.info(f"✅ [PayNode-PY] {ptype} payment prepared. Retrying request...")
|
|
214
238
|
|
|
215
|
-
import json
|
|
216
|
-
import base64
|
|
217
239
|
b64_payload = base64.b64encode(json.dumps(payment_payload).encode()).decode()
|
|
218
240
|
|
|
219
241
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
220
242
|
retry_headers.update({
|
|
221
243
|
'Content-Type': 'application/json',
|
|
222
|
-
'
|
|
244
|
+
'PAYMENT-SIGNATURE': b64_payload,
|
|
245
|
+
'X-402-Payload': b64_payload, # Backward compatibility
|
|
223
246
|
'X-402-Order-Id': order_id
|
|
224
247
|
})
|
|
225
248
|
kwargs['headers'] = retry_headers
|
|
226
249
|
return kwargs
|
|
227
250
|
|
|
251
|
+
def _request_with_settlement_check(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
252
|
+
"""Wrapper to check for PAYMENT-RESPONSE after retry."""
|
|
253
|
+
response = self.session.request(method, url, **kwargs)
|
|
254
|
+
|
|
255
|
+
settle_header = response.headers.get('PAYMENT-RESPONSE') or response.headers.get('X-PAYMENT-RESPONSE')
|
|
256
|
+
if settle_header:
|
|
257
|
+
try:
|
|
258
|
+
settle_header_str = settle_header.strip()
|
|
259
|
+
if settle_header_str.startswith('{'):
|
|
260
|
+
decoded = settle_header_str
|
|
261
|
+
else:
|
|
262
|
+
decoded = base64.b64decode(settle_header).decode()
|
|
263
|
+
settle_data = json.loads(decoded)
|
|
264
|
+
if settle_data.get('success'):
|
|
265
|
+
logger.info(f"✅ [PayNode-PY] Settlement confirmed: {settle_data.get('transaction')}")
|
|
266
|
+
else:
|
|
267
|
+
logger.warning(f"⚠️ [PayNode-PY] Settlement failed: {settle_data.get('errorReason', 'Unknown error')}")
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.warning(f"⚠️ [PayNode-PY] Failed to parse settlement response: {e}")
|
|
270
|
+
|
|
271
|
+
return response
|
|
272
|
+
|
|
228
273
|
def sign_transfer_with_authorization(self, token_addr, to, amount, valid_after, valid_before, nonce, extra=None):
|
|
229
274
|
extra = extra or {}
|
|
230
275
|
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.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']
|
|
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.1
|
|
3
|
+
Version: 2.2.1
|
|
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.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).
|
|
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.1)
|
|
59
|
+
### Key Features (v2.2.1)
|
|
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.1
|
|
7
|
+
version = "2.2.1"
|
|
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.1}/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.1}/paynode_sdk_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|