stablepay-verifier 0.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.
- stablepay_verifier/__init__.py +18 -0
- stablepay_verifier/__main__.py +6 -0
- stablepay_verifier/chains.py +177 -0
- stablepay_verifier/cli.py +366 -0
- stablepay_verifier/models.py +134 -0
- stablepay_verifier/utils.py +179 -0
- stablepay_verifier/verifier.py +249 -0
- stablepay_verifier-0.1.0.dist-info/METADATA +300 -0
- stablepay_verifier-0.1.0.dist-info/RECORD +12 -0
- stablepay_verifier-0.1.0.dist-info/WHEEL +4 -0
- stablepay_verifier-0.1.0.dist-info/entry_points.txt +3 -0
- stablepay_verifier-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for StablePay Verifier.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_time_window(window: str) -> timedelta:
|
|
10
|
+
"""
|
|
11
|
+
Parse a time window string into a timedelta.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
window: Time window string (e.g., "1h", "24h", "7d", "30m")
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
timedelta representing the time window
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ValueError: If the format is invalid
|
|
21
|
+
"""
|
|
22
|
+
window = window.lower().strip()
|
|
23
|
+
match = re.match(r"^(\d+)([hdm])$", window)
|
|
24
|
+
|
|
25
|
+
if not match:
|
|
26
|
+
raise ValueError(f"Invalid time window format: {window}. Use: 1h, 24h, 7d, 30m")
|
|
27
|
+
|
|
28
|
+
value = int(match.group(1))
|
|
29
|
+
unit = match.group(2)
|
|
30
|
+
|
|
31
|
+
if unit == "h":
|
|
32
|
+
return timedelta(hours=value)
|
|
33
|
+
elif unit == "d":
|
|
34
|
+
return timedelta(days=value)
|
|
35
|
+
elif unit == "m":
|
|
36
|
+
return timedelta(minutes=value)
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(f"Unknown time unit: {unit}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def estimate_blocks_from_time(
|
|
42
|
+
time_delta: timedelta,
|
|
43
|
+
block_time_seconds: float = 2.0
|
|
44
|
+
) -> int:
|
|
45
|
+
"""
|
|
46
|
+
Estimate the number of blocks in a time period.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
time_delta: Time period to estimate
|
|
50
|
+
block_time_seconds: Average block time in seconds
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Estimated number of blocks
|
|
54
|
+
"""
|
|
55
|
+
total_seconds = time_delta.total_seconds()
|
|
56
|
+
return max(1, int(total_seconds / block_time_seconds))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_address(address: str, length: int = 8) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Format an address for display (shortened).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
address: Full Ethereum address
|
|
65
|
+
length: Number of characters to show on each end
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Shortened address (e.g., "0x1234...abcd")
|
|
69
|
+
"""
|
|
70
|
+
if len(address) <= length * 2 + 2:
|
|
71
|
+
return address
|
|
72
|
+
return f"{address[:length+2]}...{address[-length:]}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_amount(amount: float, decimals: int = 2) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Format an amount for display.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
amount: Amount to format
|
|
81
|
+
decimals: Number of decimal places
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Formatted amount string
|
|
85
|
+
"""
|
|
86
|
+
return f"{amount:,.{decimals}f}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def wei_to_token(wei_amount: int, decimals: int = 6) -> float:
|
|
90
|
+
"""
|
|
91
|
+
Convert wei/smallest unit to token amount.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
wei_amount: Amount in smallest unit
|
|
95
|
+
decimals: Token decimals
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Human-readable token amount
|
|
99
|
+
"""
|
|
100
|
+
return wei_amount / (10 ** decimals)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def token_to_wei(token_amount: float, decimals: int = 6) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Convert token amount to wei/smallest unit.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
token_amount: Human-readable token amount
|
|
109
|
+
decimals: Token decimals
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Amount in smallest unit
|
|
113
|
+
"""
|
|
114
|
+
return int(token_amount * (10 ** decimals))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_valid_address(address: str) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Check if an address is a valid Ethereum address.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
address: Address to validate
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if valid, False otherwise
|
|
126
|
+
"""
|
|
127
|
+
if not address or not isinstance(address, str):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
address = address.strip()
|
|
131
|
+
if not address.startswith("0x"):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
if len(address) != 42:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
int(address, 16)
|
|
139
|
+
return True
|
|
140
|
+
except ValueError:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_utc_now() -> datetime:
|
|
145
|
+
"""Get current UTC datetime."""
|
|
146
|
+
return datetime.now(timezone.utc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def format_timestamp(dt: datetime | None) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Format a datetime for display.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
dt: Datetime to format
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Formatted datetime string or "Unknown"
|
|
158
|
+
"""
|
|
159
|
+
if dt is None:
|
|
160
|
+
return "Unknown"
|
|
161
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def calculate_tolerance_range(
|
|
165
|
+
amount: float,
|
|
166
|
+
tolerance_percent: float = 0.01
|
|
167
|
+
) -> tuple[float, float]:
|
|
168
|
+
"""
|
|
169
|
+
Calculate the acceptable payment range based on tolerance.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
amount: Expected amount
|
|
173
|
+
tolerance_percent: Tolerance as decimal (0.01 = 1%)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Tuple of (minimum acceptable, maximum acceptable)
|
|
177
|
+
"""
|
|
178
|
+
tolerance_amount = amount * tolerance_percent
|
|
179
|
+
return (amount - tolerance_amount, amount + tolerance_amount)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core payment verification logic for StablePay Verifier.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from web3 import Web3
|
|
9
|
+
from web3.exceptions import Web3Exception
|
|
10
|
+
|
|
11
|
+
from stablepay_verifier.chains import (
|
|
12
|
+
ERC20_ABI,
|
|
13
|
+
TRANSFER_EVENT_SIGNATURE,
|
|
14
|
+
get_chain_config,
|
|
15
|
+
get_token_config,
|
|
16
|
+
)
|
|
17
|
+
from stablepay_verifier.models import (
|
|
18
|
+
PaymentResult,
|
|
19
|
+
PaymentStatus,
|
|
20
|
+
Transfer,
|
|
21
|
+
VerifyRequest,
|
|
22
|
+
)
|
|
23
|
+
from stablepay_verifier.utils import (
|
|
24
|
+
estimate_blocks_from_time,
|
|
25
|
+
parse_time_window,
|
|
26
|
+
wei_to_token,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VerificationError(Exception):
|
|
31
|
+
"""Custom exception for verification errors."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, code: str = "UNKNOWN_ERROR"):
|
|
34
|
+
self.message = message
|
|
35
|
+
self.code = code
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify_payment(request: VerifyRequest) -> PaymentResult:
|
|
40
|
+
"""
|
|
41
|
+
Verify a stablecoin payment on-chain.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
request: VerifyRequest with payment details
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
PaymentResult with verification status and details
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
VerificationError: If verification fails due to configuration or network issues
|
|
51
|
+
"""
|
|
52
|
+
# Get chain configuration
|
|
53
|
+
chain_config = get_chain_config(request.chain)
|
|
54
|
+
if chain_config is None:
|
|
55
|
+
raise VerificationError(
|
|
56
|
+
f"Chain '{request.chain}' is not supported. "
|
|
57
|
+
f"Supported chains: polygon, ethereum, arbitrum, base, optimism",
|
|
58
|
+
code="UNSUPPORTED_CHAIN"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Get token configuration
|
|
62
|
+
token_config = get_token_config(request.chain, request.token)
|
|
63
|
+
if token_config is None:
|
|
64
|
+
raise VerificationError(
|
|
65
|
+
f"Token '{request.token}' not found on {request.chain}. "
|
|
66
|
+
f"Check the token symbol or provide a contract address.",
|
|
67
|
+
code="UNSUPPORTED_TOKEN"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Connect to RPC
|
|
71
|
+
rpc_url = request.rpc or chain_config.default_rpc
|
|
72
|
+
try:
|
|
73
|
+
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 30}))
|
|
74
|
+
if not w3.is_connected():
|
|
75
|
+
raise VerificationError(
|
|
76
|
+
f"Failed to connect to RPC endpoint: {rpc_url}",
|
|
77
|
+
code="RPC_ERROR"
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise VerificationError(
|
|
81
|
+
f"RPC connection error: {str(e)}",
|
|
82
|
+
code="RPC_ERROR"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Determine block range
|
|
86
|
+
try:
|
|
87
|
+
current_block = w3.eth.block_number
|
|
88
|
+
except Web3Exception as e:
|
|
89
|
+
raise VerificationError(
|
|
90
|
+
f"Failed to get current block number: {str(e)}",
|
|
91
|
+
code="RPC_ERROR"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Calculate from_block based on time window or explicit value
|
|
95
|
+
if request.from_block is not None:
|
|
96
|
+
from_block = request.from_block
|
|
97
|
+
else:
|
|
98
|
+
time_delta = parse_time_window(request.time_window)
|
|
99
|
+
blocks_to_search = estimate_blocks_from_time(time_delta, chain_config.block_time)
|
|
100
|
+
from_block = max(0, current_block - blocks_to_search)
|
|
101
|
+
|
|
102
|
+
to_block = request.to_block or current_block
|
|
103
|
+
|
|
104
|
+
# Validate block range
|
|
105
|
+
max_block_range = 100000
|
|
106
|
+
if to_block - from_block > max_block_range:
|
|
107
|
+
raise VerificationError(
|
|
108
|
+
f"Block range too large ({to_block - from_block} blocks). "
|
|
109
|
+
f"Maximum is {max_block_range} blocks. Narrow your search.",
|
|
110
|
+
code="RANGE_TOO_LARGE"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Prepare address for event filtering
|
|
114
|
+
receiver_address = Web3.to_checksum_address(request.address)
|
|
115
|
+
token_address = Web3.to_checksum_address(token_config.address)
|
|
116
|
+
|
|
117
|
+
# Build event filter for Transfer events
|
|
118
|
+
# Transfer(address indexed from, address indexed to, uint256 value)
|
|
119
|
+
# Topic[0] = event signature
|
|
120
|
+
# Topic[1] = from address (optional filter)
|
|
121
|
+
# Topic[2] = to address (our receiver)
|
|
122
|
+
|
|
123
|
+
topics = [
|
|
124
|
+
TRANSFER_EVENT_SIGNATURE, # Transfer event signature
|
|
125
|
+
None, # from: any sender (or filtered below)
|
|
126
|
+
"0x" + receiver_address[2:].lower().zfill(64), # to: our receiver (padded)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
# Add sender filter if specified
|
|
130
|
+
if request.sender:
|
|
131
|
+
sender_address = Web3.to_checksum_address(request.sender)
|
|
132
|
+
topics[1] = "0x" + sender_address[2:].lower().zfill(64)
|
|
133
|
+
|
|
134
|
+
# Fetch Transfer events
|
|
135
|
+
try:
|
|
136
|
+
logs = w3.eth.get_logs({
|
|
137
|
+
"address": token_address,
|
|
138
|
+
"topics": topics,
|
|
139
|
+
"fromBlock": from_block,
|
|
140
|
+
"toBlock": to_block,
|
|
141
|
+
})
|
|
142
|
+
except Web3Exception as e:
|
|
143
|
+
error_msg = str(e).lower()
|
|
144
|
+
if "rate" in error_msg or "limit" in error_msg:
|
|
145
|
+
raise VerificationError(
|
|
146
|
+
"RPC rate limit exceeded. Try again later or use a custom RPC endpoint.",
|
|
147
|
+
code="RATE_LIMITED"
|
|
148
|
+
)
|
|
149
|
+
raise VerificationError(
|
|
150
|
+
f"Failed to fetch transfer events: {str(e)}",
|
|
151
|
+
code="RPC_ERROR"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Process transfer logs
|
|
155
|
+
transfers: list[Transfer] = []
|
|
156
|
+
total_received = 0
|
|
157
|
+
|
|
158
|
+
for log in logs:
|
|
159
|
+
# Decode the transfer amount from data
|
|
160
|
+
raw_amount = int(log["data"].hex(), 16)
|
|
161
|
+
amount = wei_to_token(raw_amount, token_config.decimals)
|
|
162
|
+
|
|
163
|
+
# Get sender from topics[1]
|
|
164
|
+
sender_topic = log["topics"][1].hex()
|
|
165
|
+
sender = Web3.to_checksum_address("0x" + sender_topic[-40:])
|
|
166
|
+
|
|
167
|
+
# Calculate confirmations
|
|
168
|
+
block_number = log["blockNumber"]
|
|
169
|
+
confirmations = current_block - block_number
|
|
170
|
+
|
|
171
|
+
# Skip if not enough confirmations
|
|
172
|
+
if confirmations < request.min_confirmations:
|
|
173
|
+
# Still add to transfers but mark as pending
|
|
174
|
+
transfers.append(Transfer(
|
|
175
|
+
tx_hash=log["transactionHash"].hex(),
|
|
176
|
+
block_number=block_number,
|
|
177
|
+
sender=sender.lower(),
|
|
178
|
+
receiver=receiver_address.lower(),
|
|
179
|
+
amount=amount,
|
|
180
|
+
raw_amount=raw_amount,
|
|
181
|
+
confirmations=confirmations,
|
|
182
|
+
))
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Verify transaction success
|
|
186
|
+
try:
|
|
187
|
+
receipt = w3.eth.get_transaction_receipt(log["transactionHash"])
|
|
188
|
+
if receipt["status"] != 1:
|
|
189
|
+
continue # Skip failed transactions
|
|
190
|
+
except Web3Exception:
|
|
191
|
+
continue # Skip if we can't get receipt
|
|
192
|
+
|
|
193
|
+
# Get block timestamp
|
|
194
|
+
try:
|
|
195
|
+
block = w3.eth.get_block(block_number)
|
|
196
|
+
timestamp = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
|
|
197
|
+
except Web3Exception:
|
|
198
|
+
timestamp = None
|
|
199
|
+
|
|
200
|
+
transfer = Transfer(
|
|
201
|
+
tx_hash=log["transactionHash"].hex(),
|
|
202
|
+
block_number=block_number,
|
|
203
|
+
sender=sender.lower(),
|
|
204
|
+
receiver=receiver_address.lower(),
|
|
205
|
+
amount=amount,
|
|
206
|
+
raw_amount=raw_amount,
|
|
207
|
+
timestamp=timestamp,
|
|
208
|
+
confirmations=confirmations,
|
|
209
|
+
)
|
|
210
|
+
transfers.append(transfer)
|
|
211
|
+
total_received += amount
|
|
212
|
+
|
|
213
|
+
# Determine payment status
|
|
214
|
+
tolerance_amount = request.amount * request.tolerance
|
|
215
|
+
min_acceptable = request.amount - tolerance_amount
|
|
216
|
+
|
|
217
|
+
# Check for pending transfers (not enough confirmations)
|
|
218
|
+
pending_amount = sum(t.amount for t in transfers if t.confirmations < request.min_confirmations)
|
|
219
|
+
confirmed_amount = total_received
|
|
220
|
+
|
|
221
|
+
if confirmed_amount >= min_acceptable:
|
|
222
|
+
status = PaymentStatus.PAID
|
|
223
|
+
elif confirmed_amount > 0:
|
|
224
|
+
status = PaymentStatus.PARTIAL
|
|
225
|
+
elif pending_amount >= min_acceptable:
|
|
226
|
+
status = PaymentStatus.PENDING
|
|
227
|
+
else:
|
|
228
|
+
status = PaymentStatus.NOT_PAID
|
|
229
|
+
|
|
230
|
+
# Get the most recent confirmed transfer for result details
|
|
231
|
+
confirmed_transfers = [t for t in transfers if t.confirmations >= request.min_confirmations]
|
|
232
|
+
latest_transfer: Optional[Transfer] = None
|
|
233
|
+
if confirmed_transfers:
|
|
234
|
+
latest_transfer = max(confirmed_transfers, key=lambda t: t.block_number)
|
|
235
|
+
|
|
236
|
+
return PaymentResult(
|
|
237
|
+
status=status,
|
|
238
|
+
expected_amount=request.amount,
|
|
239
|
+
matched_amount=confirmed_amount,
|
|
240
|
+
transaction_hash=latest_transfer.tx_hash if latest_transfer else None,
|
|
241
|
+
block_number=latest_transfer.block_number if latest_transfer else None,
|
|
242
|
+
timestamp=latest_transfer.timestamp if latest_transfer else None,
|
|
243
|
+
confirmations=latest_transfer.confirmations if latest_transfer else 0,
|
|
244
|
+
sender=latest_transfer.sender if latest_transfer else None,
|
|
245
|
+
receiver=request.address.lower(),
|
|
246
|
+
token=request.token,
|
|
247
|
+
chain=request.chain,
|
|
248
|
+
transfers=transfers,
|
|
249
|
+
)
|