paynode-sdk-python 1.1.3__py3-none-any.whl → 1.4.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 +14 -4
- paynode_sdk/client.py +25 -9
- paynode_sdk/constants.py +4 -4
- paynode_sdk/errors.py +19 -3
- paynode_sdk/middleware.py +23 -6
- paynode_sdk/verifier.py +44 -28
- paynode_sdk/webhook.py +2 -2
- {paynode_sdk_python-1.1.3.dist-info → paynode_sdk_python-1.4.0.dist-info}/METADATA +11 -3
- paynode_sdk_python-1.4.0.dist-info/RECORD +13 -0
- paynode_sdk_python-1.1.3.dist-info/RECORD +0 -13
- {paynode_sdk_python-1.1.3.dist-info → paynode_sdk_python-1.4.0.dist-info}/WHEEL +0 -0
- {paynode_sdk_python-1.1.3.dist-info → paynode_sdk_python-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {paynode_sdk_python-1.1.3.dist-info → paynode_sdk_python-1.4.0.dist-info}/top_level.txt +0 -0
paynode_sdk/__init__.py
CHANGED
|
@@ -5,17 +5,27 @@ import logging
|
|
|
5
5
|
# to ensure a clean experience for PayNode SDK users.
|
|
6
6
|
warnings.filterwarnings("ignore", category=DeprecationWarning, module="websockets.legacy")
|
|
7
7
|
|
|
8
|
-
from .middleware import PayNodeMiddleware
|
|
8
|
+
from .middleware import PayNodeMiddleware, x402_gate
|
|
9
9
|
from .verifier import PayNodeVerifier
|
|
10
10
|
from .errors import ErrorCode, PayNodeException
|
|
11
11
|
from .idempotency import IdempotencyStore, MemoryIdempotencyStore
|
|
12
12
|
from .webhook import PayNodeWebhookNotifier, PaymentEvent
|
|
13
13
|
from .client import PayNodeAgentClient
|
|
14
|
-
from .constants import
|
|
14
|
+
from .constants import (
|
|
15
|
+
PAYNODE_ROUTER_ADDRESS,
|
|
16
|
+
PAYNODE_ROUTER_ADDRESS_SANDBOX,
|
|
17
|
+
BASE_USDC_ADDRESS,
|
|
18
|
+
BASE_USDC_ADDRESS_SANDBOX,
|
|
19
|
+
ACCEPTED_TOKENS,
|
|
20
|
+
MIN_PAYMENT_AMOUNT
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
__all__ = [
|
|
17
|
-
"PayNodeMiddleware", "PayNodeVerifier", "ErrorCode", "PayNodeException",
|
|
24
|
+
"PayNodeMiddleware", "x402_gate", "PayNodeVerifier", "ErrorCode", "PayNodeException",
|
|
18
25
|
"IdempotencyStore", "MemoryIdempotencyStore",
|
|
19
26
|
"PayNodeWebhookNotifier", "PaymentEvent",
|
|
20
|
-
"PayNodeAgentClient",
|
|
27
|
+
"PayNodeAgentClient",
|
|
28
|
+
"PAYNODE_ROUTER_ADDRESS", "PAYNODE_ROUTER_ADDRESS_SANDBOX",
|
|
29
|
+
"BASE_USDC_ADDRESS", "BASE_USDC_ADDRESS_SANDBOX",
|
|
30
|
+
"ACCEPTED_TOKENS", "MIN_PAYMENT_AMOUNT"
|
|
21
31
|
]
|
paynode_sdk/client.py
CHANGED
|
@@ -6,7 +6,7 @@ from eth_account.messages import encode_typed_data
|
|
|
6
6
|
from web3 import Web3
|
|
7
7
|
from requests.adapters import HTTPAdapter
|
|
8
8
|
from urllib3.util.retry import Retry
|
|
9
|
-
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS
|
|
9
|
+
from .constants import PAYNODE_ROUTER_ADDRESS, BASE_USDC_ADDRESS, BASE_USDC_DECIMALS, BASE_RPC_URLS, ACCEPTED_TOKENS
|
|
10
10
|
from .errors import PayNodeException, ErrorCode
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger("paynode_sdk.client")
|
|
@@ -17,7 +17,7 @@ class PayNodeAgentClient:
|
|
|
17
17
|
Automatically handles the x402 'Payment Required' handshake.
|
|
18
18
|
Supports RPC redundancy and EIP-2612 Permit-First payments.
|
|
19
19
|
"""
|
|
20
|
-
def __init__(self, private_key: str, rpc_urls: list | str =
|
|
20
|
+
def __init__(self, private_key: str, rpc_urls: list | str = BASE_RPC_URLS):
|
|
21
21
|
self.rpc_urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
|
|
22
22
|
self.w3 = self._init_w3()
|
|
23
23
|
|
|
@@ -46,7 +46,7 @@ class PayNodeAgentClient:
|
|
|
46
46
|
except Exception as e:
|
|
47
47
|
logger.warning(f"⚠️ [PayNode-PY] RPC {rpc} failed: {str(e)}")
|
|
48
48
|
continue
|
|
49
|
-
raise PayNodeException(
|
|
49
|
+
raise PayNodeException(ErrorCode.rpc_error)
|
|
50
50
|
|
|
51
51
|
def request_gate(self, url: str, method: str = "GET", **kwargs):
|
|
52
52
|
"""The high-level autonomous method handling 402 loop."""
|
|
@@ -67,7 +67,7 @@ class PayNodeAgentClient:
|
|
|
67
67
|
kwargs = self._handle_402(response.headers, **kwargs)
|
|
68
68
|
except Exception as e:
|
|
69
69
|
if isinstance(e, PayNodeException): raise
|
|
70
|
-
raise PayNodeException(f"An unexpected error occurred: {str(e)}"
|
|
70
|
+
raise PayNodeException(ErrorCode.internal_error, message=f"An unexpected error occurred: {str(e)}")
|
|
71
71
|
continue
|
|
72
72
|
return response
|
|
73
73
|
return response
|
|
@@ -78,13 +78,29 @@ class PayNodeAgentClient:
|
|
|
78
78
|
amount_raw = int(headers.get('x-paynode-amount', 0))
|
|
79
79
|
token_addr = headers.get('x-paynode-token-address')
|
|
80
80
|
order_id = headers.get('x-paynode-order-id')
|
|
81
|
+
currency = headers.get('x-paynode-currency', 'USDC')
|
|
82
|
+
chain_id_header = headers.get('x-paynode-chain-id')
|
|
81
83
|
|
|
82
84
|
if not all([router_addr, merchant_addr, amount_raw, token_addr, order_id]):
|
|
83
|
-
raise PayNodeException("Malformed 402 headers: missing metadata"
|
|
85
|
+
raise PayNodeException(ErrorCode.internal_error, message="Malformed 402 headers: missing metadata")
|
|
86
|
+
|
|
87
|
+
# Network safety check (v1.4)
|
|
88
|
+
if chain_id_header:
|
|
89
|
+
current_chain_id = self.w3.eth.chain_id
|
|
90
|
+
if int(chain_id_header) != current_chain_id:
|
|
91
|
+
raise PayNodeException(ErrorCode.invalid_receipt, message=f"Network mismatch: Current {current_chain_id}, Request {chain_id_header}.")
|
|
92
|
+
|
|
93
|
+
logger.info(f"💡 [PayNode-PY] Payment request: {amount_raw} {currency} to {merchant_addr}")
|
|
84
94
|
|
|
85
95
|
# v1.3 Constraint: Min payment protection
|
|
86
96
|
if amount_raw < 1000:
|
|
87
|
-
raise PayNodeException(
|
|
97
|
+
raise PayNodeException(ErrorCode.amount_too_low)
|
|
98
|
+
|
|
99
|
+
# v1.4 Constraint: Token whitelist pre-flight (Anti-FakeToken)
|
|
100
|
+
resolved_chain_id = int(chain_id_header) if chain_id_header else 8453
|
|
101
|
+
whitelist = ACCEPTED_TOKENS.get(resolved_chain_id, [])
|
|
102
|
+
if whitelist and token_addr and token_addr.lower() not in [t.lower() for t in whitelist]:
|
|
103
|
+
raise PayNodeException(ErrorCode.token_not_accepted)
|
|
88
104
|
|
|
89
105
|
# Protocol v1.3: Permit-First Execution
|
|
90
106
|
try:
|
|
@@ -94,12 +110,12 @@ class PayNodeAgentClient:
|
|
|
94
110
|
tx_hash = self._execute_pay(router_addr, token_addr, merchant_addr, amount_raw, order_id)
|
|
95
111
|
else:
|
|
96
112
|
logger.info("⚡ [PayNode-PY] Insufficient allowance. Attempting Permit-First payment...")
|
|
97
|
-
tx_hash = self.
|
|
113
|
+
tx_hash = self.pay_with_permit(router_addr, token_addr, merchant_addr, amount_raw, order_id)
|
|
98
114
|
|
|
99
115
|
logger.info(f"✅ [PayNode-PY] Payment successful: {tx_hash}")
|
|
100
116
|
except Exception as e:
|
|
101
117
|
if isinstance(e, PayNodeException): raise
|
|
102
|
-
raise PayNodeException(
|
|
118
|
+
raise PayNodeException(ErrorCode.transaction_failed, details=e)
|
|
103
119
|
|
|
104
120
|
retry_headers = kwargs.get('headers', {}).copy()
|
|
105
121
|
retry_headers.update({'x-paynode-receipt': tx_hash, 'x-paynode-order-id': order_id})
|
|
@@ -173,7 +189,7 @@ class PayNodeAgentClient:
|
|
|
173
189
|
"deadline": deadline
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
def
|
|
192
|
+
def pay_with_permit(self, router_addr, token_addr, merchant_addr, amount, order_id):
|
|
177
193
|
"""Combines sign_permit and on-chain submission."""
|
|
178
194
|
sig = self.sign_permit(token_addr, router_addr, amount)
|
|
179
195
|
router_abi = [{"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"}]
|
paynode_sdk/constants.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Generated by scripts/sync-config.py
|
|
2
|
-
PAYNODE_ROUTER_ADDRESS = "
|
|
3
|
-
PAYNODE_ROUTER_ADDRESS_SANDBOX = "
|
|
2
|
+
PAYNODE_ROUTER_ADDRESS = "0x4A73696ccF76E7381b044cB95127B3784369Ed63"
|
|
3
|
+
PAYNODE_ROUTER_ADDRESS_SANDBOX = "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"
|
|
4
4
|
BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
5
|
-
BASE_USDC_ADDRESS_SANDBOX = "
|
|
5
|
+
BASE_USDC_ADDRESS_SANDBOX = "0x109AEddD656Ed2761d1e210E179329105039c784"
|
|
6
6
|
BASE_USDC_DECIMALS = 6
|
|
7
7
|
|
|
8
8
|
PROTOCOL_TREASURY = "0x598bF63F5449876efafa7b36b77Deb2070621C0E"
|
|
@@ -14,7 +14,7 @@ BASE_RPC_URLS_SANDBOX = ["https://sepolia.base.org", "https://base-sepolia-rpc.p
|
|
|
14
14
|
|
|
15
15
|
ACCEPTED_TOKENS = {
|
|
16
16
|
8453: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
|
|
17
|
-
84532: ["
|
|
17
|
+
84532: ["0x109AEddD656Ed2761d1e210E179329105039c784"]
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
PAYNODE_ROUTER_ABI = [
|
paynode_sdk/errors.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Generated by scripts/sync-config.py
|
|
1
2
|
from enum import Enum
|
|
2
3
|
from typing import Any, Optional
|
|
3
4
|
|
|
@@ -15,9 +16,24 @@ class ErrorCode(str, Enum):
|
|
|
15
16
|
order_mismatch = 'order_mismatch'
|
|
16
17
|
missing_receipt = 'missing_receipt'
|
|
17
18
|
|
|
19
|
+
ERROR_MESSAGES = {
|
|
20
|
+
ErrorCode.rpc_error: "Failed to connect to any provided RPC nodes.",
|
|
21
|
+
ErrorCode.insufficient_funds: "Wallet lacks USDC or ETH for gas.",
|
|
22
|
+
ErrorCode.amount_too_low: "Payment amount is below the protocol minimum (1000).",
|
|
23
|
+
ErrorCode.token_not_accepted: "The provided token address is not in the whitelist.",
|
|
24
|
+
ErrorCode.transaction_failed: "On-chain transaction reverted or failed.",
|
|
25
|
+
ErrorCode.duplicate_transaction: "This transaction hash has already been consumed.",
|
|
26
|
+
ErrorCode.invalid_receipt: "The provided receipt (TxHash) is malformed or invalid.",
|
|
27
|
+
ErrorCode.internal_error: "An unexpected error occurred.",
|
|
28
|
+
ErrorCode.transaction_not_found: "Transaction not found on-chain.",
|
|
29
|
+
ErrorCode.wrong_contract: "Payment event was not emitted by the official PayNode contract.",
|
|
30
|
+
ErrorCode.order_mismatch: "OrderId in receipt does not match requested ID.",
|
|
31
|
+
ErrorCode.missing_receipt: "Please pay to PayNode contract and provide 'x-paynode-receipt' header.",
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
class PayNodeException(Exception):
|
|
19
|
-
def __init__(self,
|
|
20
|
-
super().__init__(message)
|
|
21
|
-
self.message = message
|
|
35
|
+
def __init__(self, code: ErrorCode, message: Optional[str] = None, details: Optional[Any] = None):
|
|
22
36
|
self.code = code
|
|
37
|
+
self.message = message or ERROR_MESSAGES.get(code, "An unexpected error occurred.")
|
|
23
38
|
self.details = details
|
|
39
|
+
super().__init__(self.message)
|
paynode_sdk/middleware.py
CHANGED
|
@@ -5,6 +5,12 @@ from fastapi.responses import JSONResponse
|
|
|
5
5
|
from .verifier import PayNodeVerifier
|
|
6
6
|
from .errors import ErrorCode
|
|
7
7
|
from .idempotency import IdempotencyStore
|
|
8
|
+
from .constants import (
|
|
9
|
+
BASE_RPC_URLS,
|
|
10
|
+
PAYNODE_ROUTER_ADDRESS,
|
|
11
|
+
BASE_USDC_ADDRESS,
|
|
12
|
+
BASE_USDC_DECIMALS
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
16
|
|
|
@@ -12,14 +18,14 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
12
18
|
def __init__(
|
|
13
19
|
self,
|
|
14
20
|
app: Any,
|
|
15
|
-
rpc_urls: list | str,
|
|
16
|
-
contract_address: str,
|
|
17
21
|
merchant_address: str,
|
|
18
|
-
chain_id: int,
|
|
19
|
-
currency: str,
|
|
20
|
-
token_address: str,
|
|
21
22
|
price: str,
|
|
22
|
-
|
|
23
|
+
contract_address: str = PAYNODE_ROUTER_ADDRESS,
|
|
24
|
+
chain_id: int = 8453,
|
|
25
|
+
currency: str = "USDC",
|
|
26
|
+
token_address: str = BASE_USDC_ADDRESS,
|
|
27
|
+
decimals: int = BASE_USDC_DECIMALS,
|
|
28
|
+
rpc_urls: list | str = BASE_RPC_URLS,
|
|
23
29
|
store: Optional[IdempotencyStore] = None,
|
|
24
30
|
generate_order_id: Optional[Callable[[Request], str]] = None
|
|
25
31
|
):
|
|
@@ -91,3 +97,14 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
91
97
|
"message": str(err)
|
|
92
98
|
}
|
|
93
99
|
)
|
|
100
|
+
|
|
101
|
+
def x402_gate(
|
|
102
|
+
merchant_address: str,
|
|
103
|
+
price: str,
|
|
104
|
+
**kwargs
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""
|
|
107
|
+
Semantic helper to mirror JS x402Gate.
|
|
108
|
+
Usage: app.add_middleware(x402_gate, merchant_address=..., price=...)
|
|
109
|
+
"""
|
|
110
|
+
return lambda app: PayNodeMiddleware(app, merchant_address=merchant_address, price=price, **kwargs)
|
paynode_sdk/verifier.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
from .errors import ErrorCode, PayNodeException
|
|
2
3
|
from .constants import PAYNODE_ROUTER_ABI, ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT
|
|
3
4
|
from .idempotency import MemoryIdempotencyStore
|
|
@@ -17,7 +18,7 @@ class PayNodeVerifier:
|
|
|
17
18
|
except Exception:
|
|
18
19
|
continue
|
|
19
20
|
if not self.w3:
|
|
20
|
-
raise PayNodeException(
|
|
21
|
+
raise PayNodeException(ErrorCode.rpc_error)
|
|
21
22
|
self.contract_address = contract_address
|
|
22
23
|
self.chain_id = int(chain_id) if chain_id else None
|
|
23
24
|
self.store = store or MemoryIdempotencyStore()
|
|
@@ -34,85 +35,100 @@ class PayNodeVerifier:
|
|
|
34
35
|
|
|
35
36
|
async def verify_payment(self, tx_hash, expected):
|
|
36
37
|
if not self.w3:
|
|
37
|
-
return {"isValid": False, "error": PayNodeException("Verifier Provider Missing"
|
|
38
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error, message="Verifier Provider Missing")}
|
|
38
39
|
|
|
39
40
|
# 0. Dust Exploit Check (Minimum Payment)
|
|
40
41
|
amount = int(expected.get("amount", 0))
|
|
41
42
|
if amount < MIN_PAYMENT_AMOUNT:
|
|
42
43
|
return {"isValid": False, "error": PayNodeException(
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
ErrorCode.amount_too_low,
|
|
45
|
+
message=f"Payment amount {amount} is below the minimum threshold of {MIN_PAYMENT_AMOUNT}."
|
|
45
46
|
)}
|
|
46
47
|
|
|
47
48
|
# 1. Token Whitelist Check (Anti-FakeToken)
|
|
48
49
|
expected_token = expected.get("tokenAddress", "").lower()
|
|
49
50
|
if self.accepted_tokens and expected_token not in self.accepted_tokens:
|
|
50
51
|
return {"isValid": False, "error": PayNodeException(
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
ErrorCode.token_not_accepted,
|
|
53
|
+
message=f"Token {expected.get('tokenAddress')} is not in the accepted whitelist."
|
|
53
54
|
)}
|
|
54
55
|
|
|
55
|
-
try:
|
|
56
|
-
is_new = await self.store.check_and_set(tx_hash, 86400) # 24 hour TTL
|
|
57
|
-
if not is_new:
|
|
58
|
-
return {"isValid": False, "error": PayNodeException("Receipt already used", ErrorCode.receipt_already_used)}
|
|
59
|
-
except Exception as e:
|
|
60
|
-
return {"isValid": False, "error": PayNodeException("Store Error", ErrorCode.internal_error, details=str(e))}
|
|
61
56
|
|
|
57
|
+
|
|
58
|
+
# Wrap synchronous web3 calls in asyncio.to_thread to avoid blocking the event loop
|
|
62
59
|
try:
|
|
63
|
-
receipt = self.w3.eth.get_transaction_receipt
|
|
60
|
+
receipt = await asyncio.to_thread(self.w3.eth.get_transaction_receipt, tx_hash)
|
|
64
61
|
except Exception:
|
|
65
|
-
return {"isValid": False, "error": PayNodeException(
|
|
62
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
66
63
|
|
|
67
64
|
if not receipt:
|
|
68
|
-
return {"isValid": False, "error": PayNodeException(
|
|
65
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
69
66
|
|
|
70
67
|
if receipt.get("status") == 0:
|
|
71
|
-
return {"isValid": False, "error": PayNodeException(
|
|
72
|
-
|
|
73
|
-
if not receipt.get("to") or receipt.get("to", "").lower() != self.contract_address.lower():
|
|
74
|
-
return {"isValid": False, "error": PayNodeException("Wrong contract", ErrorCode.wrong_contract)}
|
|
68
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_failed)}
|
|
75
69
|
|
|
76
70
|
contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=PAYNODE_ROUTER_ABI)
|
|
77
71
|
|
|
78
72
|
try:
|
|
79
|
-
logs = contract.events.PaymentReceived().process_receipt
|
|
73
|
+
logs = await asyncio.to_thread(contract.events.PaymentReceived().process_receipt, receipt)
|
|
80
74
|
except Exception:
|
|
81
|
-
return {"isValid": False, "error": PayNodeException(
|
|
75
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
|
|
82
76
|
|
|
83
77
|
if not logs:
|
|
84
|
-
return {"isValid": False, "error": PayNodeException("No valid PaymentReceived event found"
|
|
78
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No valid PaymentReceived event found")}
|
|
85
79
|
|
|
86
|
-
# Find the
|
|
80
|
+
# Find and validate the specific log
|
|
87
81
|
merchant = expected.get("merchantAddress", "").lower()
|
|
88
82
|
token = expected.get("tokenAddress", "").lower()
|
|
89
83
|
amount = int(expected.get("amount", 0))
|
|
90
|
-
|
|
91
84
|
order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
|
|
92
85
|
|
|
93
|
-
|
|
86
|
+
last_error = None
|
|
87
|
+
valid_log_found = False
|
|
88
|
+
|
|
94
89
|
for log in logs:
|
|
90
|
+
if log.address.lower() != self.contract_address.lower():
|
|
91
|
+
last_error = last_error or PayNodeException(ErrorCode.wrong_contract)
|
|
92
|
+
continue
|
|
93
|
+
|
|
95
94
|
args = log.args
|
|
96
95
|
|
|
96
|
+
# 4. Verify OrderId
|
|
97
97
|
if args.get("orderId") != order_id_bytes:
|
|
98
|
+
last_error = PayNodeException(ErrorCode.order_mismatch)
|
|
98
99
|
continue
|
|
99
100
|
|
|
101
|
+
# 5. Verify Merchant
|
|
100
102
|
if args.get("merchant", "").lower() != merchant:
|
|
103
|
+
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment went to a different merchant.")
|
|
101
104
|
continue
|
|
102
105
|
|
|
106
|
+
# 6. Verify Token
|
|
103
107
|
if args.get("token", "").lower() != token:
|
|
108
|
+
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment used unexpected token.")
|
|
104
109
|
continue
|
|
105
110
|
|
|
111
|
+
# 7. Verify Amount
|
|
106
112
|
if args.get("amount", 0) < amount:
|
|
113
|
+
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment amount is below required price.")
|
|
107
114
|
continue
|
|
108
115
|
|
|
116
|
+
# 8. Verify ChainId
|
|
109
117
|
if self.chain_id and args.get("chainId") != self.chain_id:
|
|
118
|
+
last_error = PayNodeException(ErrorCode.invalid_receipt, message="ChainId mismatch. Invalid network.")
|
|
110
119
|
continue
|
|
111
120
|
|
|
112
|
-
|
|
121
|
+
valid_log_found = True
|
|
113
122
|
break
|
|
114
123
|
|
|
115
|
-
if not
|
|
116
|
-
return {"isValid": False, "error": PayNodeException("
|
|
124
|
+
if not valid_log_found:
|
|
125
|
+
return {"isValid": False, "error": last_error or PayNodeException(ErrorCode.invalid_receipt, message="No matching payment event found.")}
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
is_new = await self.store.check_and_set(tx_hash, 86400) # 24 hour TTL
|
|
129
|
+
if not is_new:
|
|
130
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction)}
|
|
131
|
+
except Exception as e:
|
|
132
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, details=str(e))}
|
|
117
133
|
|
|
118
134
|
return {"isValid": True}
|
paynode_sdk/webhook.py
CHANGED
|
@@ -212,8 +212,8 @@ class PayNodeWebhookNotifier:
|
|
|
212
212
|
async with session.post(self.webhook_url, data=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
213
213
|
if resp.status >= 400:
|
|
214
214
|
raise PayNodeException(
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
ErrorCode.internal_error,
|
|
216
|
+
message=f"Webhook returned {resp.status}"
|
|
217
217
|
)
|
|
218
218
|
|
|
219
219
|
logger.info(f"✅ [PayNode Webhook] Delivered tx {event.tx_hash[:10]}... → {resp.status}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paynode-sdk-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: PayNode Protocol Python SDK for AI Agents
|
|
5
5
|
Author-email: PayNodeLabs <contact@paynode.dev>
|
|
6
6
|
License: MIT
|
|
@@ -67,13 +67,21 @@ cp .env.example .env
|
|
|
67
67
|
# Edit .env with your private key and RPC URLs
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
### 2.
|
|
70
|
+
### 2. Get Test Tokens (Required for Base Sepolia)
|
|
71
|
+
|
|
72
|
+
If you're testing on Sepolia, run the helper script to mint 1,000 mock USDC:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python examples/mint_test_tokens.py
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Run the Merchant Server (FastAPI)
|
|
71
79
|
|
|
72
80
|
```bash
|
|
73
81
|
python examples/fastapi_server.py
|
|
74
82
|
```
|
|
75
83
|
|
|
76
|
-
###
|
|
84
|
+
### 4. Run the Agent Client
|
|
77
85
|
|
|
78
86
|
In another terminal:
|
|
79
87
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
paynode_sdk/__init__.py,sha256=NTyR6AbDSimrLFIeZ4SfJmkesA9J9VQZ3H4vVrhoZKc,1139
|
|
2
|
+
paynode_sdk/client.py,sha256=gqsNJoi72iBuOnw8DMNp-V59qUwOt-vvNd3L_zm9J9Q,11566
|
|
3
|
+
paynode_sdk/constants.py,sha256=-hR1P9B80-aQ0kCbY9hJEb1tpiRia1F0VCki9LpG7zs,1429
|
|
4
|
+
paynode_sdk/errors.py,sha256=9Mnyctt-ekcbNAkgKi45i0UZr8QXDEBwLpQTNtuz9i0,1965
|
|
5
|
+
paynode_sdk/idempotency.py,sha256=od7HuSxFdejBP0oE4QCzbJdrDZWvziiu09d3BRErU2k,999
|
|
6
|
+
paynode_sdk/middleware.py,sha256=ji0P-y1jnu4C4U95ASsylNujqPms5HAY5D6RfJiolgc,4105
|
|
7
|
+
paynode_sdk/verifier.py,sha256=DtsmaqXgHYoW-wQddL5QRoC6nCWr6Lc1_AomcVSvjxY,6071
|
|
8
|
+
paynode_sdk/webhook.py,sha256=xmesxnjnk8KQaqpvby3-uRYrmZbti_dhPw22r4uhwus,8310
|
|
9
|
+
paynode_sdk_python-1.4.0.dist-info/licenses/LICENSE,sha256=U8RjGlEBtXN6PA-qN_N3Uh60jyu3qe26ZBmgt-LAHc4,1069
|
|
10
|
+
paynode_sdk_python-1.4.0.dist-info/METADATA,sha256=eMYqGKWKYePIoYz2SW_8pgb_8T7pIh6f3HF6BOVU6l0,3037
|
|
11
|
+
paynode_sdk_python-1.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
paynode_sdk_python-1.4.0.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
|
|
13
|
+
paynode_sdk_python-1.4.0.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
paynode_sdk/__init__.py,sha256=p6URBqxFz1AErG7rIRWKnfwnzAC19qrMURtG7YhVR1I,821
|
|
2
|
-
paynode_sdk/client.py,sha256=tNQE3dizUnIc15Z9M1NU_0jZcoLJe01oN1TI40Ynl2k,10761
|
|
3
|
-
paynode_sdk/constants.py,sha256=puqz09qeKcMoigWrqdIzkhtAzFpECxEwHGLvDd3U_CQ,1429
|
|
4
|
-
paynode_sdk/errors.py,sha256=m49zByht9_nrmgX4_zHUR5eLsCHX_iR-d_BJZ2VNOx8,829
|
|
5
|
-
paynode_sdk/idempotency.py,sha256=od7HuSxFdejBP0oE4QCzbJdrDZWvziiu09d3BRErU2k,999
|
|
6
|
-
paynode_sdk/middleware.py,sha256=ubSFv2fiAjJ16cxrORDfWbsUHffhBbD4Wfvt0zM7lvE,3563
|
|
7
|
-
paynode_sdk/verifier.py,sha256=CzWO9IsXwc3AeCkmXy8gvcNTUbPhXnbfxmAa7lr1mhE,5274
|
|
8
|
-
paynode_sdk/webhook.py,sha256=DaCIuZ_rI7Kynt60Drw2EKQJuhNW0GoMk_u9EZy4Jxs,8302
|
|
9
|
-
paynode_sdk_python-1.1.3.dist-info/licenses/LICENSE,sha256=U8RjGlEBtXN6PA-qN_N3Uh60jyu3qe26ZBmgt-LAHc4,1069
|
|
10
|
-
paynode_sdk_python-1.1.3.dist-info/METADATA,sha256=nluXsilwB805frjjWE2rKgJ_ZC98hjQK_06O4HGSIao,2858
|
|
11
|
-
paynode_sdk_python-1.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
-
paynode_sdk_python-1.1.3.dist-info/top_level.txt,sha256=c6Skc1Xx-9O-JJ7sHghLW8Kyn4hyJoVPUawH1Mu8iTU,12
|
|
13
|
-
paynode_sdk_python-1.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|