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.
Files changed (22) hide show
  1. {paynode_sdk_python-2.1.0/paynode_sdk_python.egg-info → paynode_sdk_python-2.2.0}/PKG-INFO +5 -3
  2. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/README.md +2 -2
  3. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/client.py +49 -16
  4. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/constants.py +2 -2
  5. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/errors.py +1 -1
  6. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/middleware.py +68 -6
  7. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/verifier.py +4 -2
  8. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0/paynode_sdk_python.egg-info}/PKG-INFO +5 -3
  9. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/requires.txt +3 -0
  10. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/pyproject.toml +4 -1
  11. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_client.py +52 -11
  12. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/LICENSE +0 -0
  13. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/__init__.py +0 -0
  14. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/idempotency.py +0 -0
  15. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk/webhook.py +0 -0
  16. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/SOURCES.txt +0 -0
  17. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
  18. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
  19. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/setup.cfg +0 -0
  20. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_concurrency.py +0 -0
  21. {paynode_sdk_python-2.1.0 → paynode_sdk_python-2.2.0}/tests/test_internals.py +0 -0
  22. {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.1.0
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
  [![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.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.1)
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
  [![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.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.1)
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 (v3.1).
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.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
@@ -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 X-402-Required header: {e}")
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
- # Unified Payload for v3.1
205
+ # Official X402 V2 Payload with PayNode extensions
206
206
  payment_payload = {
207
- "version": "3.1",
208
- "type": ptype,
209
- "orderId": order_id,
210
- "payload": payload_data
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
- 'X-402-Payload': b64_payload,
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 = ["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.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 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.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
  [![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.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.1)
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.
@@ -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.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 = "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}")