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,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,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)
|