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.
Files changed (22) hide show
  1. {paynode_sdk_python-2.1.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.2.1}/PKG-INFO +5 -3
  2. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/README.md +2 -2
  3. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/client.py +63 -18
  4. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/constants.py +2 -2
  5. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/errors.py +1 -1
  6. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/middleware.py +68 -6
  7. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/verifier.py +4 -2
  8. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1/paynode_sdk_python.egg-info}/PKG-INFO +5 -3
  9. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/requires.txt +3 -0
  10. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/pyproject.toml +4 -1
  11. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_client.py +52 -11
  12. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/LICENSE +0 -0
  13. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/__init__.py +0 -0
  14. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/idempotency.py +0 -0
  15. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk/webhook.py +0 -0
  16. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/SOURCES.txt +0 -0
  17. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
  18. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/paynode_sdk_python.egg-info/top_level.txt +0 -0
  19. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/setup.cfg +0 -0
  20. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_concurrency.py +0 -0
  21. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.1}/tests/test_internals.py +0 -0
  22. {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.0
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
  [![Official Documentation](https://img.shields.io/badge/Docs-docs.paynode.dev-00ff88?style=for-the-badge&logo=readthedocs)](https://docs.paynode.dev)
24
26
  [![PyPI Version](https://img.shields.io/pypi/v/paynode-sdk-python.svg?style=for-the-badge)](https://pypi.org/project/paynode-sdk-python/)
25
27
 
26
- The official Python SDK for the **PayNode Protocol (v3.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).
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
  [![Official Documentation](https://img.shields.io/badge/Docs-docs.paynode.dev-00ff88?style=for-the-badge&logo=readthedocs)](https://docs.paynode.dev)
4
4
  [![PyPI Version](https://img.shields.io/pypi/v/paynode-sdk-python.svg?style=for-the-badge)](https://pypi.org/project/paynode-sdk-python/)
5
5
 
6
- The official Python SDK for the **PayNode Protocol (v3.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).
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 (v3.1).
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.session.request(method, url, **kwargs)
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 X-402-Required header)
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
- if not body and b64_required:
114
+ header_body = None
115
+ if b64_required:
113
116
  try:
114
- import base64
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 X-402-Required header: {e}")
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
- # Unified Payload for v3.1
212
+ # Official X402 V2 Payload with PayNode extensions
206
213
  payment_payload = {
207
- "version": "3.1",
208
- "type": ptype,
209
- "orderId": order_id,
210
- "payload": payload_data
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
- 'X-402-Payload': b64_payload,
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 = ["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"]
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 'X-402-Payload' header.",
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
- unified_payload = json.loads(base64.b64decode(v2_payload_header.encode()).decode())
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 X-402-Payload header: {e}")
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
- return await call_next(request)
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": err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt,
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.0
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
  [![Official Documentation](https://img.shields.io/badge/Docs-docs.paynode.dev-00ff88?style=for-the-badge&logo=readthedocs)](https://docs.paynode.dev)
24
26
  [![PyPI Version](https://img.shields.io/pypi/v/paynode-sdk-python.svg?style=for-the-badge)](https://pypi.org/project/paynode-sdk-python/)
25
27
 
26
- The official Python SDK for the **PayNode Protocol (v3.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).
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.
@@ -7,3 +7,6 @@ fastapi>=0.111.0
7
7
  pytest>=7.0.0
8
8
  responses>=0.23.0
9
9
  pytest-mock>=3.10.0
10
+
11
+ [webhook]
12
+ aiohttp>=3.9.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "paynode-sdk-python"
7
- version = "2.1.0"
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 = "0xMerchantWalletAddress789"
11
+ MOCK_MERCHANT = "0x" + "7" * 40
12
12
  MOCK_TOKEN = "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"
13
- MOCK_ROUTER = "0xPayNodeRouterAddress123"
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 X-402-Payload was sent
74
+ # Verify PAYMENT-SIGNATURE was sent
75
75
  retry_request = responses.calls[1].request
76
- assert 'X-402-Payload' in retry_request.headers
77
- payload = json.loads(base64.b64decode(retry_request.headers['X-402-Payload']).decode())
78
- assert payload['type'] == 'onchain'
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 X-402-Payload
187
+ # Verify PAYMENT-SIGNATURE
186
188
  retry_request = responses.calls[1].request
187
- assert 'X-402-Payload' in retry_request.headers
188
- payload = json.loads(base64.b64decode(retry_request.headers['X-402-Payload']).decode())
189
- assert payload['type'] == 'eip3009'
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}")