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,18 @@
1
+ """
2
+ StablePay Verifier - Verify stablecoin payments on-chain.
3
+
4
+ No custody. No fees. Just truth.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __app_name__ = "stablepay"
9
+
10
+ from stablepay_verifier.models import PaymentResult, PaymentStatus, VerifyRequest
11
+ from stablepay_verifier.verifier import verify_payment
12
+
13
+ __all__ = [
14
+ "verify_payment",
15
+ "PaymentResult",
16
+ "PaymentStatus",
17
+ "VerifyRequest",
18
+ ]
@@ -0,0 +1,6 @@
1
+ """Entry point for running as module: python -m stablepay_verifier"""
2
+
3
+ from stablepay_verifier.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,177 @@
1
+ """
2
+ Chain and token configurations for StablePay Verifier.
3
+ """
4
+
5
+ from stablepay_verifier.models import ChainConfig, TokenConfig
6
+
7
+ # Supported blockchain networks
8
+ CHAINS: dict[str, ChainConfig] = {
9
+ "polygon": ChainConfig(
10
+ name="Polygon",
11
+ chain_id=137,
12
+ default_rpc="https://polygon-rpc.com",
13
+ block_time=2.0,
14
+ explorer_url="https://polygonscan.com",
15
+ ),
16
+ "ethereum": ChainConfig(
17
+ name="Ethereum",
18
+ chain_id=1,
19
+ default_rpc="https://eth.llamarpc.com",
20
+ block_time=12.0,
21
+ explorer_url="https://etherscan.io",
22
+ ),
23
+ "arbitrum": ChainConfig(
24
+ name="Arbitrum One",
25
+ chain_id=42161,
26
+ default_rpc="https://arb1.arbitrum.io/rpc",
27
+ block_time=0.25,
28
+ explorer_url="https://arbiscan.io",
29
+ ),
30
+ "base": ChainConfig(
31
+ name="Base",
32
+ chain_id=8453,
33
+ default_rpc="https://mainnet.base.org",
34
+ block_time=2.0,
35
+ explorer_url="https://basescan.org",
36
+ ),
37
+ "optimism": ChainConfig(
38
+ name="Optimism",
39
+ chain_id=10,
40
+ default_rpc="https://mainnet.optimism.io",
41
+ block_time=2.0,
42
+ explorer_url="https://optimistic.etherscan.io",
43
+ ),
44
+ }
45
+
46
+ # Token contract addresses per chain
47
+ # Format: TOKENS[chain][symbol] = TokenConfig
48
+ TOKENS: dict[str, dict[str, TokenConfig]] = {
49
+ "polygon": {
50
+ "USDC": TokenConfig(
51
+ symbol="USDC",
52
+ name="USD Coin",
53
+ address="0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
54
+ decimals=6,
55
+ ),
56
+ "USDT": TokenConfig(
57
+ symbol="USDT",
58
+ name="Tether USD",
59
+ address="0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
60
+ decimals=6,
61
+ ),
62
+ "DAI": TokenConfig(
63
+ symbol="DAI",
64
+ name="Dai Stablecoin",
65
+ address="0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063",
66
+ decimals=18,
67
+ ),
68
+ },
69
+ "ethereum": {
70
+ "USDC": TokenConfig(
71
+ symbol="USDC",
72
+ name="USD Coin",
73
+ address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
74
+ decimals=6,
75
+ ),
76
+ "USDT": TokenConfig(
77
+ symbol="USDT",
78
+ name="Tether USD",
79
+ address="0xdAC17F958D2ee523a2206206994597C13D831ec7",
80
+ decimals=6,
81
+ ),
82
+ "DAI": TokenConfig(
83
+ symbol="DAI",
84
+ name="Dai Stablecoin",
85
+ address="0x6B175474E89094C44Da98b954EesfdC1D3709CEc",
86
+ decimals=18,
87
+ ),
88
+ },
89
+ "arbitrum": {
90
+ "USDC": TokenConfig(
91
+ symbol="USDC",
92
+ name="USD Coin",
93
+ address="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
94
+ decimals=6,
95
+ ),
96
+ "USDT": TokenConfig(
97
+ symbol="USDT",
98
+ name="Tether USD",
99
+ address="0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
100
+ decimals=6,
101
+ ),
102
+ },
103
+ "base": {
104
+ "USDC": TokenConfig(
105
+ symbol="USDC",
106
+ name="USD Coin",
107
+ address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
108
+ decimals=6,
109
+ ),
110
+ },
111
+ "optimism": {
112
+ "USDC": TokenConfig(
113
+ symbol="USDC",
114
+ name="USD Coin",
115
+ address="0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
116
+ decimals=6,
117
+ ),
118
+ "USDT": TokenConfig(
119
+ symbol="USDT",
120
+ name="Tether USD",
121
+ address="0x94b008aA00579c1307B0EF2c499aD98a8ce58e58",
122
+ decimals=6,
123
+ ),
124
+ },
125
+ }
126
+
127
+ # ERC20 Transfer event signature
128
+ TRANSFER_EVENT_SIGNATURE = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
129
+
130
+ # ERC20 ABI (minimal for transfer events)
131
+ ERC20_ABI = [
132
+ {
133
+ "anonymous": False,
134
+ "inputs": [
135
+ {"indexed": True, "name": "from", "type": "address"},
136
+ {"indexed": True, "name": "to", "type": "address"},
137
+ {"indexed": False, "name": "value", "type": "uint256"},
138
+ ],
139
+ "name": "Transfer",
140
+ "type": "event",
141
+ },
142
+ {
143
+ "inputs": [],
144
+ "name": "decimals",
145
+ "outputs": [{"name": "", "type": "uint8"}],
146
+ "stateMutability": "view",
147
+ "type": "function",
148
+ },
149
+ {
150
+ "inputs": [],
151
+ "name": "symbol",
152
+ "outputs": [{"name": "", "type": "string"}],
153
+ "stateMutability": "view",
154
+ "type": "function",
155
+ },
156
+ ]
157
+
158
+
159
+ def get_chain_config(chain: str) -> ChainConfig | None:
160
+ """Get chain configuration by name."""
161
+ return CHAINS.get(chain.lower())
162
+
163
+
164
+ def get_token_config(chain: str, symbol: str) -> TokenConfig | None:
165
+ """Get token configuration for a chain."""
166
+ chain_tokens = TOKENS.get(chain.lower(), {})
167
+ return chain_tokens.get(symbol.upper())
168
+
169
+
170
+ def get_supported_chains() -> list[str]:
171
+ """Get list of supported chain names."""
172
+ return list(CHAINS.keys())
173
+
174
+
175
+ def get_supported_tokens(chain: str) -> list[str]:
176
+ """Get list of supported tokens for a chain."""
177
+ return list(TOKENS.get(chain.lower(), {}).keys())
@@ -0,0 +1,366 @@
1
+ """
2
+ CLI interface for StablePay Verifier.
3
+ """
4
+
5
+ import json
6
+ import sys
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from stablepay_verifier import __version__
16
+ from stablepay_verifier.chains import (
17
+ CHAINS,
18
+ TOKENS,
19
+ get_supported_chains,
20
+ get_supported_tokens,
21
+ )
22
+ from stablepay_verifier.models import PaymentStatus, VerifyRequest
23
+ from stablepay_verifier.utils import format_address, format_amount, format_timestamp
24
+ from stablepay_verifier.verifier import VerificationError, verify_payment
25
+
26
+ # Initialize Typer app and Rich console
27
+ app = typer.Typer(
28
+ name="stablepay",
29
+ help="🔍 Verify stablecoin payments on-chain. No custody. No fees. Just truth.",
30
+ no_args_is_help=True,
31
+ rich_markup_mode="rich",
32
+ )
33
+ console = Console()
34
+
35
+ # Exit codes
36
+ EXIT_PAID = 0
37
+ EXIT_NOT_PAID = 1
38
+ EXIT_PARTIAL = 2
39
+ EXIT_PENDING = 3
40
+ EXIT_ERROR = 10
41
+ EXIT_RPC_ERROR = 11
42
+
43
+
44
+ def version_callback(value: bool) -> None:
45
+ """Show version and exit."""
46
+ if value:
47
+ console.print(f"[bold blue]StablePay Verifier[/bold blue] v{__version__}")
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.callback()
52
+ def main(
53
+ version: Optional[bool] = typer.Option(
54
+ None,
55
+ "--version",
56
+ "-V",
57
+ callback=version_callback,
58
+ is_eager=True,
59
+ help="Show version and exit.",
60
+ ),
61
+ ) -> None:
62
+ """
63
+ 🔍 StablePay Verifier - Verify stablecoin payments on-chain.
64
+
65
+ No custody. No fees. Just truth.
66
+ """
67
+ pass
68
+
69
+
70
+ @app.command()
71
+ def verify(
72
+ address: str = typer.Option(
73
+ ...,
74
+ "--address",
75
+ "-a",
76
+ help="Receiver wallet address (0x...)",
77
+ ),
78
+ amount: float = typer.Option(
79
+ ...,
80
+ "--amount",
81
+ "-m",
82
+ help="Expected payment amount",
83
+ ),
84
+ token: str = typer.Option(
85
+ "USDC",
86
+ "--token",
87
+ "-t",
88
+ help="Token symbol (USDC, USDT, DAI)",
89
+ ),
90
+ chain: str = typer.Option(
91
+ "polygon",
92
+ "--chain",
93
+ "-c",
94
+ help="Blockchain network (polygon, ethereum, arbitrum, base, optimism)",
95
+ ),
96
+ rpc: Optional[str] = typer.Option(
97
+ None,
98
+ "--rpc",
99
+ "-r",
100
+ help="Custom RPC endpoint URL",
101
+ ),
102
+ time_window: str = typer.Option(
103
+ "24h",
104
+ "--time-window",
105
+ "-w",
106
+ help="Time window to search (e.g., 1h, 24h, 7d)",
107
+ ),
108
+ sender: Optional[str] = typer.Option(
109
+ None,
110
+ "--sender",
111
+ "-s",
112
+ help="Filter by sender address",
113
+ ),
114
+ min_confirmations: int = typer.Option(
115
+ 12,
116
+ "--min-confirmations",
117
+ help="Minimum block confirmations required",
118
+ ),
119
+ tolerance: float = typer.Option(
120
+ 0.01,
121
+ "--tolerance",
122
+ help="Amount tolerance as decimal (0.01 = 1%%)",
123
+ ),
124
+ output_format: str = typer.Option(
125
+ "text",
126
+ "--output",
127
+ "-o",
128
+ help="Output format: text or json",
129
+ ),
130
+ quiet: bool = typer.Option(
131
+ False,
132
+ "--quiet",
133
+ "-q",
134
+ help="Minimal output (just status)",
135
+ ),
136
+ verbose: bool = typer.Option(
137
+ False,
138
+ "--verbose",
139
+ "-v",
140
+ help="Show debug information",
141
+ ),
142
+ ) -> None:
143
+ """
144
+ Verify a stablecoin payment was received.
145
+
146
+ Examples:
147
+
148
+ stablepay verify -a 0x742d35... -m 100
149
+
150
+ stablepay verify -a 0x742d35... -m 50 -t USDT -c ethereum
151
+
152
+ stablepay verify -a 0x742d35... -m 100 --time-window 7d
153
+ """
154
+ try:
155
+ # Create verification request
156
+ request = VerifyRequest(
157
+ address=address,
158
+ amount=amount,
159
+ token=token,
160
+ chain=chain,
161
+ rpc=rpc,
162
+ sender=sender,
163
+ time_window=time_window,
164
+ min_confirmations=min_confirmations,
165
+ tolerance=tolerance,
166
+ )
167
+ except ValueError as e:
168
+ _handle_error(str(e), "INVALID_INPUT", output_format, quiet)
169
+ raise typer.Exit(EXIT_ERROR)
170
+
171
+ # Show progress if not quiet
172
+ if not quiet and output_format == "text":
173
+ console.print(f"\n[dim]Verifying {token} payment on {chain.title()}...[/dim]")
174
+
175
+ # Perform verification
176
+ try:
177
+ result = verify_payment(request)
178
+ except VerificationError as e:
179
+ _handle_error(e.message, e.code, output_format, quiet)
180
+ exit_code = EXIT_RPC_ERROR if e.code == "RPC_ERROR" else EXIT_ERROR
181
+ raise typer.Exit(exit_code)
182
+ except Exception as e:
183
+ _handle_error(f"Unexpected error: {str(e)}", "UNKNOWN_ERROR", output_format, quiet)
184
+ raise typer.Exit(EXIT_ERROR)
185
+
186
+ # Output results
187
+ if output_format == "json":
188
+ _output_json(result)
189
+ elif quiet:
190
+ _output_quiet(result)
191
+ else:
192
+ _output_rich(result, verbose)
193
+
194
+ # Set exit code based on status
195
+ exit_code = {
196
+ PaymentStatus.PAID: EXIT_PAID,
197
+ PaymentStatus.NOT_PAID: EXIT_NOT_PAID,
198
+ PaymentStatus.PARTIAL: EXIT_PARTIAL,
199
+ PaymentStatus.PENDING: EXIT_PENDING,
200
+ }.get(result.status, EXIT_ERROR)
201
+
202
+ raise typer.Exit(exit_code)
203
+
204
+
205
+ @app.command()
206
+ def info() -> None:
207
+ """
208
+ Show supported chains and tokens.
209
+ """
210
+ console.print("\n[bold blue]Supported Chains & Tokens[/bold blue]\n")
211
+
212
+ for chain_name, chain_config in CHAINS.items():
213
+ table = Table(title=f"🔗 {chain_config.name} ({chain_name})", show_header=True)
214
+ table.add_column("Token", style="cyan")
215
+ table.add_column("Name", style="dim")
216
+ table.add_column("Contract Address", style="dim")
217
+
218
+ tokens = TOKENS.get(chain_name, {})
219
+ for symbol, token_config in tokens.items():
220
+ table.add_row(
221
+ symbol,
222
+ token_config.name,
223
+ format_address(token_config.address, 6),
224
+ )
225
+
226
+ if not tokens:
227
+ table.add_row("[dim]No tokens configured[/dim]", "", "")
228
+
229
+ console.print(table)
230
+ console.print()
231
+
232
+ console.print("[dim]Tip: Use --chain and --token to specify the network and token.[/dim]")
233
+ console.print("[dim]Default: USDC on Polygon[/dim]\n")
234
+
235
+
236
+ def _handle_error(message: str, code: str, output_format: str, quiet: bool) -> None:
237
+ """Handle and display errors."""
238
+ if output_format == "json":
239
+ console.print(json.dumps({
240
+ "status": "ERROR",
241
+ "error_code": code,
242
+ "message": message,
243
+ }))
244
+ elif quiet:
245
+ console.print(f"ERROR: {message}", style="red")
246
+ else:
247
+ console.print()
248
+ console.print(Panel(
249
+ f"[red bold]Error:[/red bold] {message}\n\n"
250
+ f"[dim]Error Code: {code}[/dim]",
251
+ title="❌ Verification Failed",
252
+ border_style="red",
253
+ ))
254
+
255
+
256
+ def _output_json(result) -> None:
257
+ """Output result as JSON."""
258
+ output = {
259
+ "status": result.status.value,
260
+ "expected_amount": result.expected_amount,
261
+ "matched_amount": result.matched_amount,
262
+ "transaction_hash": result.transaction_hash,
263
+ "block_number": result.block_number,
264
+ "timestamp": result.timestamp.isoformat() if result.timestamp else None,
265
+ "confirmations": result.confirmations,
266
+ "sender": result.sender,
267
+ "receiver": result.receiver,
268
+ "token": result.token,
269
+ "chain": result.chain,
270
+ "transfer_count": len(result.transfers),
271
+ }
272
+ console.print(json.dumps(output, indent=2))
273
+
274
+
275
+ def _output_quiet(result) -> None:
276
+ """Output minimal status."""
277
+ console.print(result.status.value)
278
+
279
+
280
+ def _output_rich(result, verbose: bool) -> None:
281
+ """Output rich formatted result."""
282
+ console.print()
283
+
284
+ # Determine status styling
285
+ status_styles = {
286
+ PaymentStatus.PAID: ("✅ PAYMENT VERIFIED", "green"),
287
+ PaymentStatus.NOT_PAID: ("❌ NOT PAID", "red"),
288
+ PaymentStatus.PARTIAL: ("⚠️ PARTIAL PAYMENT", "yellow"),
289
+ PaymentStatus.PENDING: ("⏳ PENDING CONFIRMATION", "yellow"),
290
+ }
291
+
292
+ status_text, status_color = status_styles.get(
293
+ result.status, ("❓ UNKNOWN", "dim")
294
+ )
295
+
296
+ # Build content
297
+ lines = []
298
+ lines.append(f"[bold]Status:[/bold] {result.status.value}")
299
+ lines.append(
300
+ f"[bold]Amount:[/bold] {format_amount(result.matched_amount)} {result.token} "
301
+ f"[dim](expected: {format_amount(result.expected_amount)})[/dim]"
302
+ )
303
+
304
+ if result.sender:
305
+ lines.append(f"[bold]From:[/bold] {format_address(result.sender)}")
306
+ lines.append(f"[bold]To:[/bold] {format_address(result.receiver)}")
307
+
308
+ if result.transaction_hash:
309
+ lines.append(f"[bold]Transaction:[/bold] {format_address(result.transaction_hash, 10)}")
310
+
311
+ if result.block_number:
312
+ lines.append(
313
+ f"[bold]Block:[/bold] {result.block_number} "
314
+ f"[dim]({result.confirmations} confirmations)[/dim]"
315
+ )
316
+
317
+ if result.timestamp:
318
+ lines.append(f"[bold]Time:[/bold] {format_timestamp(result.timestamp)}")
319
+
320
+ # Show chain info
321
+ lines.append(f"[bold]Network:[/bold] {result.chain.title()}")
322
+
323
+ content = "\n".join(lines)
324
+
325
+ console.print(Panel(
326
+ content,
327
+ title=status_text,
328
+ border_style=status_color,
329
+ padding=(1, 2),
330
+ ))
331
+
332
+ # Verbose: show all transfers
333
+ if verbose and result.transfers:
334
+ console.print("\n[bold]All Matching Transfers:[/bold]")
335
+ table = Table(show_header=True)
336
+ table.add_column("TX Hash", style="dim")
337
+ table.add_column("Amount", justify="right")
338
+ table.add_column("From", style="dim")
339
+ table.add_column("Block", justify="right")
340
+ table.add_column("Confirmations", justify="right")
341
+
342
+ for transfer in result.transfers:
343
+ conf_style = "green" if transfer.confirmations >= 12 else "yellow"
344
+ table.add_row(
345
+ format_address(transfer.tx_hash, 8),
346
+ format_amount(transfer.amount),
347
+ format_address(transfer.sender),
348
+ str(transfer.block_number),
349
+ f"[{conf_style}]{transfer.confirmations}[/{conf_style}]",
350
+ )
351
+
352
+ console.print(table)
353
+
354
+ # Tips for NOT_PAID status
355
+ if result.status == PaymentStatus.NOT_PAID:
356
+ console.print()
357
+ console.print("[dim]Tips:[/dim]")
358
+ console.print("[dim] • Try expanding the time window with --time-window 7d[/dim]")
359
+ console.print("[dim] • Verify the wallet address is correct[/dim]")
360
+ console.print("[dim] • Check if the payment was sent on the correct chain[/dim]")
361
+
362
+ console.print()
363
+
364
+
365
+ if __name__ == "__main__":
366
+ app()
@@ -0,0 +1,134 @@
1
+ """
2
+ Pydantic models for StablePay Verifier.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+
12
+ class PaymentStatus(str, Enum):
13
+ """Payment verification status."""
14
+
15
+ PAID = "PAID"
16
+ NOT_PAID = "NOT_PAID"
17
+ PARTIAL = "PARTIAL"
18
+ PENDING = "PENDING"
19
+
20
+
21
+ class ChainConfig(BaseModel):
22
+ """Configuration for a blockchain network."""
23
+
24
+ name: str
25
+ chain_id: int
26
+ default_rpc: str
27
+ block_time: float = 2.0 # Average block time in seconds
28
+ explorer_url: str = ""
29
+
30
+
31
+ class TokenConfig(BaseModel):
32
+ """Configuration for a token on a specific chain."""
33
+
34
+ symbol: str
35
+ name: str
36
+ address: str
37
+ decimals: int = 6
38
+
39
+
40
+ class Transfer(BaseModel):
41
+ """Represents a single token transfer event."""
42
+
43
+ tx_hash: str
44
+ block_number: int
45
+ sender: str
46
+ receiver: str
47
+ amount: float # Human-readable amount (after decimal conversion)
48
+ raw_amount: int # Raw amount in wei/smallest unit
49
+ timestamp: Optional[datetime] = None
50
+ confirmations: int = 0
51
+
52
+
53
+ class VerifyRequest(BaseModel):
54
+ """Request parameters for payment verification."""
55
+
56
+ address: str = Field(..., description="Receiver wallet address")
57
+ amount: float = Field(..., gt=0, description="Expected payment amount")
58
+ token: str = Field(default="USDC", description="Token symbol")
59
+ chain: str = Field(default="polygon", description="Blockchain network")
60
+ rpc: Optional[str] = Field(default=None, description="Custom RPC endpoint")
61
+ sender: Optional[str] = Field(default=None, description="Filter by sender address")
62
+ time_window: str = Field(default="24h", description="Time window to search")
63
+ from_block: Optional[int] = Field(default=None, description="Starting block number")
64
+ to_block: Optional[int] = Field(default=None, description="Ending block number")
65
+ min_confirmations: int = Field(default=12, ge=0, description="Minimum confirmations")
66
+ tolerance: float = Field(default=0.01, ge=0, le=1, description="Amount tolerance (0.01 = 1%)")
67
+
68
+ @field_validator("address", "sender", mode="before")
69
+ @classmethod
70
+ def validate_address(cls, v: Optional[str]) -> Optional[str]:
71
+ """Validate Ethereum address format."""
72
+ if v is None:
73
+ return None
74
+ v = v.strip()
75
+ if not v.startswith("0x") or len(v) != 42:
76
+ raise ValueError("Invalid address format. Expected 0x followed by 40 hex characters.")
77
+ try:
78
+ int(v, 16)
79
+ except ValueError:
80
+ raise ValueError("Invalid address format. Contains non-hexadecimal characters.")
81
+ return v.lower()
82
+
83
+ @field_validator("chain", mode="before")
84
+ @classmethod
85
+ def validate_chain(cls, v: str) -> str:
86
+ """Normalize chain name."""
87
+ return v.lower().strip()
88
+
89
+ @field_validator("token", mode="before")
90
+ @classmethod
91
+ def validate_token(cls, v: str) -> str:
92
+ """Normalize token symbol."""
93
+ return v.upper().strip()
94
+
95
+ @field_validator("time_window", mode="before")
96
+ @classmethod
97
+ def validate_time_window(cls, v: str) -> str:
98
+ """Validate time window format."""
99
+ v = v.lower().strip()
100
+ if not any(v.endswith(unit) for unit in ["h", "d", "m"]):
101
+ raise ValueError("Invalid time window format. Use: 1h, 24h, 7d, 30d")
102
+ try:
103
+ int(v[:-1])
104
+ except ValueError:
105
+ raise ValueError("Invalid time window format. Number must precede unit.")
106
+ return v
107
+
108
+
109
+ class PaymentResult(BaseModel):
110
+ """Result of a payment verification."""
111
+
112
+ status: PaymentStatus
113
+ expected_amount: float
114
+ matched_amount: float = 0.0
115
+ transaction_hash: Optional[str] = None
116
+ block_number: Optional[int] = None
117
+ timestamp: Optional[datetime] = None
118
+ confirmations: int = 0
119
+ sender: Optional[str] = None
120
+ receiver: str
121
+ token: str
122
+ chain: str
123
+ transfers: list[Transfer] = Field(default_factory=list)
124
+ error: Optional[str] = None
125
+
126
+ @property
127
+ def is_paid(self) -> bool:
128
+ """Check if payment was verified."""
129
+ return self.status == PaymentStatus.PAID
130
+
131
+ @property
132
+ def shortfall(self) -> float:
133
+ """Calculate the remaining amount needed."""
134
+ return max(0, self.expected_amount - self.matched_amount)