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.
@@ -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
+ )