paynode-sdk-python 1.0.1__py3-none-any.whl → 1.1.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -2,5 +2,13 @@ from .middleware import PayNodeMiddleware
2
2
  from .verifier import PayNodeVerifier
3
3
  from .errors import ErrorCode, PayNodeException
4
4
  from .idempotency import IdempotencyStore, MemoryIdempotencyStore
5
+ from .webhook import PayNodeWebhookNotifier, PaymentEvent
6
+ from .client import PayNodeAgentClient
7
+ from .constants import ACCEPTED_TOKENS
5
8
 
6
- __all__ = ["PayNodeMiddleware", "PayNodeVerifier", "ErrorCode", "PayNodeException", "IdempotencyStore", "MemoryIdempotencyStore"]
9
+ __all__ = [
10
+ "PayNodeMiddleware", "PayNodeVerifier", "ErrorCode", "PayNodeException",
11
+ "IdempotencyStore", "MemoryIdempotencyStore",
12
+ "PayNodeWebhookNotifier", "PaymentEvent",
13
+ "PayNodeAgentClient", "ACCEPTED_TOKENS"
14
+ ]
paynode_sdk/client.py CHANGED
@@ -1,53 +1,94 @@
1
- import requests
2
1
  import time
2
+ import logging
3
+ import threading
4
+ import requests
3
5
  from web3 import Web3
6
+ from requests.adapters import HTTPAdapter
7
+ from urllib3.util.retry import Retry
4
8
  from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS
9
+ from .errors import PayNodeException, ErrorCode
10
+
11
+ logger = logging.getLogger("paynode_sdk.client")
5
12
 
6
13
  class PayNodeAgentClient:
7
14
  def __init__(self, rpc_urls: list, private_key: str):
8
- self.w3 = Web3(Web3.HTTPProvider(rpc_urls[0]))
15
+ self.rpc_urls = rpc_urls
16
+ self.w3 = self._init_w3()
17
+ # Initialize account and discard private key string to prevent Traceback leaks
9
18
  self.account = self.w3.eth.account.from_key(private_key)
10
- self.private_key = private_key
19
+ self.nonce_lock = threading.Lock()
20
+
21
+ # Setup session with standard HTTP retries for non-402 errors
22
+ self.session = requests.Session()
23
+ retry_strategy = Retry(
24
+ total=3,
25
+ status_forcelist=[429, 500, 502, 503, 504],
26
+ allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"]
27
+ )
28
+ adapter = HTTPAdapter(max_retries=retry_strategy)
29
+ self.session.mount("https://", adapter)
30
+ self.session.mount("http://", adapter)
31
+
32
+ def _init_w3(self):
33
+ for rpc in self.rpc_urls:
34
+ try:
35
+ w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={'timeout': 10}))
36
+ if w3.is_connected():
37
+ return w3
38
+ except Exception:
39
+ continue
40
+ raise PayNodeException("Failed to connect to any RPC URL", ErrorCode.RPC_ERROR)
11
41
 
12
42
  def get(self, url, **kwargs):
13
- response = requests.get(url, **kwargs)
14
- if response.status_code == 402:
15
- print("💡 [PayNode-PY] 402 Detected. Handling payment...")
16
- return self._handle_402(url, "GET", response.headers, **kwargs)
17
- return response
43
+ return self._request_with_402_retry("GET", url, **kwargs)
18
44
 
19
45
  def post(self, url, **kwargs):
20
- response = requests.post(url, **kwargs)
21
- if response.status_code == 402:
22
- print("💡 [PayNode-PY] 402 Detected. Handling payment...")
23
- return self._handle_402(url, "POST", response.headers, **kwargs)
46
+ return self._request_with_402_retry("POST", url, **kwargs)
47
+
48
+ def _request_with_402_retry(self, method, url, max_retries=3, **kwargs):
49
+ for attempt in range(max_retries):
50
+ response = self.session.request(method, url, **kwargs)
51
+ if response.status_code == 402:
52
+ logger.info("💡 [PayNode-PY] 402 Detected. Handling payment...")
53
+ try:
54
+ kwargs = self._handle_402(response.headers, **kwargs)
55
+ except Exception as e:
56
+ if isinstance(e, PayNodeException):
57
+ raise
58
+ raise PayNodeException(f"Payment execution failed: {str(e)}", ErrorCode.INTERNAL_ERROR)
59
+ time.sleep(1) # Backoff before retry
60
+ continue
61
+ return response
24
62
  return response
25
63
 
26
- def _handle_402(self, url, method, headers, **kwargs):
64
+ def _handle_402(self, headers, **kwargs):
27
65
  router_addr = headers.get('x-paynode-contract')
28
66
  merchant_addr = headers.get('x-paynode-merchant')
29
67
  amount_raw = int(headers.get('x-paynode-amount', 0))
30
68
  token_addr = headers.get('x-paynode-token-address')
31
69
  order_id = headers.get('x-paynode-order-id')
32
70
 
33
- # 1. Handle Approval
34
- self._ensure_allowance(token_addr, router_addr, amount_raw)
71
+ try:
72
+ self._ensure_allowance(token_addr, router_addr, amount_raw)
73
+ tx_hash = self._execute_pay(router_addr, token_addr, merchant_addr, amount_raw, order_id)
74
+ logger.info(f"✅ [PayNode-PY] Payment successful: {tx_hash}")
75
+ except ValueError as e:
76
+ err_msg = str(e).lower()
77
+ if "insufficient funds" in err_msg:
78
+ raise PayNodeException("Insufficient funds for gas or token.", ErrorCode.INSUFFICIENT_FUNDS)
79
+ raise PayNodeException(f"Transaction failed: {str(e)}", ErrorCode.TRANSACTION_FAILED)
80
+ except Exception as e:
81
+ if isinstance(e, PayNodeException):
82
+ raise
83
+ raise PayNodeException(f"Unknown error during payment: {str(e)}", ErrorCode.INTERNAL_ERROR)
35
84
 
36
- # 2. Execute Payment
37
- tx_hash = self._execute_pay(router_addr, token_addr, merchant_addr, amount_raw, order_id)
38
- print(f"✅ [PayNode-PY] Payment successful: {tx_hash}")
39
-
40
- # 3. Retry
41
85
  retry_headers = kwargs.get('headers', {}).copy()
42
86
  retry_headers.update({
43
87
  'x-paynode-receipt': tx_hash,
44
88
  'x-paynode-order-id': order_id
45
89
  })
46
90
  kwargs['headers'] = retry_headers
47
-
48
- if method == "GET":
49
- return requests.get(url, **kwargs)
50
- return requests.post(url, **kwargs)
91
+ return kwargs
51
92
 
52
93
  def _ensure_allowance(self, token_addr, spender_addr, amount):
53
94
  token_abi = [
@@ -58,42 +99,120 @@ class PayNodeAgentClient:
58
99
  allowance = token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
59
100
 
60
101
  if allowance < amount:
61
- print(f"🔐 [PayNode-PY] Allowance too low. Granting Infinite Approval...")
62
- # Use 20% higher gas price for better reliability
102
+ logger.info("🔐 [PayNode-PY] Allowance too low. Granting Infinite Approval...")
63
103
  current_gas_price = int(self.w3.eth.gas_price * 1.2)
64
104
 
65
- tx = token.functions.approve(Web3.to_checksum_address(spender_addr), 2**256 - 1).build_transaction({
105
+ with self.nonce_lock:
106
+ nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
107
+ tx = token.functions.approve(Web3.to_checksum_address(spender_addr), 2**256 - 1).build_transaction({
108
+ 'from': self.account.address,
109
+ 'nonce': nonce,
110
+ 'gas': 100000,
111
+ 'gasPrice': current_gas_price
112
+ })
113
+ signed_tx = self.account.sign_transaction(tx)
114
+ tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
115
+
116
+ logger.info(f"⏳ Waiting for approval confirmation: {self.w3.to_hex(tx_h)}...")
117
+ self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
118
+
119
+ def _execute_pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
120
+ router_abi = [
121
+ {"inputs": [{"name": "t", "type": "address"}, {"name": "m", "type": "address"}, {"name": "a", "type": "uint256"}, {"name": "o", "type": "bytes32"}], "name": "pay", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
122
+ {"inputs": [{"name": "payer", "type": "address"}, {"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}, {"name": "v", "type": "uint8"}, {"name": "r", "type": "bytes32"}, {"name": "s", "type": "bytes32"}], "name": "payWithPermit", "outputs": [], "stateMutability": "nonpayable", "type": "function"}
123
+ ]
124
+ router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
125
+ order_id_bytes = self.w3.keccak(text=order_id)
126
+
127
+ current_gas_price = int(self.w3.eth.gas_price * 1.2)
128
+
129
+ with self.nonce_lock:
130
+ nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
131
+ tx = router.functions.pay(
132
+ Web3.to_checksum_address(token_addr),
133
+ Web3.to_checksum_address(merchant_addr),
134
+ amount,
135
+ order_id_bytes
136
+ ).build_transaction({
66
137
  'from': self.account.address,
67
- 'nonce': self.w3.eth.get_transaction_count(self.account.address, 'pending'),
68
- 'gas': 100000,
138
+ 'nonce': nonce,
139
+ 'gas': 200000,
69
140
  'gasPrice': current_gas_price
70
141
  })
71
- signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
142
+ signed_tx = self.account.sign_transaction(tx)
72
143
  tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
73
- print(f"⏳ Waiting for approval confirmation: {self.w3.to_hex(tx_h)}...")
74
- self.w3.eth.wait_for_transaction_receipt(tx_h)
75
- time.sleep(1) # Extra buffer for indexers
144
+
145
+ self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
146
+ return self.w3.to_hex(tx_h)
76
147
 
77
- def _execute_pay(self, router_addr, token_addr, merchant_addr, amount, order_id):
78
- router_abi = [{"inputs": [{"name": "t", "type": "address"}, {"name": "m", "type": "address"}, {"name": "a", "type": "uint256"}, {"name": "o", "type": "bytes32"}], "name": "pay", "outputs": [], "stateMutability": "nonpayable", "type": "function"}]
148
+ def pay_with_permit(
149
+ self,
150
+ router_addr: str,
151
+ payer_address: str,
152
+ token_addr: str,
153
+ merchant_addr: str,
154
+ amount: int,
155
+ order_id: str,
156
+ deadline: int,
157
+ v: int,
158
+ r: bytes,
159
+ s: bytes
160
+ ) -> str:
161
+ """
162
+ Execute payment using EIP-2612 Permit — single-tx approve + pay.
163
+ The payer signs the permit offline, and this Agent relays it on-chain.
164
+
165
+ Args:
166
+ router_addr: PayNode Router contract address
167
+ payer_address: The address that holds the tokens and signed the permit
168
+ token_addr: ERC20 token address (must support EIP-2612)
169
+ merchant_addr: Merchant receiving 99% of payment
170
+ amount: Token amount in smallest unit (e.g. 1000000 = 1 USDC)
171
+ order_id: Order identifier string
172
+ deadline: Unix timestamp after which the permit is invalid
173
+ v: ECDSA recovery id
174
+ r: ECDSA signature r component (bytes32)
175
+ s: ECDSA signature s component (bytes32)
176
+ """
177
+ router_abi = [
178
+ {"inputs": [{"name": "payer", "type": "address"}, {"name": "token", "type": "address"}, {"name": "merchant", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "orderId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}, {"name": "v", "type": "uint8"}, {"name": "r", "type": "bytes32"}, {"name": "s", "type": "bytes32"}], "name": "payWithPermit", "outputs": [], "stateMutability": "nonpayable", "type": "function"}
179
+ ]
79
180
  router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
80
181
  order_id_bytes = self.w3.keccak(text=order_id)
81
182
 
82
- # Use 20% higher gas price
83
183
  current_gas_price = int(self.w3.eth.gas_price * 1.2)
84
184
 
85
- tx = router.functions.pay(
86
- Web3.to_checksum_address(token_addr),
87
- Web3.to_checksum_address(merchant_addr),
88
- amount,
89
- order_id_bytes
90
- ).build_transaction({
91
- 'from': self.account.address,
92
- 'nonce': self.w3.eth.get_transaction_count(self.account.address, 'pending'),
93
- 'gas': 200000,
94
- 'gasPrice': current_gas_price
95
- })
96
- signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
97
- tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
98
- self.w3.eth.wait_for_transaction_receipt(tx_h)
99
- return self.w3.to_hex(tx_h)
185
+ try:
186
+ with self.nonce_lock:
187
+ nonce = self.w3.eth.get_transaction_count(self.account.address, 'pending')
188
+ tx = router.functions.payWithPermit(
189
+ Web3.to_checksum_address(payer_address),
190
+ Web3.to_checksum_address(token_addr),
191
+ Web3.to_checksum_address(merchant_addr),
192
+ amount,
193
+ order_id_bytes,
194
+ deadline,
195
+ v,
196
+ r,
197
+ s
198
+ ).build_transaction({
199
+ 'from': self.account.address,
200
+ 'nonce': nonce,
201
+ 'gas': 300000,
202
+ 'gasPrice': current_gas_price
203
+ })
204
+ signed_tx = self.account.sign_transaction(tx)
205
+ tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
206
+
207
+ self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
208
+ logger.info(f"✅ [PayNode-PY] Permit payment confirmed: {self.w3.to_hex(tx_h)}")
209
+ return self.w3.to_hex(tx_h)
210
+ except ValueError as e:
211
+ err_msg = str(e).lower()
212
+ if "insufficient funds" in err_msg:
213
+ raise PayNodeException("Insufficient funds for gas.", ErrorCode.INSUFFICIENT_FUNDS)
214
+ raise PayNodeException(f"Permit transaction failed: {str(e)}", ErrorCode.PERMIT_FAILED)
215
+ except Exception as e:
216
+ if isinstance(e, PayNodeException):
217
+ raise
218
+ raise PayNodeException(f"Permit payment error: {str(e)}", ErrorCode.INTERNAL_ERROR)
paynode_sdk/constants.py CHANGED
@@ -1,5 +1,14 @@
1
- PAYNODE_ROUTER_ADDRESS = "0xA88B5eaD188De39c015AC51F45E1B41D3d95f2bb"
2
- PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x1E12700393D3222BC451fb0aEe7351E4eB6779b1"
1
+ # Generated by scripts/sync-config.py
2
+ PAYNODE_ROUTER_ADDRESS = "0x92e20164FC457a2aC35f53D06268168e6352b200"
3
+ PAYNODE_ROUTER_ADDRESS_SANDBOX = "0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408"
3
4
  BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
4
5
  BASE_USDC_ADDRESS_SANDBOX = "0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798"
5
6
  BASE_USDC_DECIMALS = 6
7
+
8
+ PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
9
+ PROTOCOL_FEE_BPS = 100
10
+
11
+ ACCEPTED_TOKENS = {
12
+ 8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"],
13
+ 84532: ["0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798"]
14
+ }
paynode_sdk/errors.py CHANGED
@@ -13,8 +13,10 @@ class ErrorCode(str, Enum):
13
13
  WRONG_CONTRACT = 'PAYNODE_WRONG_CONTRACT'
14
14
  WRONG_MERCHANT = 'PAYNODE_WRONG_MERCHANT'
15
15
  WRONG_TOKEN = 'PAYNODE_WRONG_TOKEN'
16
+ TOKEN_NOT_ACCEPTED = 'PAYNODE_TOKEN_NOT_ACCEPTED'
16
17
  INSUFFICIENT_FUNDS = 'PAYNODE_INSUFFICIENT_FUNDS'
17
18
  ORDER_MISMATCH = 'PAYNODE_ORDER_MISMATCH'
19
+ PERMIT_FAILED = 'PAYNODE_PERMIT_FAILED'
18
20
 
19
21
  # System
20
22
  RPC_ERROR = 'PAYNODE_RPC_ERROR'
paynode_sdk/verifier.py CHANGED
@@ -1,36 +1,100 @@
1
- from .errors import ErrorCode
2
- from unittest.mock import MagicMock
1
+ from .errors import ErrorCode, PayNodeException
2
+ from .constants import PAYNODE_ROUTER_ABI, ACCEPTED_TOKENS
3
+ from .idempotency import MemoryIdempotencyStore
4
+ from web3 import Web3
3
5
 
4
6
  class PayNodeVerifier:
5
- def __init__(self, rpc_url=None, contract_address=None, chain_id=None, w3=None):
6
- self.w3 = w3 or MagicMock()
7
+ def __init__(self, rpc_url=None, contract_address=None, chain_id=None, w3=None, store=None, accepted_tokens=None):
8
+ self.w3 = w3
9
+ if not self.w3 and rpc_url:
10
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url))
7
11
  self.contract_address = contract_address
8
- self.used_receipts = set()
12
+ self.chain_id = int(chain_id) if chain_id else None
13
+ self.store = store or MemoryIdempotencyStore()
14
+
15
+ # Build accepted token set: user-provided or chain-default
16
+ # accepted_tokens=None → use chain default; accepted_tokens=[] → explicitly disable whitelist
17
+ if accepted_tokens is not None:
18
+ token_list = accepted_tokens
19
+ elif self.chain_id:
20
+ token_list = ACCEPTED_TOKENS.get(self.chain_id)
21
+ else:
22
+ token_list = None
23
+ self.accepted_tokens = set(t.lower() for t in token_list) if token_list else None
9
24
 
10
25
  async def verify_payment(self, tx_hash, expected):
11
- if tx_hash in self.used_receipts:
12
- return {"isValid": False, "error": MagicError(ErrorCode.TRANSACTION_NOT_FOUND if "Used" not in str(tx_hash) else ErrorCode.RECEIPT_ALREADY_USED)}
13
-
14
- # 兼容性修复:根据枚举实际名称调整
26
+ if not self.w3:
27
+ return {"isValid": False, "error": PayNodeException("Verifier Provider Missing", ErrorCode.RPC_ERROR)}
28
+
29
+ # 0. Token Whitelist Check (Anti-FakeToken)
30
+ expected_token = expected.get("tokenAddress", "").lower()
31
+ if self.accepted_tokens and expected_token not in self.accepted_tokens:
32
+ return {"isValid": False, "error": PayNodeException(
33
+ f"Token {expected.get('tokenAddress')} is not in the accepted whitelist.",
34
+ ErrorCode.TOKEN_NOT_ACCEPTED
35
+ )}
36
+
15
37
  try:
16
- err_code = ErrorCode.RECEIPT_ALREADY_USED
17
- except AttributeError:
18
- err_code = ErrorCode.PAYNODE_RECEIPT_ALREADY_USED
38
+ is_new = await self.store.check_and_set(tx_hash, 86400) # 24 hour TTL
39
+ if not is_new:
40
+ return {"isValid": False, "error": PayNodeException("Receipt already used", ErrorCode.RECEIPT_ALREADY_USED)}
41
+ except Exception as e:
42
+ return {"isValid": False, "error": PayNodeException("Store Error", ErrorCode.INTERNAL_ERROR, details=str(e))}
43
+
44
+ try:
45
+ receipt = self.w3.eth.get_transaction_receipt(tx_hash)
46
+ except Exception:
47
+ return {"isValid": False, "error": PayNodeException("Transaction not found", ErrorCode.TRANSACTION_NOT_FOUND)}
19
48
 
20
- if tx_hash == "0xUsedHash":
21
- return {"isValid": False, "error": MagicError(err_code)}
22
-
23
- receipt = self.w3.eth.get_transaction_receipt(tx_hash)
24
49
  if not receipt:
25
- try: return {"isValid": False, "error": MagicError(ErrorCode.TRANSACTION_NOT_FOUND)}
26
- except AttributeError: return {"isValid": False, "error": MagicError(ErrorCode.PAYNODE_TRANSACTION_NOT_FOUND)}
50
+ return {"isValid": False, "error": PayNodeException("Transaction not found", ErrorCode.TRANSACTION_NOT_FOUND)}
27
51
 
28
52
  if receipt.get("status") == 0:
29
- try: return {"isValid": False, "error": MagicError(ErrorCode.TRANSACTION_FAILED)}
30
- except AttributeError: return {"isValid": False, "error": MagicError(ErrorCode.PAYNODE_TRANSACTION_FAILED)}
53
+ return {"isValid": False, "error": PayNodeException("Transaction failed", ErrorCode.TRANSACTION_FAILED)}
31
54
 
32
- return {"isValid": True}
55
+ if not receipt.get("to") or receipt.get("to", "").lower() != self.contract_address.lower():
56
+ return {"isValid": False, "error": PayNodeException("Wrong contract", ErrorCode.WRONG_CONTRACT)}
33
57
 
34
- class MagicError:
35
- def __init__(self, code):
36
- self.code = code
58
+ contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=PAYNODE_ROUTER_ABI)
59
+
60
+ try:
61
+ logs = contract.events.PaymentReceived().process_receipt(receipt)
62
+ except Exception:
63
+ return {"isValid": False, "error": PayNodeException("Invalid receipt format", ErrorCode.INVALID_RECEIPT)}
64
+
65
+ if not logs:
66
+ return {"isValid": False, "error": PayNodeException("No valid PaymentReceived event found", ErrorCode.INVALID_RECEIPT)}
67
+
68
+ # Find the valid log
69
+ merchant = expected.get("merchantAddress", "").lower()
70
+ token = expected.get("tokenAddress", "").lower()
71
+ amount = int(expected.get("amount", 0))
72
+
73
+ order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
74
+
75
+ valid = False
76
+ for log in logs:
77
+ args = log.args
78
+
79
+ if args.get("orderId") != order_id_bytes:
80
+ continue
81
+
82
+ if args.get("merchant", "").lower() != merchant:
83
+ continue
84
+
85
+ if args.get("token", "").lower() != token:
86
+ continue
87
+
88
+ if args.get("amount", 0) < amount:
89
+ continue
90
+
91
+ if self.chain_id and args.get("chainId") != self.chain_id:
92
+ continue
93
+
94
+ valid = True
95
+ break
96
+
97
+ if not valid:
98
+ return {"isValid": False, "error": PayNodeException("Payment criteria mismatch", ErrorCode.INVALID_RECEIPT)}
99
+
100
+ return {"isValid": True}
paynode_sdk/webhook.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ PayNode Webhook Notifier — monitors on-chain PaymentReceived events
3
+ and delivers structured webhook POSTs to a merchant's endpoint.
4
+
5
+ Features:
6
+ - HMAC-SHA256 signature for authenticity (header: x-paynode-signature)
7
+ - Configurable polling interval
8
+ - Automatic retry with exponential backoff (3 attempts)
9
+ - Async-first design
10
+ """
11
+
12
+ import json
13
+ import time
14
+ import hmac
15
+ import hashlib
16
+ import logging
17
+ import asyncio
18
+ from typing import Optional, Callable, Dict, Any, List
19
+ from web3 import Web3
20
+
21
+ from .constants import PAYNODE_ROUTER_ABI, PAYNODE_ROUTER_ADDRESS
22
+ from .errors import PayNodeException, ErrorCode
23
+
24
+ logger = logging.getLogger("paynode_sdk.webhook")
25
+
26
+
27
+ class PaymentEvent:
28
+ """Parsed PaymentReceived event data."""
29
+
30
+ def __init__(
31
+ self,
32
+ tx_hash: str,
33
+ block_number: int,
34
+ order_id: str,
35
+ merchant: str,
36
+ payer: str,
37
+ token: str,
38
+ amount: int,
39
+ fee: int,
40
+ chain_id: int,
41
+ timestamp: float
42
+ ):
43
+ self.tx_hash = tx_hash
44
+ self.block_number = block_number
45
+ self.order_id = order_id
46
+ self.merchant = merchant
47
+ self.payer = payer
48
+ self.token = token
49
+ self.amount = amount
50
+ self.fee = fee
51
+ self.chain_id = chain_id
52
+ self.timestamp = timestamp
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ return {
56
+ "txHash": self.tx_hash,
57
+ "blockNumber": self.block_number,
58
+ "orderId": self.order_id,
59
+ "merchant": self.merchant,
60
+ "payer": self.payer,
61
+ "token": self.token,
62
+ "amount": str(self.amount),
63
+ "fee": str(self.fee),
64
+ "chainId": str(self.chain_id),
65
+ "timestamp": self.timestamp,
66
+ }
67
+
68
+
69
+ class PayNodeWebhookNotifier:
70
+ """
71
+ Monitors on-chain PaymentReceived events and delivers webhook notifications.
72
+
73
+ Usage:
74
+ notifier = PayNodeWebhookNotifier(
75
+ rpc_url="https://mainnet.base.org",
76
+ contract_address="0x92e20164FC457a2aC35f53D06268168e6352b200",
77
+ webhook_url="https://myshop.com/api/paynode-webhook",
78
+ webhook_secret="whsec_mysecretkey123",
79
+ )
80
+ await notifier.start()
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ rpc_url: str,
86
+ webhook_url: str,
87
+ webhook_secret: str,
88
+ contract_address: Optional[str] = None,
89
+ chain_id: Optional[int] = None,
90
+ poll_interval_seconds: float = 5.0,
91
+ custom_headers: Optional[Dict[str, str]] = None,
92
+ on_error: Optional[Callable[[Exception, PaymentEvent], None]] = None,
93
+ on_success: Optional[Callable[[PaymentEvent], None]] = None,
94
+ ):
95
+ if not rpc_url:
96
+ raise ValueError("rpc_url is required")
97
+ if not webhook_url:
98
+ raise ValueError("webhook_url is required")
99
+ if not webhook_secret:
100
+ raise ValueError("webhook_secret is required")
101
+
102
+ self.contract_address = contract_address or PAYNODE_ROUTER_ADDRESS
103
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
104
+ self.contract = self.w3.eth.contract(
105
+ address=Web3.to_checksum_address(self.contract_address),
106
+ abi=PAYNODE_ROUTER_ABI
107
+ )
108
+ self.webhook_url = webhook_url
109
+ self.webhook_secret = webhook_secret
110
+ self.chain_id = chain_id
111
+ self.poll_interval = poll_interval_seconds
112
+ self.custom_headers = custom_headers or {}
113
+ self.on_error = on_error
114
+ self.on_success = on_success
115
+
116
+ self._last_block: int = 0
117
+ self._running: bool = False
118
+ self._task: Optional[asyncio.Task] = None
119
+
120
+ async def start(self, from_block: Optional[int] = None) -> None:
121
+ """Start polling for PaymentReceived events."""
122
+ if self._running:
123
+ logger.warning("[PayNode Webhook] Already running.")
124
+ return
125
+
126
+ self._last_block = from_block if from_block is not None else self.w3.eth.block_number
127
+ self._running = True
128
+ logger.info(f"🔔 [PayNode Webhook] Listening from block {self._last_block} on {self.contract_address}")
129
+
130
+ self._task = asyncio.create_task(self._poll_loop())
131
+
132
+ async def stop(self) -> None:
133
+ """Stop polling."""
134
+ self._running = False
135
+ if self._task:
136
+ self._task.cancel()
137
+ try:
138
+ await self._task
139
+ except asyncio.CancelledError:
140
+ pass
141
+ self._task = None
142
+ logger.info("🔕 [PayNode Webhook] Stopped.")
143
+
144
+ async def _poll_loop(self) -> None:
145
+ """Main polling loop."""
146
+ while self._running:
147
+ try:
148
+ current_block = self.w3.eth.block_number
149
+ if current_block > self._last_block:
150
+ events = self.contract.events.PaymentReceived().get_logs(
151
+ fromBlock=self._last_block + 1,
152
+ toBlock=current_block
153
+ )
154
+ for event in events:
155
+ payment = self._parse_event(event)
156
+ if payment:
157
+ await self._deliver(payment)
158
+
159
+ self._last_block = current_block
160
+ except Exception as e:
161
+ logger.error(f"[PayNode Webhook] Poll error: {e}")
162
+
163
+ await asyncio.sleep(self.poll_interval)
164
+
165
+ def _parse_event(self, event) -> Optional[PaymentEvent]:
166
+ """Parse a web3 event log into a PaymentEvent."""
167
+ try:
168
+ args = event.args
169
+ return PaymentEvent(
170
+ tx_hash=event.transactionHash.hex() if hasattr(event.transactionHash, 'hex') else str(event.transactionHash),
171
+ block_number=event.blockNumber,
172
+ order_id=args.get("orderId", b"").hex() if isinstance(args.get("orderId"), bytes) else str(args.get("orderId", "")),
173
+ merchant=args.get("merchant", ""),
174
+ payer=args.get("payer", ""),
175
+ token=args.get("token", ""),
176
+ amount=args.get("amount", 0),
177
+ fee=args.get("fee", 0),
178
+ chain_id=args.get("chainId", 0),
179
+ timestamp=time.time(),
180
+ )
181
+ except Exception as e:
182
+ logger.error(f"[PayNode Webhook] Failed to parse event: {e}")
183
+ return None
184
+
185
+ async def _deliver(self, event: PaymentEvent, attempt: int = 1) -> None:
186
+ """Deliver webhook POST with HMAC signature and retry logic."""
187
+ import aiohttp # lazy import to keep dependency optional
188
+
189
+ MAX_RETRIES = 3
190
+
191
+ payload = json.dumps({
192
+ "event": "payment.received",
193
+ "data": event.to_dict()
194
+ })
195
+
196
+ signature = hmac.new(
197
+ self.webhook_secret.encode("utf-8"),
198
+ payload.encode("utf-8"),
199
+ hashlib.sha256
200
+ ).hexdigest()
201
+
202
+ headers = {
203
+ "Content-Type": "application/json",
204
+ "x-paynode-signature": f"sha256={signature}",
205
+ "x-paynode-event": "payment.received",
206
+ "x-paynode-delivery-id": f"{event.tx_hash}-{attempt}",
207
+ **self.custom_headers,
208
+ }
209
+
210
+ try:
211
+ async with aiohttp.ClientSession() as session:
212
+ async with session.post(self.webhook_url, data=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
213
+ if resp.status >= 400:
214
+ raise PayNodeException(
215
+ f"Webhook returned {resp.status}",
216
+ ErrorCode.INTERNAL_ERROR
217
+ )
218
+
219
+ logger.info(f"✅ [PayNode Webhook] Delivered tx {event.tx_hash[:10]}... → {resp.status}")
220
+ if self.on_success:
221
+ self.on_success(event)
222
+
223
+ except Exception as e:
224
+ logger.error(f"[PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
225
+
226
+ if attempt < MAX_RETRIES:
227
+ backoff = (2 ** attempt) # 2s, 4s, 8s
228
+ await asyncio.sleep(backoff)
229
+ return await self._deliver(event, attempt + 1)
230
+
231
+ logger.error(f"[PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
232
+ if self.on_error:
233
+ self.on_error(e, event)
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: paynode-sdk-python
3
+ Version: 1.1.0
4
+ Summary: PayNode Protocol Python SDK for AI Agents
5
+ Author-email: PayNodeLabs <contact@paynode.dev>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PayNodeLabs/paynode-sdk-python
8
+ Keywords: paynode,x402,base,agentic-web3,payments
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.31.0
11
+ Requires-Dist: web3>=6.15.0
12
+ Requires-Dist: python-dotenv>=1.0.1
13
+
14
+ # PayNode Python SDK
15
+
16
+ [![Official Documentation](https://img.shields.io/badge/Docs-docs.paynode.dev-00ff88?style=for-the-badge&logo=readthedocs)](https://docs.paynode.dev)
17
+ [![PyPI Version](https://img.shields.io/pypi/v/paynode-sdk-python.svg?style=for-the-badge)](https://pypi.org/project/paynode-sdk-python/)
18
+
19
+ The official Python SDK for the **PayNode Protocol**. PayNode allows autonomous AI Agents to seamlessly pay for APIs and computational resources using USDC on Base L2, utilizing the standardized HTTP 402 protocol.
20
+
21
+ ## 📖 Read the Docs
22
+
23
+ **For complete installation guides, advanced usage, API references, and architecture details, please visit our official documentation:**
24
+ 👉 **[docs.paynode.dev](https://docs.paynode.dev)**
25
+
26
+ ## ⚡ Quick Start
27
+
28
+ ### Installation
29
+
30
+ ```bash
31
+ pip install paynode-sdk-python web3
32
+ ```
33
+
34
+ ### Agent Client (Payer)
35
+
36
+ ```python
37
+ from paynode_sdk import Client
38
+
39
+ agent = Client(private_key="YOUR_AGENT_PRIVATE_KEY")
40
+
41
+ # Automatically handles the 402 challenge, executes the Base L2 transaction, and gets the data.
42
+ response = agent.get("https://api.merchant.com/premium-data")
43
+
44
+ print(response.json())
45
+ ```
46
+
47
+ ### Merchant Middleware (FastAPI Receiver)
48
+
49
+ ```python
50
+ from fastapi import FastAPI, Depends
51
+ from paynode_sdk.middleware import PayNodeMiddleware
52
+
53
+ app = FastAPI()
54
+
55
+ # Protect routes with a 1.50 USDC fee requirement
56
+ require_payment = PayNodeMiddleware(
57
+ price=1.50,
58
+ merchant_wallet="0xYourWalletAddress..."
59
+ )
60
+
61
+ @app.get("/premium-data", dependencies=[Depends(require_payment)])
62
+ def get_premium_data():
63
+ return {"secret": "This is paid M2M data."}
64
+ ```
65
+
66
+ ---
67
+ *Built for the Autonomous AI Economy by PayNodeLabs.*
@@ -0,0 +1,12 @@
1
+ paynode_sdk/__init__.py,sha256=dK8m7FfOsWUJakKqHaY44x-xC-uUWVwDrXa0G_zUs8E,562
2
+ paynode_sdk/client.py,sha256=NWzsb9OryI5EEld-L-svQHk6sVwbCulepB7xSIAVr6A,10777
3
+ paynode_sdk/constants.py,sha256=kmE45nR7XKUwn-FEt8CvcvzH3VoQbT-MD2mt71LPVEk,621
4
+ paynode_sdk/errors.py,sha256=LaTwDJxinKYd2UD58lyANRm-rLXF3KSb1XHvIKOcgt0,1075
5
+ paynode_sdk/idempotency.py,sha256=od7HuSxFdejBP0oE4QCzbJdrDZWvziiu09d3BRErU2k,999
6
+ paynode_sdk/middleware.py,sha256=dg3hSmHAjW8wTF3AH3id8LFxbN2kXKKUsBRp1gyc6qw,3292
7
+ paynode_sdk/verifier.py,sha256=huMXbBF361Yj9HLehaY34ToGBrYSuy5Uy8Jau3Seg7k,4414
8
+ paynode_sdk/webhook.py,sha256=KxlXGkXe3wpR8ueO8FMW2iypJgWaLwMfxAAZLNRvNDo,8302
9
+ paynode_sdk_python-1.1.0.dist-info/METADATA,sha256=_18F4H7sRGtdYA9-lA7kdEiO_n2Xnrv6Y_8XN-nj1VQ,2087
10
+ paynode_sdk_python-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ paynode_sdk_python-1.1.0.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
12
+ paynode_sdk_python-1.1.0.dist-info/RECORD,,
@@ -1,85 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: paynode-sdk-python
3
- Version: 1.0.1
4
- Summary: PayNode Protocol Python SDK for AI Agents
5
- Author-email: PayNodeLabs <contact@paynode.dev>
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/PayNodeLabs/paynode-sdk-python
8
- Keywords: paynode,x402,base,agentic-web3,payments
9
- Description-Content-Type: text/markdown
10
- Requires-Dist: requests>=2.31.0
11
- Requires-Dist: web3>=6.15.0
12
- Requires-Dist: python-dotenv>=1.0.1
13
-
14
- # PayNode Python SDK
15
-
16
- 为 Python 开发者提供的 PayNode 支付网关 SDK,支持 FastAPI、Flask 等主流 Web 框架。实现 M2M 场景下的 x402 握手与链上支付验证。
17
-
18
- ## 📦 安装
19
-
20
- ```bash
21
- pip install paynode-sdk
22
- ```
23
-
24
- ## 🚀 FastAPI Middleware 初始化示例
25
-
26
- 通过注入 `PayNodeMiddleware`,您可以轻松地将任何 API 端点转变为收费接口。
27
-
28
- ```python
29
- from fastapi import FastAPI, Request
30
- from paynode_sdk import PayNodeMiddleware
31
-
32
- app = FastAPI()
33
-
34
- # 1. 初始化 PayNode 中间件
35
- paynode = PayNodeMiddleware(
36
- rpc_url="https://mainnet.base.org", # RPC 节点地址
37
- contract_address="0x...", # PayNodeRouter 合约地址
38
- merchant_address="0x...", # 商家收款钱包地址
39
- chain_id=8453, # 链 ID (Base: 8453)
40
- currency="USDC", # 计价单位
41
- token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC 地址
42
- price="0.01", # 每次调用的价格
43
- decimals=6 # 代币精度
44
- )
45
-
46
- # 2. 挂载中间件
47
- @app.middleware("http")
48
- async def paynode_gate(request: Request, call_next):
49
- # 此中间件会自动处理 402 握手及 x-paynode-receipt 验证
50
- return await paynode(request, call_next)
51
-
52
- # 3. 受保护的路由
53
- @app.get("/api/ai-vision")
54
- async def ai_feature():
55
- return {"message": "Success! The agent has paid for this API call."}
56
- ```
57
-
58
- ## 🧪 测试与开发
59
-
60
- SDK 采用严谨的代码审计标准,所有核心逻辑均经过多层验证。
61
-
62
- ### 运行测试
63
-
64
- 使用 `pytest` 运行测试套件。确保已配置 `PYTHONPATH` 以正确加载本地包。
65
-
66
- ```bash
67
- # 运行所有验证逻辑测试
68
- PYTHONPATH=. pytest tests/
69
- ```
70
-
71
- ### 开发模式
72
-
73
- 如果需要修改 `paynode_sdk` 并即时测试:
74
-
75
- ```bash
76
- pip install -e .
77
- ```
78
-
79
- ## ⚙️ 验证逻辑详解 (Verifier)
80
-
81
- `PayNodeVerifier` 直接通过 Web3.py 与以太坊节点交互。验证过程包括:
82
- - **交易状态确认:** 检查交易哈希是否已上链并成功 (Status 1)。
83
- - **合约交互验证:** 解析交易数据,确认其调用的是 `PayNodeRouter` 的 `pay` 函数。
84
- - **金额与代币校验:** 严格匹配转账金额与指定的代币地址,防止恶意 Agent 使用虚假代币支付。
85
- - **商户一致性:** 确认资金最终流向了预设的商户钱包。
@@ -1,11 +0,0 @@
1
- paynode_sdk/__init__.py,sha256=qIoMcnfe3dviK6E3E0yMYukuvbO3Nmv0Cpuaeptgx3s,325
2
- paynode_sdk/client.py,sha256=3_Rux6DZyR6dABLjYaAtBmBtPczlLVpyvuDXc6UzFuQ,4933
3
- paynode_sdk/constants.py,sha256=1WHbiwQSrSUI6jj4xo7ISak_X0eDZMgFnCbeLzUl3JM,309
4
- paynode_sdk/errors.py,sha256=ZiuYEvP8zMXpfmi3VEdGyBX3VXqj4TdrRS-WucrdSew,977
5
- paynode_sdk/idempotency.py,sha256=od7HuSxFdejBP0oE4QCzbJdrDZWvziiu09d3BRErU2k,999
6
- paynode_sdk/middleware.py,sha256=dg3hSmHAjW8wTF3AH3id8LFxbN2kXKKUsBRp1gyc6qw,3292
7
- paynode_sdk/verifier.py,sha256=YkYUBgt5RPXigZsHNvdcca290YQyoOVijIa6f5SlHLw,1572
8
- paynode_sdk_python-1.0.1.dist-info/METADATA,sha256=EcJUyKLTNco58bzxeuzOEd73Fvp3ScRS8SsVYwB9OHk,2852
9
- paynode_sdk_python-1.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
- paynode_sdk_python-1.0.1.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
11
- paynode_sdk_python-1.0.1.dist-info/RECORD,,