paynode-sdk-python 1.4.0__py3-none-any.whl → 2.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 +2 -2
- paynode_sdk/client.py +254 -129
- paynode_sdk/constants.py +3 -18
- paynode_sdk/errors.py +1 -1
- paynode_sdk/idempotency.py +54 -9
- paynode_sdk/middleware.py +99 -48
- paynode_sdk/verifier.py +250 -74
- paynode_sdk/webhook.py +11 -11
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/METADATA +13 -2
- paynode_sdk_python-2.1.0.dist-info/RECORD +13 -0
- paynode_sdk_python-1.4.0.dist-info/RECORD +0 -13
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/WHEEL +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {paynode_sdk_python-1.4.0.dist-info → paynode_sdk_python-2.1.0.dist-info}/top_level.txt +0 -0
paynode_sdk/middleware.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import time
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
2
5
|
from typing import Optional, Callable, Any
|
|
3
6
|
from fastapi import Request, Response
|
|
4
7
|
from fastapi.responses import JSONResponse
|
|
@@ -14,6 +17,8 @@ from .constants import (
|
|
|
14
17
|
|
|
15
18
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
19
|
|
|
20
|
+
logger = logging.getLogger("paynode_sdk.middleware")
|
|
21
|
+
|
|
17
22
|
class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
18
23
|
def __init__(
|
|
19
24
|
self,
|
|
@@ -27,7 +32,8 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
27
32
|
decimals: int = BASE_USDC_DECIMALS,
|
|
28
33
|
rpc_urls: list | str = BASE_RPC_URLS,
|
|
29
34
|
store: Optional[IdempotencyStore] = None,
|
|
30
|
-
generate_order_id: Optional[Callable[[Request], str]] = None
|
|
35
|
+
generate_order_id: Optional[Callable[[Request], str]] = None,
|
|
36
|
+
**kwargs
|
|
31
37
|
):
|
|
32
38
|
super().__init__(app)
|
|
33
39
|
# The Verifier holds the state of the idempotency store
|
|
@@ -41,62 +47,107 @@ class PayNodeMiddleware(BaseHTTPMiddleware):
|
|
|
41
47
|
self.chain_id = chain_id
|
|
42
48
|
self.generate_order_id = generate_order_id or (lambda r: f"agent_py_{int(time.time() * 1000)}")
|
|
43
49
|
|
|
44
|
-
#
|
|
45
|
-
|
|
50
|
+
# DEV-2 FIX: Avoid float precision risks by using integer arithmetic or decimal string parsing
|
|
51
|
+
if "." in price:
|
|
52
|
+
parts = price.split(".")
|
|
53
|
+
integer_part = parts[0]
|
|
54
|
+
fraction_part = parts[1][:decimals].ljust(decimals, "0")
|
|
55
|
+
self.amount_int = int(integer_part + fraction_part)
|
|
56
|
+
else:
|
|
57
|
+
self.amount_int = int(price) * (10 ** decimals)
|
|
58
|
+
self.description = kwargs.get('description', "Protected Resource")
|
|
59
|
+
self.max_timeout_seconds = kwargs.get('max_timeout_seconds', 3600)
|
|
46
60
|
|
|
47
61
|
async def dispatch(self, request: Request, call_next):
|
|
48
|
-
|
|
49
|
-
order_id = request.headers.get('
|
|
62
|
+
v2_payload_header = request.headers.get('X-402-Payload')
|
|
63
|
+
order_id = request.headers.get('X-402-Order-Id')
|
|
50
64
|
|
|
51
65
|
if not order_id:
|
|
52
66
|
order_id = self.generate_order_id(request)
|
|
53
67
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'x-paynode-token-address': self.token_address,
|
|
62
|
-
'x-paynode-chain-id': str(self.chain_id),
|
|
63
|
-
'x-paynode-order-id': order_id,
|
|
64
|
-
}
|
|
65
|
-
return JSONResponse(
|
|
66
|
-
status_code=402,
|
|
67
|
-
headers=headers,
|
|
68
|
-
content={
|
|
69
|
-
"error": "Payment Required",
|
|
70
|
-
"code": ErrorCode.missing_receipt,
|
|
71
|
-
"message": "Please pay to PayNode contract and provide 'x-paynode-receipt' header.",
|
|
72
|
-
"amount": self.price,
|
|
73
|
-
"currency": self.currency
|
|
74
|
-
}
|
|
75
|
-
)
|
|
68
|
+
# Handle x402 v2 Unified Payload
|
|
69
|
+
unified_payload = None
|
|
70
|
+
if v2_payload_header:
|
|
71
|
+
try:
|
|
72
|
+
unified_payload = json.loads(base64.b64decode(v2_payload_header.encode()).decode())
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"❌ [PayNode-Middleware] Failed to decode X-402-Payload header: {e}")
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
if unified_payload:
|
|
77
|
+
try:
|
|
78
|
+
result = await self.verifier.verify(
|
|
79
|
+
unified_payload,
|
|
80
|
+
{
|
|
81
|
+
"merchantAddress": self.merchant_address,
|
|
82
|
+
"tokenAddress": self.token_address,
|
|
83
|
+
"amount": str(self.amount_int),
|
|
84
|
+
"orderId": order_id
|
|
85
|
+
},
|
|
86
|
+
# BUG-1 FIX: extra should come from our own config (v2Response schema), not the agent's payload
|
|
87
|
+
{
|
|
88
|
+
"name": self.currency,
|
|
89
|
+
"version": "2" # USDC v2
|
|
90
|
+
} if unified_payload.get("type") == "eip3009" else {}
|
|
91
|
+
)
|
|
92
|
+
if result.get("isValid"):
|
|
93
|
+
request.state.paynode = {"unified_payload": unified_payload, "orderId": order_id}
|
|
94
|
+
return await call_next(request)
|
|
95
|
+
else:
|
|
96
|
+
err = result.get("error")
|
|
97
|
+
return JSONResponse(
|
|
98
|
+
status_code=403,
|
|
99
|
+
content={
|
|
100
|
+
"error": "Forbidden",
|
|
101
|
+
"code": err.code if hasattr(err, 'code') else ErrorCode.invalid_receipt,
|
|
102
|
+
"message": str(err)
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"⚠️ [PayNode-Middleware] Failed to process x402 v2 payload: {e}")
|
|
84
107
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
108
|
+
# No valid payment found, return 402 with X-402-Required
|
|
109
|
+
v2_response = {
|
|
110
|
+
"x402Version": 2,
|
|
111
|
+
"error": "Payment Required by PayNode",
|
|
112
|
+
"resource": {
|
|
113
|
+
"url": str(request.url),
|
|
114
|
+
"description": self.description,
|
|
115
|
+
"mimeType": request.headers.get("accept", "application/json")
|
|
116
|
+
},
|
|
117
|
+
"accepts": [
|
|
118
|
+
{
|
|
119
|
+
"scheme": "exact",
|
|
120
|
+
"type": "eip3009",
|
|
121
|
+
"network": f"eip155:{self.chain_id}",
|
|
122
|
+
"amount": str(self.amount_int),
|
|
123
|
+
"asset": self.token_address,
|
|
124
|
+
"payTo": self.merchant_address,
|
|
125
|
+
"maxTimeoutSeconds": self.max_timeout_seconds,
|
|
126
|
+
"extra": {
|
|
127
|
+
"name": self.currency,
|
|
128
|
+
"version": "2"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"scheme": "exact",
|
|
133
|
+
"type": "onchain",
|
|
134
|
+
"network": f"eip155:{self.chain_id}",
|
|
135
|
+
"amount": str(self.amount_int),
|
|
136
|
+
"asset": self.token_address,
|
|
137
|
+
"payTo": self.merchant_address,
|
|
138
|
+
"maxTimeoutSeconds": self.max_timeout_seconds,
|
|
139
|
+
"router": self.contract_address
|
|
98
140
|
}
|
|
99
|
-
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
b64_required = base64.b64encode(json.dumps(v2_response).encode()).decode()
|
|
145
|
+
|
|
146
|
+
headers = {
|
|
147
|
+
'X-402-Required': b64_required,
|
|
148
|
+
'X-402-Order-Id': order_id,
|
|
149
|
+
}
|
|
150
|
+
return JSONResponse(status_code=402, headers=headers, content=v2_response)
|
|
100
151
|
|
|
101
152
|
def x402_gate(
|
|
102
153
|
merchant_address: str,
|
paynode_sdk/verifier.py
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
5
|
from .errors import ErrorCode, PayNodeException
|
|
3
|
-
from .constants import
|
|
6
|
+
from .constants import ACCEPTED_TOKENS, MIN_PAYMENT_AMOUNT, PAYNODE_ROUTER_ABI
|
|
4
7
|
from .idempotency import MemoryIdempotencyStore
|
|
5
8
|
from web3 import Web3
|
|
9
|
+
from eth_account import Account
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("paynode_sdk.verifier")
|
|
6
12
|
|
|
7
13
|
class PayNodeVerifier:
|
|
8
14
|
def __init__(self, rpc_urls=None, contract_address=None, chain_id=None, w3=None, store=None, accepted_tokens=None):
|
|
9
15
|
self.w3 = w3
|
|
10
16
|
if not self.w3 and rpc_urls:
|
|
11
17
|
urls = rpc_urls if isinstance(rpc_urls, list) else [rpc_urls]
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
def _check_rpc(url):
|
|
13
20
|
try:
|
|
14
|
-
temp_w3 = Web3(Web3.HTTPProvider(
|
|
21
|
+
temp_w3 = Web3(Web3.HTTPProvider(url, request_kwargs={'timeout': 3}))
|
|
15
22
|
if temp_w3.is_connected():
|
|
16
|
-
|
|
17
|
-
break
|
|
23
|
+
return temp_w3
|
|
18
24
|
except Exception:
|
|
19
|
-
|
|
25
|
+
pass
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
with ThreadPoolExecutor(max_workers=min(len(urls), 5)) as executor:
|
|
29
|
+
future_to_url = {executor.submit(_check_rpc, url): url for url in urls}
|
|
30
|
+
for future in as_completed(future_to_url):
|
|
31
|
+
w3_instance = future.result()
|
|
32
|
+
if w3_instance:
|
|
33
|
+
self.w3 = w3_instance
|
|
34
|
+
logger.debug(f"⚡ [PayNode-PY] Verifier connected to RPC: {future_to_url[future]}")
|
|
35
|
+
break
|
|
36
|
+
|
|
20
37
|
if not self.w3:
|
|
21
|
-
raise PayNodeException(ErrorCode.rpc_error)
|
|
38
|
+
raise PayNodeException(ErrorCode.rpc_error, message="All provided RPC nodes are unreachable.")
|
|
22
39
|
self.contract_address = contract_address
|
|
23
40
|
self.chain_id = int(chain_id) if chain_id else None
|
|
24
41
|
self.store = store or MemoryIdempotencyStore()
|
|
25
42
|
|
|
26
43
|
# Build accepted token set: user-provided or chain-default
|
|
27
|
-
# accepted_tokens=None → use chain default; accepted_tokens=[] → explicitly disable whitelist
|
|
28
44
|
if accepted_tokens is not None:
|
|
29
45
|
token_list = accepted_tokens
|
|
30
46
|
elif self.chain_id:
|
|
@@ -33,102 +49,262 @@ class PayNodeVerifier:
|
|
|
33
49
|
token_list = None
|
|
34
50
|
self.accepted_tokens = set(t.lower() for t in token_list) if token_list else None
|
|
35
51
|
|
|
36
|
-
async def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)}
|
|
52
|
+
async def verify(self, unified_payload: dict, expected: dict, extra: dict = None) -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Unified verification entry point for X402 V3.1 (Hybrid V2).
|
|
55
|
+
Routes to verify_onchain_payment or verify_transfer_with_authorization (eip3009).
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
# 1. Double-check Protocol Dust Limit (>= 1000)
|
|
59
|
+
expected_amount = int(expected.get("amount", 0))
|
|
60
|
+
if expected_amount < MIN_PAYMENT_AMOUNT:
|
|
61
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
|
|
47
62
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return {"isValid": False, "error": PayNodeException(
|
|
52
|
-
ErrorCode.token_not_accepted,
|
|
53
|
-
message=f"Token {expected.get('tokenAddress')} is not in the accepted whitelist."
|
|
54
|
-
)}
|
|
63
|
+
# 2. Security: Token Whitelist Check
|
|
64
|
+
if self.accepted_tokens and expected.get("tokenAddress", "").lower() not in self.accepted_tokens:
|
|
65
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message=f"Token {expected.get('tokenAddress')} not allowed")}
|
|
55
66
|
|
|
67
|
+
payload_type = unified_payload.get("type")
|
|
68
|
+
actual_payload = unified_payload.get("payload", {})
|
|
69
|
+
order_id = unified_payload.get("orderId")
|
|
70
|
+
|
|
71
|
+
if payload_type == "onchain":
|
|
72
|
+
tx_hash = actual_payload.get("txHash")
|
|
73
|
+
if not tx_hash:
|
|
74
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Missing txHash in onchain payload")}
|
|
75
|
+
|
|
76
|
+
onchain_expected = {
|
|
77
|
+
"merchantAddress": expected.get("merchantAddress"),
|
|
78
|
+
"tokenAddress": expected.get("tokenAddress"),
|
|
79
|
+
"amount": expected.get("amount"),
|
|
80
|
+
"orderId": order_id
|
|
81
|
+
}
|
|
82
|
+
return await self.verify_onchain_payment(tx_hash, onchain_expected)
|
|
83
|
+
|
|
84
|
+
elif payload_type == "eip3009":
|
|
85
|
+
token_addr = expected.get("tokenAddress")
|
|
86
|
+
if not token_addr:
|
|
87
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.token_not_accepted, message="tokenAddress is required for eip3009 verification")}
|
|
88
|
+
|
|
89
|
+
return await self.verify_transfer_with_authorization(
|
|
90
|
+
token_addr,
|
|
91
|
+
actual_payload,
|
|
92
|
+
{"to": expected.get("merchantAddress"), "value": expected.get("amount")},
|
|
93
|
+
extra
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=f"Unsupported payload type: {payload_type}")}
|
|
97
|
+
except Exception as e:
|
|
98
|
+
if isinstance(e, PayNodeException):
|
|
99
|
+
return {"isValid": False, "error": e}
|
|
100
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
|
|
56
101
|
|
|
102
|
+
async def verify_onchain_payment(self, tx_hash, expected):
|
|
103
|
+
if not self.w3:
|
|
104
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error)}
|
|
57
105
|
|
|
58
|
-
# Wrap synchronous web3 calls in asyncio.to_thread to avoid blocking the event loop
|
|
59
106
|
try:
|
|
60
107
|
receipt = await asyncio.to_thread(self.w3.eth.get_transaction_receipt, tx_hash)
|
|
61
108
|
except Exception:
|
|
62
109
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
63
110
|
|
|
64
|
-
if
|
|
111
|
+
if receipt is None:
|
|
65
112
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_not_found)}
|
|
66
|
-
|
|
113
|
+
|
|
67
114
|
if receipt.get("status") == 0:
|
|
68
115
|
return {"isValid": False, "error": PayNodeException(ErrorCode.transaction_failed)}
|
|
69
116
|
|
|
70
117
|
contract = self.w3.eth.contract(address=Web3.to_checksum_address(self.contract_address), abi=PAYNODE_ROUTER_ABI)
|
|
71
118
|
|
|
119
|
+
# 1. Check if the router was even involved (against 'WrongContract' vs 'InvalidReceipt')
|
|
120
|
+
# Filter logs for current contract
|
|
121
|
+
relevant_logs = [log for log in receipt.get("logs", []) if log.get("address", "").lower() == self.contract_address.lower()]
|
|
122
|
+
if not relevant_logs:
|
|
123
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.wrong_contract, message="Transaction did not interact with the expected PayNodeRouter contract")}
|
|
124
|
+
|
|
72
125
|
try:
|
|
73
|
-
|
|
126
|
+
processed_logs = await asyncio.to_thread(contract.events.PaymentReceived().process_receipt, {"logs": relevant_logs})
|
|
74
127
|
except Exception:
|
|
75
|
-
|
|
128
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt)}
|
|
76
129
|
|
|
77
|
-
if not
|
|
78
|
-
|
|
130
|
+
if not processed_logs:
|
|
131
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="No PaymentReceived event found in router logs")}
|
|
79
132
|
|
|
80
|
-
# Find and validate the specific log
|
|
81
133
|
merchant = expected.get("merchantAddress", "").lower()
|
|
82
134
|
token = expected.get("tokenAddress", "").lower()
|
|
83
135
|
amount = int(expected.get("amount", 0))
|
|
84
136
|
order_id_bytes = self.w3.keccak(text=expected.get("orderId", ""))
|
|
85
137
|
|
|
86
|
-
last_error = None
|
|
87
138
|
valid_log_found = False
|
|
88
|
-
|
|
89
|
-
for log in
|
|
90
|
-
if log.address.lower() != self.contract_address.lower():
|
|
91
|
-
last_error = last_error or PayNodeException(ErrorCode.wrong_contract)
|
|
92
|
-
continue
|
|
93
|
-
|
|
139
|
+
order_id_mismatch_found = False
|
|
140
|
+
for log in processed_logs:
|
|
94
141
|
args = log.args
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
continue
|
|
100
|
-
|
|
101
|
-
# 5. Verify Merchant
|
|
102
|
-
if args.get("merchant", "").lower() != merchant:
|
|
103
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment went to a different merchant.")
|
|
104
|
-
continue
|
|
105
|
-
|
|
106
|
-
# 6. Verify Token
|
|
107
|
-
if args.get("token", "").lower() != token:
|
|
108
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment used unexpected token.")
|
|
109
|
-
continue
|
|
110
|
-
|
|
111
|
-
# 7. Verify Amount
|
|
112
|
-
if args.get("amount", 0) < amount:
|
|
113
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="Payment amount is below required price.")
|
|
114
|
-
continue
|
|
115
|
-
|
|
116
|
-
# 8. Verify ChainId
|
|
117
|
-
if self.chain_id and args.get("chainId") != self.chain_id:
|
|
118
|
-
last_error = PayNodeException(ErrorCode.invalid_receipt, message="ChainId mismatch. Invalid network.")
|
|
119
|
-
continue
|
|
120
|
-
|
|
121
|
-
valid_log_found = True
|
|
122
|
-
break
|
|
142
|
+
is_merchant_match = args.get("merchant", "").lower() == merchant
|
|
143
|
+
is_token_match = args.get("token", "").lower() == token
|
|
144
|
+
is_amount_match = args.get("amount", 0) >= amount
|
|
145
|
+
is_order_match = args.get("orderId") == order_id_bytes
|
|
123
146
|
|
|
147
|
+
if is_merchant_match and is_token_match and is_amount_match:
|
|
148
|
+
if is_order_match:
|
|
149
|
+
valid_log_found = True
|
|
150
|
+
break
|
|
151
|
+
else:
|
|
152
|
+
order_id_mismatch_found = True
|
|
153
|
+
|
|
124
154
|
if not valid_log_found:
|
|
125
|
-
|
|
155
|
+
if order_id_mismatch_found:
|
|
156
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.order_mismatch, message="Payment log found but orderId does not match")}
|
|
157
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Payment event data mismatch")}
|
|
126
158
|
|
|
127
|
-
|
|
128
|
-
is_new = await self.store.check_and_set(tx_hash, 86400)
|
|
159
|
+
if self.store:
|
|
160
|
+
is_new = await self.store.check_and_set(tx_hash, 86400)
|
|
129
161
|
if not is_new:
|
|
130
162
|
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))}
|
|
133
163
|
|
|
134
164
|
return {"isValid": True}
|
|
165
|
+
|
|
166
|
+
async def verify_transfer_with_authorization(
|
|
167
|
+
self,
|
|
168
|
+
token_addr: str,
|
|
169
|
+
payload: dict,
|
|
170
|
+
expected: dict,
|
|
171
|
+
extra: dict = None
|
|
172
|
+
) -> dict:
|
|
173
|
+
"""
|
|
174
|
+
Verifies an EIP-3009 TransferWithAuthorization signature.
|
|
175
|
+
Includes RPC state checks for balance and nonce status.
|
|
176
|
+
"""
|
|
177
|
+
if not self.w3:
|
|
178
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.rpc_error, message="Verifier web3 instance missing")}
|
|
179
|
+
|
|
180
|
+
extra = extra or {}
|
|
181
|
+
try:
|
|
182
|
+
signature = payload["signature"]
|
|
183
|
+
auth = payload["authorization"]
|
|
184
|
+
|
|
185
|
+
# 1. Basic validation
|
|
186
|
+
if auth["to"].lower() != expected["to"].lower():
|
|
187
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Recipient mismatch")}
|
|
188
|
+
|
|
189
|
+
payload_value = int(auth["value"])
|
|
190
|
+
expected_value = int(expected["value"])
|
|
191
|
+
if payload_value < expected_value:
|
|
192
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.amount_too_low)}
|
|
193
|
+
|
|
194
|
+
# 2. Time window check
|
|
195
|
+
now = int(time.time())
|
|
196
|
+
if now < int(auth["validAfter"]):
|
|
197
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization not yet valid")}
|
|
198
|
+
if now > int(auth["validBefore"]):
|
|
199
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Authorization expired")}
|
|
200
|
+
|
|
201
|
+
# 3. Signature verification
|
|
202
|
+
chain_id = self.chain_id or await asyncio.to_thread(lambda: self.w3.eth.chain_id)
|
|
203
|
+
domain = {
|
|
204
|
+
"name": extra.get("name", "USD Coin"),
|
|
205
|
+
"version": extra.get("version", "2"),
|
|
206
|
+
"chainId": chain_id,
|
|
207
|
+
"verifyingContract": Web3.to_checksum_address(token_addr)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
types = {
|
|
211
|
+
"EIP712Domain": [
|
|
212
|
+
{"name": "name", "type": "string"},
|
|
213
|
+
{"name": "version", "type": "string"},
|
|
214
|
+
{"name": "chainId", "type": "uint256"},
|
|
215
|
+
{"name": "verifyingContract", "type": "address"},
|
|
216
|
+
],
|
|
217
|
+
"TransferWithAuthorization": [
|
|
218
|
+
{"name": "from", "type": "address"},
|
|
219
|
+
{"name": "to", "type": "address"},
|
|
220
|
+
{"name": "value", "type": "uint256"},
|
|
221
|
+
{"name": "validAfter", "type": "uint256"},
|
|
222
|
+
{"name": "validBefore", "type": "uint256"},
|
|
223
|
+
{"name": "nonce", "type": "bytes32"},
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
auth_msg = {
|
|
228
|
+
"from": Web3.to_checksum_address(auth["from"]),
|
|
229
|
+
"to": Web3.to_checksum_address(auth["to"]),
|
|
230
|
+
"value": payload_value,
|
|
231
|
+
"validAfter": int(auth["validAfter"]),
|
|
232
|
+
"validBefore": int(auth["validBefore"]),
|
|
233
|
+
"nonce": Web3.to_bytes(hexstr=auth["nonce"])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
structured_data = {
|
|
237
|
+
"types": types,
|
|
238
|
+
"domain": domain,
|
|
239
|
+
"primaryType": "TransferWithAuthorization",
|
|
240
|
+
"message": auth_msg
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
from eth_account.messages import encode_typed_data
|
|
244
|
+
signable_msg = encode_typed_data(full_message=structured_data)
|
|
245
|
+
recovered_address = Account.recover_message(signable_msg, signature=signature)
|
|
246
|
+
|
|
247
|
+
if recovered_address.lower() != auth["from"].lower():
|
|
248
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.invalid_receipt, message="Invalid signature")}
|
|
249
|
+
|
|
250
|
+
# 4. Idempotency (Nonce local check)
|
|
251
|
+
nonce = auth["nonce"]
|
|
252
|
+
if self.store:
|
|
253
|
+
is_new = await self.store.check_and_set(nonce, 86400)
|
|
254
|
+
if not is_new:
|
|
255
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already used in local memory")}
|
|
256
|
+
|
|
257
|
+
# 5. RPC State Checks (Balance & Nonce)
|
|
258
|
+
token_abi = [
|
|
259
|
+
{
|
|
260
|
+
"constant": True,
|
|
261
|
+
"inputs": [{"name": "account", "type": "address"}],
|
|
262
|
+
"name": "balanceOf",
|
|
263
|
+
"outputs": [{"name": "", "type": "uint256"}],
|
|
264
|
+
"payable": False,
|
|
265
|
+
"stateMutability": "view",
|
|
266
|
+
"type": "function",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"constant": True,
|
|
270
|
+
"inputs": [
|
|
271
|
+
{"name": "authorizer", "type": "address"},
|
|
272
|
+
{"name": "nonce", "type": "bytes32"}
|
|
273
|
+
],
|
|
274
|
+
"name": "authorizationState",
|
|
275
|
+
"outputs": [{"name": "", "type": "bool"}],
|
|
276
|
+
"payable": False,
|
|
277
|
+
"stateMutability": "view",
|
|
278
|
+
"type": "function",
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
token_contract = self.w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=token_abi)
|
|
283
|
+
authorizer_address = Web3.to_checksum_address(auth["from"])
|
|
284
|
+
nonce_bytes = Web3.to_bytes(hexstr=nonce)
|
|
285
|
+
|
|
286
|
+
# Concurrent RPC calls
|
|
287
|
+
try:
|
|
288
|
+
balance, is_nonce_used_on_chain = await asyncio.gather(
|
|
289
|
+
asyncio.to_thread(token_contract.functions.balanceOf(authorizer_address).call),
|
|
290
|
+
asyncio.to_thread(token_contract.functions.authorizationState(authorizer_address, nonce_bytes).call)
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.warning(f"RPC state check failed for token {token_addr}: {e}")
|
|
294
|
+
if self.store: await self.store.delete(nonce)
|
|
295
|
+
return {
|
|
296
|
+
"isValid": False,
|
|
297
|
+
"error": PayNodeException(ErrorCode.rpc_error, message=f"Cannot verify on-chain state: {e}")
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if balance < payload_value:
|
|
301
|
+
if self.store: await self.store.delete(nonce)
|
|
302
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.insufficient_funds, message="Insufficient token balance")}
|
|
303
|
+
|
|
304
|
+
if is_nonce_used_on_chain:
|
|
305
|
+
if self.store: await self.store.delete(nonce)
|
|
306
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.duplicate_transaction, message="Nonce already consumed on-chain")}
|
|
307
|
+
|
|
308
|
+
return {"isValid": True}
|
|
309
|
+
except Exception as e:
|
|
310
|
+
return {"isValid": False, "error": PayNodeException(ErrorCode.internal_error, message=str(e))}
|
paynode_sdk/webhook.py
CHANGED
|
@@ -3,7 +3,7 @@ PayNode Webhook Notifier — monitors on-chain PaymentReceived events
|
|
|
3
3
|
and delivers structured webhook POSTs to a merchant's endpoint.
|
|
4
4
|
|
|
5
5
|
Features:
|
|
6
|
-
- HMAC-SHA256 signature for authenticity (header:
|
|
6
|
+
- HMAC-SHA256 signature for authenticity (header: X-402-Signature)
|
|
7
7
|
- Configurable polling interval
|
|
8
8
|
- Automatic retry with exponential backoff (3 attempts)
|
|
9
9
|
- Async-first design
|
|
@@ -73,7 +73,7 @@ class PayNodeWebhookNotifier:
|
|
|
73
73
|
Usage:
|
|
74
74
|
notifier = PayNodeWebhookNotifier(
|
|
75
75
|
rpc_url="https://mainnet.base.org",
|
|
76
|
-
contract_address="
|
|
76
|
+
contract_address="0x4A73696ccF76E7381b044cB95127B3784369Ed63",
|
|
77
77
|
webhook_url="https://myshop.com/api/paynode-webhook",
|
|
78
78
|
webhook_secret="whsec_mysecretkey123",
|
|
79
79
|
)
|
|
@@ -100,7 +100,7 @@ class PayNodeWebhookNotifier:
|
|
|
100
100
|
raise ValueError("webhook_secret is required")
|
|
101
101
|
|
|
102
102
|
self.contract_address = contract_address or PAYNODE_ROUTER_ADDRESS
|
|
103
|
-
self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout":
|
|
103
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 3}))
|
|
104
104
|
self.contract = self.w3.eth.contract(
|
|
105
105
|
address=Web3.to_checksum_address(self.contract_address),
|
|
106
106
|
abi=PAYNODE_ROUTER_ABI
|
|
@@ -120,7 +120,7 @@ class PayNodeWebhookNotifier:
|
|
|
120
120
|
async def start(self, from_block: Optional[int] = None) -> None:
|
|
121
121
|
"""Start polling for PaymentReceived events."""
|
|
122
122
|
if self._running:
|
|
123
|
-
logger.warning("[PayNode Webhook] Already running.")
|
|
123
|
+
logger.warning("🔔 [PayNode Webhook] Already running.")
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
self._last_block = from_block if from_block is not None else self.w3.eth.block_number
|
|
@@ -158,7 +158,7 @@ class PayNodeWebhookNotifier:
|
|
|
158
158
|
|
|
159
159
|
self._last_block = current_block
|
|
160
160
|
except Exception as e:
|
|
161
|
-
logger.error(f"[PayNode Webhook] Poll error: {e}")
|
|
161
|
+
logger.error(f"❌ [PayNode Webhook] Poll error: {e}")
|
|
162
162
|
|
|
163
163
|
await asyncio.sleep(self.poll_interval)
|
|
164
164
|
|
|
@@ -179,7 +179,7 @@ class PayNodeWebhookNotifier:
|
|
|
179
179
|
timestamp=time.time(),
|
|
180
180
|
)
|
|
181
181
|
except Exception as e:
|
|
182
|
-
logger.error(f"[PayNode Webhook] Failed to parse event: {e}")
|
|
182
|
+
logger.error(f"❌ [PayNode Webhook] Failed to parse event: {e}")
|
|
183
183
|
return None
|
|
184
184
|
|
|
185
185
|
async def _deliver(self, event: PaymentEvent, attempt: int = 1) -> None:
|
|
@@ -201,9 +201,9 @@ class PayNodeWebhookNotifier:
|
|
|
201
201
|
|
|
202
202
|
headers = {
|
|
203
203
|
"Content-Type": "application/json",
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
204
|
+
"X-402-Signature": f"sha256={signature}",
|
|
205
|
+
"X-402-Event": "payment.received",
|
|
206
|
+
"X-402-Delivery-Id": f"{event.tx_hash}-{attempt}",
|
|
207
207
|
**self.custom_headers,
|
|
208
208
|
}
|
|
209
209
|
|
|
@@ -221,13 +221,13 @@ class PayNodeWebhookNotifier:
|
|
|
221
221
|
self.on_success(event)
|
|
222
222
|
|
|
223
223
|
except Exception as e:
|
|
224
|
-
logger.error(f"[PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
|
|
224
|
+
logger.error(f"⚠️ [PayNode Webhook] Delivery failed (attempt {attempt}/{MAX_RETRIES}): {e}")
|
|
225
225
|
|
|
226
226
|
if attempt < MAX_RETRIES:
|
|
227
227
|
backoff = (2 ** attempt) # 2s, 4s, 8s
|
|
228
228
|
await asyncio.sleep(backoff)
|
|
229
229
|
return await self._deliver(event, attempt + 1)
|
|
230
230
|
|
|
231
|
-
logger.error(f"[PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
|
|
231
|
+
logger.error(f"❌ [PayNode Webhook] Gave up on tx {event.tx_hash} after {MAX_RETRIES} attempts.")
|
|
232
232
|
if self.on_error:
|
|
233
233
|
self.on_error(e, event)
|