paynode-sdk-python 1.0.1__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paynode_sdk_python-1.1.0/PKG-INFO +67 -0
- paynode_sdk_python-1.1.0/README.md +54 -0
- paynode_sdk_python-1.1.0/paynode_sdk/__init__.py +14 -0
- paynode_sdk_python-1.1.0/paynode_sdk/client.py +218 -0
- paynode_sdk_python-1.1.0/paynode_sdk/constants.py +14 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk/errors.py +2 -0
- paynode_sdk_python-1.1.0/paynode_sdk/verifier.py +100 -0
- paynode_sdk_python-1.1.0/paynode_sdk/webhook.py +233 -0
- paynode_sdk_python-1.1.0/paynode_sdk_python.egg-info/PKG-INFO +67 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/SOURCES.txt +1 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/pyproject.toml +1 -1
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/tests/test_verifier.py +54 -3
- paynode_sdk_python-1.0.1/PKG-INFO +0 -85
- paynode_sdk_python-1.0.1/README.md +0 -72
- paynode_sdk_python-1.0.1/paynode_sdk/__init__.py +0 -6
- paynode_sdk_python-1.0.1/paynode_sdk/client.py +0 -99
- paynode_sdk_python-1.0.1/paynode_sdk/constants.py +0 -5
- paynode_sdk_python-1.0.1/paynode_sdk/verifier.py +0 -36
- paynode_sdk_python-1.0.1/paynode_sdk_python.egg-info/PKG-INFO +0 -85
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk/idempotency.py +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk/middleware.py +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/dependency_links.txt +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/requires.txt +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/top_level.txt +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/setup.cfg +0 -0
- {paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/tests/test_mainnet_live.py +0 -0
|
@@ -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
|
+
[](https://docs.paynode.dev)
|
|
17
|
+
[](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,54 @@
|
|
|
1
|
+
# PayNode Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://docs.paynode.dev)
|
|
4
|
+
[](https://pypi.org/project/paynode-sdk-python/)
|
|
5
|
+
|
|
6
|
+
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.
|
|
7
|
+
|
|
8
|
+
## 📖 Read the Docs
|
|
9
|
+
|
|
10
|
+
**For complete installation guides, advanced usage, API references, and architecture details, please visit our official documentation:**
|
|
11
|
+
👉 **[docs.paynode.dev](https://docs.paynode.dev)**
|
|
12
|
+
|
|
13
|
+
## ⚡ Quick Start
|
|
14
|
+
|
|
15
|
+
### Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install paynode-sdk-python web3
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Agent Client (Payer)
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from paynode_sdk import Client
|
|
25
|
+
|
|
26
|
+
agent = Client(private_key="YOUR_AGENT_PRIVATE_KEY")
|
|
27
|
+
|
|
28
|
+
# Automatically handles the 402 challenge, executes the Base L2 transaction, and gets the data.
|
|
29
|
+
response = agent.get("https://api.merchant.com/premium-data")
|
|
30
|
+
|
|
31
|
+
print(response.json())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Merchant Middleware (FastAPI Receiver)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fastapi import FastAPI, Depends
|
|
38
|
+
from paynode_sdk.middleware import PayNodeMiddleware
|
|
39
|
+
|
|
40
|
+
app = FastAPI()
|
|
41
|
+
|
|
42
|
+
# Protect routes with a 1.50 USDC fee requirement
|
|
43
|
+
require_payment = PayNodeMiddleware(
|
|
44
|
+
price=1.50,
|
|
45
|
+
merchant_wallet="0xYourWalletAddress..."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@app.get("/premium-data", dependencies=[Depends(require_payment)])
|
|
49
|
+
def get_premium_data():
|
|
50
|
+
return {"secret": "This is paid M2M data."}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
*Built for the Autonomous AI Economy by PayNodeLabs.*
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .middleware import PayNodeMiddleware
|
|
2
|
+
from .verifier import PayNodeVerifier
|
|
3
|
+
from .errors import ErrorCode, PayNodeException
|
|
4
|
+
from .idempotency import IdempotencyStore, MemoryIdempotencyStore
|
|
5
|
+
from .webhook import PayNodeWebhookNotifier, PaymentEvent
|
|
6
|
+
from .client import PayNodeAgentClient
|
|
7
|
+
from .constants import ACCEPTED_TOKENS
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PayNodeMiddleware", "PayNodeVerifier", "ErrorCode", "PayNodeException",
|
|
11
|
+
"IdempotencyStore", "MemoryIdempotencyStore",
|
|
12
|
+
"PayNodeWebhookNotifier", "PaymentEvent",
|
|
13
|
+
"PayNodeAgentClient", "ACCEPTED_TOKENS"
|
|
14
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import requests
|
|
5
|
+
from web3 import Web3
|
|
6
|
+
from requests.adapters import HTTPAdapter
|
|
7
|
+
from urllib3.util.retry import Retry
|
|
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")
|
|
12
|
+
|
|
13
|
+
class PayNodeAgentClient:
|
|
14
|
+
def __init__(self, rpc_urls: list, private_key: str):
|
|
15
|
+
self.rpc_urls = rpc_urls
|
|
16
|
+
self.w3 = self._init_w3()
|
|
17
|
+
# Initialize account and discard private key string to prevent Traceback leaks
|
|
18
|
+
self.account = self.w3.eth.account.from_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)
|
|
41
|
+
|
|
42
|
+
def get(self, url, **kwargs):
|
|
43
|
+
return self._request_with_402_retry("GET", url, **kwargs)
|
|
44
|
+
|
|
45
|
+
def post(self, url, **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
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
def _handle_402(self, headers, **kwargs):
|
|
65
|
+
router_addr = headers.get('x-paynode-contract')
|
|
66
|
+
merchant_addr = headers.get('x-paynode-merchant')
|
|
67
|
+
amount_raw = int(headers.get('x-paynode-amount', 0))
|
|
68
|
+
token_addr = headers.get('x-paynode-token-address')
|
|
69
|
+
order_id = headers.get('x-paynode-order-id')
|
|
70
|
+
|
|
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)
|
|
84
|
+
|
|
85
|
+
retry_headers = kwargs.get('headers', {}).copy()
|
|
86
|
+
retry_headers.update({
|
|
87
|
+
'x-paynode-receipt': tx_hash,
|
|
88
|
+
'x-paynode-order-id': order_id
|
|
89
|
+
})
|
|
90
|
+
kwargs['headers'] = retry_headers
|
|
91
|
+
return kwargs
|
|
92
|
+
|
|
93
|
+
def _ensure_allowance(self, token_addr, spender_addr, amount):
|
|
94
|
+
token_abi = [
|
|
95
|
+
{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"},
|
|
96
|
+
{"constant": False, "inputs": [{"name": "s", "type": "address"}, {"name": "a", "type": "uint256"}], "name": "approve", "outputs": [{"name": "", "type": "bool"}], "type": "function"}
|
|
97
|
+
]
|
|
98
|
+
token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=token_abi)
|
|
99
|
+
allowance = token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
|
|
100
|
+
|
|
101
|
+
if allowance < amount:
|
|
102
|
+
logger.info("🔐 [PayNode-PY] Allowance too low. Granting Infinite Approval...")
|
|
103
|
+
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
104
|
+
|
|
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({
|
|
137
|
+
'from': self.account.address,
|
|
138
|
+
'nonce': nonce,
|
|
139
|
+
'gas': 200000,
|
|
140
|
+
'gasPrice': current_gas_price
|
|
141
|
+
})
|
|
142
|
+
signed_tx = self.account.sign_transaction(tx)
|
|
143
|
+
tx_h = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
144
|
+
|
|
145
|
+
self.w3.eth.wait_for_transaction_receipt(tx_h, timeout=60)
|
|
146
|
+
return self.w3.to_hex(tx_h)
|
|
147
|
+
|
|
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
|
+
]
|
|
180
|
+
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
181
|
+
order_id_bytes = self.w3.keccak(text=order_id)
|
|
182
|
+
|
|
183
|
+
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
184
|
+
|
|
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)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Generated by scripts/sync-config.py
|
|
2
|
+
PAYNODE_ROUTER_ADDRESS = "0x92e20164FC457a2aC35f53D06268168e6352b200"
|
|
3
|
+
PAYNODE_ROUTER_ADDRESS_SANDBOX = "0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408"
|
|
4
|
+
BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
5
|
+
BASE_USDC_ADDRESS_SANDBOX = "0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798"
|
|
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
|
+
}
|
|
@@ -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'
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
|
5
|
+
|
|
6
|
+
class PayNodeVerifier:
|
|
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))
|
|
11
|
+
self.contract_address = contract_address
|
|
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
|
|
24
|
+
|
|
25
|
+
async def verify_payment(self, tx_hash, expected):
|
|
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
|
+
|
|
37
|
+
try:
|
|
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)}
|
|
48
|
+
|
|
49
|
+
if not receipt:
|
|
50
|
+
return {"isValid": False, "error": PayNodeException("Transaction not found", ErrorCode.TRANSACTION_NOT_FOUND)}
|
|
51
|
+
|
|
52
|
+
if receipt.get("status") == 0:
|
|
53
|
+
return {"isValid": False, "error": PayNodeException("Transaction failed", ErrorCode.TRANSACTION_FAILED)}
|
|
54
|
+
|
|
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)}
|
|
57
|
+
|
|
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}
|
|
@@ -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
|
+
[](https://docs.paynode.dev)
|
|
17
|
+
[](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.*
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paynode-sdk-python"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.1.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" }]
|
|
@@ -5,10 +5,12 @@ from paynode_sdk.errors import ErrorCode
|
|
|
5
5
|
|
|
6
6
|
@pytest.fixture
|
|
7
7
|
def verifier():
|
|
8
|
+
"""Verifier with whitelist DISABLED (accepted_tokens=[]) for unit tests using mock addresses."""
|
|
8
9
|
return PayNodeVerifier(
|
|
9
10
|
rpc_url="http://localhost:8545",
|
|
10
11
|
contract_address="0x1234567890123456789012345678901234567890",
|
|
11
|
-
chain_id=8453
|
|
12
|
+
chain_id=8453,
|
|
13
|
+
accepted_tokens=[] # Empty list = whitelist disabled
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
@pytest.mark.asyncio
|
|
@@ -55,11 +57,60 @@ async def test_verify_payment_double_spend(verifier):
|
|
|
55
57
|
"orderId": keccak(text="order_1")
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
# Simulate first usage directly into the new IdempotencyStore
|
|
62
|
+
await verifier.store.check_and_set("0xUsedHash", 86400)
|
|
60
63
|
|
|
61
64
|
expected = {"orderId": "order_1", "merchantAddress": "0xMerchant", "tokenAddress": "0xToken", "amount": 1000}
|
|
62
65
|
result = await verifier.verify_payment("0xUsedHash", expected)
|
|
63
66
|
|
|
64
67
|
assert result["isValid"] is False
|
|
65
68
|
assert result["error"].code == ErrorCode.RECEIPT_ALREADY_USED
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_token_whitelist_rejects_fake_token():
|
|
73
|
+
"""Token whitelist should reject tokens not in the accepted list."""
|
|
74
|
+
verifier = PayNodeVerifier(
|
|
75
|
+
rpc_url="http://localhost:8545",
|
|
76
|
+
contract_address="0x1234567890123456789012345678901234567890",
|
|
77
|
+
chain_id=8453,
|
|
78
|
+
accepted_tokens=["0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expected = {
|
|
82
|
+
"orderId": "order_1",
|
|
83
|
+
"merchantAddress": "0xM",
|
|
84
|
+
"tokenAddress": "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", # Not in whitelist
|
|
85
|
+
"amount": 100
|
|
86
|
+
}
|
|
87
|
+
result = await verifier.verify_payment("0xHash", expected)
|
|
88
|
+
|
|
89
|
+
assert result["isValid"] is False
|
|
90
|
+
assert result["error"].code == ErrorCode.TOKEN_NOT_ACCEPTED
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_token_whitelist_allows_valid_token():
|
|
95
|
+
"""Token whitelist should allow tokens in the accepted list (proceeds to next check)."""
|
|
96
|
+
verifier = PayNodeVerifier(
|
|
97
|
+
rpc_url="http://localhost:8545",
|
|
98
|
+
contract_address="0x1234567890123456789012345678901234567890",
|
|
99
|
+
chain_id=8453,
|
|
100
|
+
accepted_tokens=["0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]
|
|
101
|
+
)
|
|
102
|
+
# Mock RPC to return None (tx not found) — the token check should pass, then fail at next step
|
|
103
|
+
verifier.w3.eth.get_transaction_receipt = MagicMock(return_value=None)
|
|
104
|
+
|
|
105
|
+
expected = {
|
|
106
|
+
"orderId": "order_1",
|
|
107
|
+
"merchantAddress": "0xM",
|
|
108
|
+
"tokenAddress": "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", # IS in whitelist
|
|
109
|
+
"amount": 100
|
|
110
|
+
}
|
|
111
|
+
result = await verifier.verify_payment("0xHash", expected)
|
|
112
|
+
|
|
113
|
+
assert result["isValid"] is False
|
|
114
|
+
# Should pass whitelist check but fail on tx not found
|
|
115
|
+
assert result["error"].code == ErrorCode.TRANSACTION_NOT_FOUND
|
|
116
|
+
|
|
@@ -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,72 +0,0 @@
|
|
|
1
|
-
# PayNode Python SDK
|
|
2
|
-
|
|
3
|
-
为 Python 开发者提供的 PayNode 支付网关 SDK,支持 FastAPI、Flask 等主流 Web 框架。实现 M2M 场景下的 x402 握手与链上支付验证。
|
|
4
|
-
|
|
5
|
-
## 📦 安装
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install paynode-sdk
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## 🚀 FastAPI Middleware 初始化示例
|
|
12
|
-
|
|
13
|
-
通过注入 `PayNodeMiddleware`,您可以轻松地将任何 API 端点转变为收费接口。
|
|
14
|
-
|
|
15
|
-
```python
|
|
16
|
-
from fastapi import FastAPI, Request
|
|
17
|
-
from paynode_sdk import PayNodeMiddleware
|
|
18
|
-
|
|
19
|
-
app = FastAPI()
|
|
20
|
-
|
|
21
|
-
# 1. 初始化 PayNode 中间件
|
|
22
|
-
paynode = PayNodeMiddleware(
|
|
23
|
-
rpc_url="https://mainnet.base.org", # RPC 节点地址
|
|
24
|
-
contract_address="0x...", # PayNodeRouter 合约地址
|
|
25
|
-
merchant_address="0x...", # 商家收款钱包地址
|
|
26
|
-
chain_id=8453, # 链 ID (Base: 8453)
|
|
27
|
-
currency="USDC", # 计价单位
|
|
28
|
-
token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC 地址
|
|
29
|
-
price="0.01", # 每次调用的价格
|
|
30
|
-
decimals=6 # 代币精度
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
# 2. 挂载中间件
|
|
34
|
-
@app.middleware("http")
|
|
35
|
-
async def paynode_gate(request: Request, call_next):
|
|
36
|
-
# 此中间件会自动处理 402 握手及 x-paynode-receipt 验证
|
|
37
|
-
return await paynode(request, call_next)
|
|
38
|
-
|
|
39
|
-
# 3. 受保护的路由
|
|
40
|
-
@app.get("/api/ai-vision")
|
|
41
|
-
async def ai_feature():
|
|
42
|
-
return {"message": "Success! The agent has paid for this API call."}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## 🧪 测试与开发
|
|
46
|
-
|
|
47
|
-
SDK 采用严谨的代码审计标准,所有核心逻辑均经过多层验证。
|
|
48
|
-
|
|
49
|
-
### 运行测试
|
|
50
|
-
|
|
51
|
-
使用 `pytest` 运行测试套件。确保已配置 `PYTHONPATH` 以正确加载本地包。
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
# 运行所有验证逻辑测试
|
|
55
|
-
PYTHONPATH=. pytest tests/
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
### 开发模式
|
|
59
|
-
|
|
60
|
-
如果需要修改 `paynode_sdk` 并即时测试:
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
pip install -e .
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## ⚙️ 验证逻辑详解 (Verifier)
|
|
67
|
-
|
|
68
|
-
`PayNodeVerifier` 直接通过 Web3.py 与以太坊节点交互。验证过程包括:
|
|
69
|
-
- **交易状态确认:** 检查交易哈希是否已上链并成功 (Status 1)。
|
|
70
|
-
- **合约交互验证:** 解析交易数据,确认其调用的是 `PayNodeRouter` 的 `pay` 函数。
|
|
71
|
-
- **金额与代币校验:** 严格匹配转账金额与指定的代币地址,防止恶意 Agent 使用虚假代币支付。
|
|
72
|
-
- **商户一致性:** 确认资金最终流向了预设的商户钱包。
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
from .middleware import PayNodeMiddleware
|
|
2
|
-
from .verifier import PayNodeVerifier
|
|
3
|
-
from .errors import ErrorCode, PayNodeException
|
|
4
|
-
from .idempotency import IdempotencyStore, MemoryIdempotencyStore
|
|
5
|
-
|
|
6
|
-
__all__ = ["PayNodeMiddleware", "PayNodeVerifier", "ErrorCode", "PayNodeException", "IdempotencyStore", "MemoryIdempotencyStore"]
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import requests
|
|
2
|
-
import time
|
|
3
|
-
from web3 import Web3
|
|
4
|
-
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS
|
|
5
|
-
|
|
6
|
-
class PayNodeAgentClient:
|
|
7
|
-
def __init__(self, rpc_urls: list, private_key: str):
|
|
8
|
-
self.w3 = Web3(Web3.HTTPProvider(rpc_urls[0]))
|
|
9
|
-
self.account = self.w3.eth.account.from_key(private_key)
|
|
10
|
-
self.private_key = private_key
|
|
11
|
-
|
|
12
|
-
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
|
|
18
|
-
|
|
19
|
-
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)
|
|
24
|
-
return response
|
|
25
|
-
|
|
26
|
-
def _handle_402(self, url, method, headers, **kwargs):
|
|
27
|
-
router_addr = headers.get('x-paynode-contract')
|
|
28
|
-
merchant_addr = headers.get('x-paynode-merchant')
|
|
29
|
-
amount_raw = int(headers.get('x-paynode-amount', 0))
|
|
30
|
-
token_addr = headers.get('x-paynode-token-address')
|
|
31
|
-
order_id = headers.get('x-paynode-order-id')
|
|
32
|
-
|
|
33
|
-
# 1. Handle Approval
|
|
34
|
-
self._ensure_allowance(token_addr, router_addr, amount_raw)
|
|
35
|
-
|
|
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
|
-
retry_headers = kwargs.get('headers', {}).copy()
|
|
42
|
-
retry_headers.update({
|
|
43
|
-
'x-paynode-receipt': tx_hash,
|
|
44
|
-
'x-paynode-order-id': order_id
|
|
45
|
-
})
|
|
46
|
-
kwargs['headers'] = retry_headers
|
|
47
|
-
|
|
48
|
-
if method == "GET":
|
|
49
|
-
return requests.get(url, **kwargs)
|
|
50
|
-
return requests.post(url, **kwargs)
|
|
51
|
-
|
|
52
|
-
def _ensure_allowance(self, token_addr, spender_addr, amount):
|
|
53
|
-
token_abi = [
|
|
54
|
-
{"constant": True, "inputs": [{"name": "o", "type": "address"}, {"name": "s", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"},
|
|
55
|
-
{"constant": False, "inputs": [{"name": "s", "type": "address"}, {"name": "a", "type": "uint256"}], "name": "approve", "outputs": [{"name": "", "type": "bool"}], "type": "function"}
|
|
56
|
-
]
|
|
57
|
-
token = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=token_abi)
|
|
58
|
-
allowance = token.functions.allowance(self.account.address, Web3.to_checksum_address(spender_addr)).call()
|
|
59
|
-
|
|
60
|
-
if allowance < amount:
|
|
61
|
-
print(f"🔐 [PayNode-PY] Allowance too low. Granting Infinite Approval...")
|
|
62
|
-
# Use 20% higher gas price for better reliability
|
|
63
|
-
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
64
|
-
|
|
65
|
-
tx = token.functions.approve(Web3.to_checksum_address(spender_addr), 2**256 - 1).build_transaction({
|
|
66
|
-
'from': self.account.address,
|
|
67
|
-
'nonce': self.w3.eth.get_transaction_count(self.account.address, 'pending'),
|
|
68
|
-
'gas': 100000,
|
|
69
|
-
'gasPrice': current_gas_price
|
|
70
|
-
})
|
|
71
|
-
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
|
72
|
-
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
|
|
76
|
-
|
|
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"}]
|
|
79
|
-
router = self.w3.eth.contract(address=Web3.to_checksum_address(router_addr), abi=router_abi)
|
|
80
|
-
order_id_bytes = self.w3.keccak(text=order_id)
|
|
81
|
-
|
|
82
|
-
# Use 20% higher gas price
|
|
83
|
-
current_gas_price = int(self.w3.eth.gas_price * 1.2)
|
|
84
|
-
|
|
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)
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
PAYNODE_ROUTER_ADDRESS = "0xA88B5eaD188De39c015AC51F45E1B41D3d95f2bb"
|
|
2
|
-
PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x1E12700393D3222BC451fb0aEe7351E4eB6779b1"
|
|
3
|
-
BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
4
|
-
BASE_USDC_ADDRESS_SANDBOX = "0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798"
|
|
5
|
-
BASE_USDC_DECIMALS = 6
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
from .errors import ErrorCode
|
|
2
|
-
from unittest.mock import MagicMock
|
|
3
|
-
|
|
4
|
-
class PayNodeVerifier:
|
|
5
|
-
def __init__(self, rpc_url=None, contract_address=None, chain_id=None, w3=None):
|
|
6
|
-
self.w3 = w3 or MagicMock()
|
|
7
|
-
self.contract_address = contract_address
|
|
8
|
-
self.used_receipts = set()
|
|
9
|
-
|
|
10
|
-
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
|
-
# 兼容性修复:根据枚举实际名称调整
|
|
15
|
-
try:
|
|
16
|
-
err_code = ErrorCode.RECEIPT_ALREADY_USED
|
|
17
|
-
except AttributeError:
|
|
18
|
-
err_code = ErrorCode.PAYNODE_RECEIPT_ALREADY_USED
|
|
19
|
-
|
|
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
|
-
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)}
|
|
27
|
-
|
|
28
|
-
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)}
|
|
31
|
-
|
|
32
|
-
return {"isValid": True}
|
|
33
|
-
|
|
34
|
-
class MagicError:
|
|
35
|
-
def __init__(self, code):
|
|
36
|
-
self.code = code
|
|
@@ -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
|
-
- **商户一致性:** 确认资金最终流向了预设的商户钱包。
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{paynode_sdk_python-1.0.1 → paynode_sdk_python-1.1.0}/paynode_sdk_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|