From e852b9bc89f8e30bf2ecc2a9d8404982e3a9444d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 02:11:27 +0800 Subject: [PATCH] feat: initialize landing page, payment logic, bot coordination, and configuration validation systems --- frontend/.env.local | 6 +- frontend/components/auth/LoginClient.tsx | 30 +- .../landing/InstitutionalLandingPage.tsx | 128 ++-- frontend/tailwind.config.ts | 20 + scripts/debug_market_scan.py | 84 --- src/bot/runtime_coordinator.py | 31 - src/onchain/polygon_wallet_watcher.py | 549 ------------------ src/payments/contract_checkout.py | 3 - src/utils/config_validation.py | 6 - tests/test_bot_runtime_coordinator.py | 4 +- 10 files changed, 111 insertions(+), 750 deletions(-) delete mode 100644 scripts/debug_market_scan.py delete mode 100644 src/onchain/polygon_wallet_watcher.py diff --git a/frontend/.env.local b/frontend/.env.local index b0a29deb..1d094089 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -13,13 +13,13 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 # NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080 # 必填:Supabase 前端公钥(鉴权开启时必须) -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby # 必填:生产环境站点 URL(OAuth 回调强制使用此域名) # 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。 # 生产环境必须设为 https://polyweather-pro.vercel.app -NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app +NEXT_PUBLIC_SITE_URL=https://polyweather.top # 常用:前端鉴权开关 # true: 启用 Supabase 登录 diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index dabe0420..05577eba 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -63,7 +63,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { passwordSignupPlaceholder: isEn ? "Set at least 6 characters" : "设置至少 6 位密码", - loginSubmit: isEn ? "Start your weather journey" : "开启天气交易之旅", + loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅", signupSubmit: isEn ? "Create account now" : "立即创建账号", loginHint: isEn ? "After signing in, your homepage will be personalized." @@ -105,9 +105,9 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { ? "By proceeding, you agree to the Privacy Policy and Terms & Conditions." : "继续操作即代表您同意隐私政策与服务条款。", desc: isEn - ? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather-signal portfolios." - : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您看清天气信号脉络。", - trusted: isEn ? "Trusted by institutional traders" : "深受机构交易员信赖", + ? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses." + : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。", + trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖", } as const; const onResetPassword = async () => { @@ -253,45 +253,45 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
-

+

{isEn ? ( <> Weather intelligence and risk management{" "} - + simplified. ) : ( <> 天气信息与风险管理{" "} - + 化繁为简。 )}

-

+

{copy.desc}

{/* High-Fidelity Mock Terminal Preview Widget */} -
+
{/* Terminal Top Window Controls */}
- POLYWEATHER_TERMINAL_v1.7 + POLYWEATHER_CONSOLE_v1.7
- {isEn ? "Runway 02L Settlement" : "跑道 02L 官方结算"} + {isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
LIVE @@ -304,7 +304,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { 28.8°C
- {isEn ? "UMA Threshold" : "结算阈值"} + {isEn ? "Target Threshold" : "监控阈值"} 30.0°C
@@ -312,8 +312,8 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { 88.5%
- {isEn ? "Index Price" : "结算指数"} - $9.20 + {isEn ? "Observed Peak" : "今日最高"} + 29.2°C
@@ -335,7 +335,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { {/* Threshold Line (30.0°C) */} - 30.0°C UMA + 30.0°C Target {/* Gradient Area under Forecast */} @@ -214,35 +214,49 @@ function InstitutionalLandingScreen() {
-
-
- - {isEn ? "Paid professional terminal" : "付费专业交易终端"} +
+
+ + {isEn ? "Paid professional dashboard" : "付费专业气象决策台"}
-

- {isEn ? "Institutional weather signal intelligence for paid users." : "面向付费用户的机构级天气决策台"} +

+ {isEn ? ( + <> + Institutional weather intelligence for{" "} + + professional teams. + + + ) : ( + <> + 面向专业团队的机构级{" "} + + 天气决策台 + + + )}

-

+

{isEn - ? "PolyWeather turns live METAR observations, DEB forecast blends, model probabilities, and signal settlement logic into one professional decision workspace." - : "PolyWeather 将 METAR 机场实测、DEB 智能融合预报、模型概率及信号结算逻辑整合于一体,打造气象风险管理专业决策环境。"} + ? "PolyWeather turns live METAR observations, DEB forecast blends, model probabilities, and deviation verification logic into one professional decision workspace." + : "PolyWeather 将 METAR 机场实测、DEB 智能融合预报、模型概率及偏差校验逻辑整合于一体,打造气象风险管理专业决策环境。"}

-
+
{isEn ? "Enter product" : "进入产品决策台"} - + {isEn ? "Subscribe / Manage account" : "订阅服务 / 管理账户"}
-

+

{isEn ? "No free product access. Subscription is required before the terminal opens." : "无免费公开产品通道。在使用决策台前必须先登录并开通订阅。"} @@ -253,19 +267,19 @@ function InstitutionalLandingScreen() {

- {isEn ? "Weather Signals Dashboard" : "天气信号交易面板"} + {isEn ? "Weather Intelligence Console" : "气象决策分析台"}
- + {isEn ? "Live" : "实时数据"}
-
-
- {isEn ? "Temperature Thresholds" : "温度天气阈值"} - - {isEn ? "Price / Edge / Signal" : "价格 / 偏差 / 信号"} +
+
+ {isEn ? "Temperature Metrics" : "温度决策指标"} + + {isEn ? "Observed / Deviation / Decision" : "实测 / 偏差 / 决策"}
@@ -320,13 +334,13 @@ function InstitutionalLandingScreen() {
- - {isEn ? "Current Signal" : "当前交易信号"} + + {isEn ? "Current Anomaly / Action" : "当前异常与决策"}
-

+

{isEn - ? "New York high-temperature signal shows a positive observation deviation with confirmed airport evidence." - : "纽约高温阈值信号出现显著的正向观测偏差,机场天气实况已验证确认。"} + ? "New York high-temperature target shows a positive observation deviation with confirmed airport evidence." + : "纽约高温阈值监测出现显著的正向观测偏差,机场天气实况已验证确认。"}

@@ -355,8 +369,8 @@ function InstitutionalLandingScreen() {

{isEn ? "Data Coverage" : "数据覆盖范围"}

-

- {isEn ? "Everything weather-signal users need in one place." : "天气信号交易者所需的一切,在此集结。"} +

+ {isEn ? "Everything weather intelligence users need in one place." : "气象决策分析人员所需的一切,在此集结。"}

@@ -394,18 +408,18 @@ function InstitutionalLandingScreen() {

-
- {isEn ? "Pro Terminal" : "专业终端"} +
+ {isEn ? "Pro Workspace" : "专业决策分析台"}
-

+

PolyWeather Pro

-

+

{isEn - ? "Full access to the institutional weather-signal terminal. Live METAR, DEB forecasts, probability distribution, AI decision cards, and real-time alerts." - : "完整访问机构级天气信号终端。实时 METAR、DEB 预报、概率分布、AI 决策卡片、实时通知。"} + ? "Full access to the institutional weather intelligence workspace. Live METAR, DEB forecasts, probability distribution, AI decision cards, and real-time alerts." + : "完整访问机构级天气决策分析台。实时 METAR、DEB 预报、概率分布、AI 决策卡片、实时通知。"}

diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index a6206b44..c43677f5 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -77,6 +77,26 @@ const config: Config = { "elevation-2": "var(--shadow-elevation-2)", "elevation-3": "var(--shadow-elevation-3)", }, + keyframes: { + "fade-up": { + "0%": { opacity: "0", transform: "translateY(20px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + "fade-in": { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, + gradient: { + "0%": { backgroundPosition: "0% 50%" }, + "50%": { backgroundPosition: "100% 50%" }, + "100%": { backgroundPosition: "0% 50%" }, + }, + }, + animation: { + "fade-up": "fade-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards", + "fade-in": "fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards", + gradient: "gradient 8s ease infinite", + }, }, }, plugins: [], diff --git a/scripts/debug_market_scan.py b/scripts/debug_market_scan.py deleted file mode 100644 index 7e4a9990..00000000 --- a/scripts/debug_market_scan.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib -import json -import os -import sys -from pathlib import Path -from typing import Any, Dict, Optional - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - - -def _safe_float(value: Optional[str]) -> Optional[float]: - if value is None: - return None - try: - return float(value) - except Exception: - return None - - -def _bucket_from_temp(value: Optional[str]) -> Optional[Dict[str, Any]]: - temp = _safe_float(value) - if temp is None: - return None - return { - "value": temp, - "temp": temp, - "label": f"{temp:g}°C", - } - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Print a read-only Polymarket market-scan diagnostic payload.", - ) - parser.add_argument("--city", required=True, help="City key or display name.") - parser.add_argument("--date", required=True, help="Target date, YYYY-MM-DD.") - parser.add_argument( - "--bucket-temp", - help="Optional model/probability bucket temperature in Celsius.", - ) - parser.add_argument( - "--model-probability", - help="Optional model probability. Accepts 0-1 or 0-100.", - ) - parser.add_argument( - "--market-slug", - help="Optional Polymarket event/market slug.", - ) - parser.add_argument( - "--debug-log", - action="store_true", - help="Also enable POLYMARKET_MARKET_SCAN_DEBUG for this process.", - ) - args = parser.parse_args() - - if args.debug_log: - os.environ["POLYMARKET_MARKET_SCAN_DEBUG"] = "true" - - model_probability = _safe_float(args.model_probability) - if model_probability is not None and model_probability > 1.0: - model_probability = model_probability / 100.0 - - module = importlib.import_module("src.data_collection.polymarket_readonly") - layer = module.PolymarketReadOnlyLayer() - scan = layer.build_market_scan( - city=args.city, - target_date=args.date, - temperature_bucket=_bucket_from_temp(args.bucket_temp), - model_probability=model_probability, - forced_market_slug=args.market_slug, - ) - - print(json.dumps(scan, ensure_ascii=False, indent=2, default=str)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 3da7d9b7..fa9e6ee5 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -84,7 +84,6 @@ class StartupCoordinator: def start_all(self) -> RuntimeStatus: loops = [ self._start_airport_high_freq_loop(), - self._start_polygon_wallet_loop(), self._start_weekly_reward_loop(), self._start_payment_event_loop(), self._start_payment_confirm_loop(), @@ -188,36 +187,6 @@ class StartupCoordinator: ), ) - def _start_polygon_wallet_loop(self) -> LoopStatus: - enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False) - chat_ids = get_telegram_chat_ids_from_env() - rpc_url = str(os.getenv("POLYGON_RPC_URL") or "").strip() - wallets_count = _parse_csv_count(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES")) - poll = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8)) - details = { - "poll_sec": poll, - "wallets_count": wallets_count, - "polymarket_only": _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True), - "chat_targets": len(chat_ids), - } - validation_error = None - if not chat_ids: - validation_error = "missing_TELEGRAM_CHAT_IDS" - elif not rpc_url: - validation_error = "missing_POLYGON_RPC_URL" - elif wallets_count == 0: - validation_error = "missing_POLYGON_WALLET_WATCH_ADDRESSES" - return self._start_with_validation( - key="polygon_wallet_watch", - label="Polygon 钱包监听", - configured_enabled=enabled, - details=details, - validation_error=validation_error, - starter=lambda: import_module( - "src.onchain.polygon_wallet_watcher" - ).start_polygon_wallet_watch_loop(self.bot), - ) - def _start_weekly_reward_loop(self) -> LoopStatus: enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True) chat_ids = get_telegram_chat_ids_from_env() diff --git a/src/onchain/polygon_wallet_watcher.py b/src/onchain/polygon_wallet_watcher.py deleted file mode 100644 index fc58a916..00000000 --- a/src/onchain/polygon_wallet_watcher.py +++ /dev/null @@ -1,549 +0,0 @@ -import json -import os -import threading -import time -from datetime import datetime, timezone -from decimal import Decimal, InvalidOperation -from typing import Any, Dict, List, Optional, Set, Tuple - -from loguru import logger -from web3 import Web3 - -from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env - -TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" -APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925" -ERC20_ABI = [ - { - "constant": True, - "inputs": [], - "name": "symbol", - "outputs": [{"name": "", "type": "string"}], - "type": "function", - }, - { - "constant": True, - "inputs": [], - "name": "decimals", - "outputs": [{"name": "", "type": "uint8"}], - "type": "function", - }, -] - -# Source: Polymarket official developer docs (Polygon contract addresses) -# https://docs.polymarket.com/developers/market-makers/setup -DEFAULT_POLYMARKET_CONTRACTS: Dict[str, str] = { - "USDC": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", - "pUSD": "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb", - "CTF": "0x4d97dcd97ec945f40cf65f87097ace5ea0476045", - "CTF_EXCHANGE": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", - "NEG_RISK_CTF_EXCHANGE": "0xC5d563A36AE78145C45a50134d48A1215220f80a", - "NEG_RISK_ADAPTER": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296", -} - - -def _env_bool(name: str, default: bool) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return raw.strip().lower() in {"1", "true", "yes", "on"} - - -def _env_int(name: str, default: int) -> int: - raw = os.getenv(name) - if raw is None: - return default - try: - return int(raw) - except Exception: - return default - - -def _short(addr: str, left: int = 6, right: int = 4) -> str: - if not addr: - return "unknown" - if len(addr) <= left + right + 2: - return addr - return f"{addr[:left + 2]}...{addr[-right:]}" - - -def _state_file() -> str: - root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - return os.path.join(root, "data", "polygon_wallet_watch_state.json") - - -def _load_state(path: str) -> Dict[str, Any]: - if not os.path.exists(path): - return {"last_scanned_block": 0, "seen_tx": {}} - try: - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - if isinstance(data, dict): - data.setdefault("last_scanned_block", 0) - data.setdefault("seen_tx", {}) - return data - except Exception as exc: - logger.warning(f"failed to load polygon watch state: {exc}") - return {"last_scanned_block": 0, "seen_tx": {}} - - -def _save_state(path: str, state: Dict[str, Any]) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp_path = f"{path}.tmp" - with open(tmp_path, "w", encoding="utf-8") as fh: - json.dump(state, fh, ensure_ascii=False, indent=2) - os.replace(tmp_path, path) - - -def _cleanup_seen_tx(state: Dict[str, Any], now_ts: int, keep_sec: int) -> None: - seen = state.get("seen_tx", {}) - if not isinstance(seen, dict): - state["seen_tx"] = {} - return - stale = [key for key, value in seen.items() if now_ts - int(value or 0) > keep_sec] - for tx_hash in stale: - seen.pop(tx_hash, None) - - -def _normalize_addr(value: Any) -> str: - if value is None: - return "" - text = str(value).strip().lower() - if text.startswith("0x") and len(text) == 42: - return text - return "" - - -def _parse_addresses(raw: Optional[str]) -> Set[str]: - out: Set[str] = set() - if not raw: - return out - for part in raw.split(","): - addr = _normalize_addr(part) - if addr: - out.add(addr) - return out - - -def _parse_polymarket_contracts(raw: Optional[str]) -> Dict[str, str]: - """ - Parse env like: - - "0xabc...,0xdef..." - - "CTF:0xabc...,EXCHANGE:0xdef..." - """ - result: Dict[str, str] = {} - if not raw: - return result - for part in raw.split(","): - segment = str(part).strip() - if not segment: - continue - label = "CUSTOM_PM" - address_part = segment - if ":" in segment: - maybe_label, maybe_addr = segment.split(":", 1) - maybe_addr_n = _normalize_addr(maybe_addr) - if maybe_addr_n: - label = (maybe_label or "CUSTOM_PM").strip() or "CUSTOM_PM" - address_part = maybe_addr_n - addr = _normalize_addr(address_part) - if not addr: - continue - if addr not in result: - result[addr] = label - return result - - -def _build_polymarket_contract_map() -> Dict[str, str]: - include_defaults = _env_bool("POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS", True) - merged: Dict[str, str] = {} - - if include_defaults: - for label, addr in DEFAULT_POLYMARKET_CONTRACTS.items(): - normalized = _normalize_addr(addr) - if normalized: - merged[normalized] = label - - custom = _parse_polymarket_contracts(os.getenv("POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS")) - for addr, label in custom.items(): - merged[addr] = label - - return merged - - -def _polygon_scan_tx_url(tx_hash: str) -> str: - base = os.getenv("POLYGON_WALLET_WATCH_TX_BASE") or "https://polygonscan.com/tx/" - return f"{base.rstrip('/')}/{tx_hash}" - - -def _polygon_scan_addr_url(address: str) -> str: - base = os.getenv("POLYGON_WALLET_WATCH_ADDR_BASE") or "https://polygonscan.com/address/" - return f"{base.rstrip('/')}/{address}" - - -def _format_matic(wei_value: int) -> str: - try: - matic = Decimal(wei_value) / Decimal(10**18) - except (InvalidOperation, ValueError): - return "0" - - if matic == matic.to_integral_value(): - return f"{int(matic)}" - return f"{matic.normalize():f}".rstrip("0").rstrip(".") - - -def _format_amount(amount: Decimal) -> str: - if amount == amount.to_integral_value(): - return str(int(amount)) - return f"{amount.normalize():f}".rstrip("0").rstrip(".") - - -def _safe_lower(value: Any) -> str: - if value is None: - return "" - return str(value).lower() - - -def _topic_to_addr(topic: Any) -> str: - try: - return _normalize_addr("0x" + topic.hex()[-40:]) - except Exception: - return "" - - -def _get_token_meta( - w3: Web3, - token_addr: str, - token_meta_cache: Dict[str, Tuple[str, int]], -) -> Tuple[str, int]: - symbol, decimals = token_meta_cache.get(token_addr, ("ERC20", 18)) - if token_addr in token_meta_cache: - return symbol, decimals - - try: - token = w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=ERC20_ABI) - symbol_raw = token.functions.symbol().call() - decimals_raw = token.functions.decimals().call() - symbol = str(symbol_raw or "ERC20") - decimals = int(decimals_raw) - except Exception: - symbol = "ERC20" - decimals = 18 - - token_meta_cache[token_addr] = (symbol, decimals) - return symbol, decimals - - -def _extract_receipt_signals( - w3: Web3, - receipt: Any, - watch_set: Set[str], - pm_contracts: Dict[str, str], - token_meta_cache: Dict[str, Tuple[str, int]], -) -> Dict[str, Any]: - transfer_lines: List[str] = [] - approval_lines: List[str] = [] - touched_labels: Set[str] = set() - pm_hit = False - - for log in receipt.logs or []: - try: - log_addr = _normalize_addr(log.address) - if log_addr in pm_contracts: - pm_hit = True - touched_labels.add(pm_contracts[log_addr]) - - topics = log.topics or [] - if not topics: - continue - topic0 = topics[0].hex().lower() - - if topic0 == TRANSFER_TOPIC and len(topics) >= 3: - from_addr = _topic_to_addr(topics[1]) - to_addr = _topic_to_addr(topics[2]) - if from_addr not in watch_set and to_addr not in watch_set: - continue - - other_addr = to_addr if from_addr in watch_set else from_addr - other_label = pm_contracts.get(other_addr) - if other_label: - pm_hit = True - touched_labels.add(other_label) - - symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache) - amount_int = int(log.data.hex(), 16) if log.data else 0 - amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0))) - - if from_addr in watch_set and to_addr in watch_set: - direction = "SELF" - elif to_addr in watch_set: - direction = "IN" - else: - direction = "OUT" - - # Keep transfer line only when it is clearly Polymarket related. - if other_label or log_addr in pm_contracts: - target = other_label or pm_contracts.get(log_addr) or _short(other_addr) - transfer_lines.append( - f"- {direction} {symbol}: {_format_amount(amount)} (对手: {target})" - ) - - if topic0 == APPROVAL_TOPIC and len(topics) >= 3: - owner = _topic_to_addr(topics[1]) - spender = _topic_to_addr(topics[2]) - if owner not in watch_set: - continue - spender_label = pm_contracts.get(spender) - if not spender_label: - continue - - pm_hit = True - touched_labels.add(spender_label) - - symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache) - amount_int = int(log.data.hex(), 16) if log.data else 0 - amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0))) - approval_lines.append( - f"- APPROVE {symbol}: {_format_amount(amount)} -> {spender_label}" - ) - except Exception: - continue - - return { - "pm_hit": pm_hit, - "transfer_lines": transfer_lines, - "approval_lines": approval_lines, - "touched_labels": sorted(touched_labels), - } - - -def _build_message( - tx: Any, - block_ts: int, - matched_wallet: str, - touched_labels: List[str], - transfer_lines: List[str], - approval_lines: List[str], - tx_to_label: Optional[str], -) -> str: - tx_hash = tx["hash"].hex() - from_addr = _safe_lower(tx.get("from")) - to_addr = _safe_lower(tx.get("to")) - - if from_addr == matched_wallet and to_addr == matched_wallet: - direction = "SELF" - elif to_addr == matched_wallet: - direction = "IN" - elif from_addr == matched_wallet: - direction = "OUT" - else: - direction = "RELATED" - - matic_value = int(tx.get("value", 0) or 0) - block_time = datetime.fromtimestamp(block_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - selector = str(tx.get("input") or "")[:10] if tx.get("input") else "0x" - - lines = [ - "⛓ Polymarket 钱包动作", - f"钱包: {_short(matched_wallet)}", - f"方向: {direction}", - f"MATIC: {_format_matic(matic_value)}", - f"区块: {tx.get('blockNumber')}", - f"时间: {block_time}", - f"方法选择器: {selector}", - ] - - if tx_to_label: - lines.append(f"直连合约: {tx_to_label}") - - if touched_labels: - lines.append(f"相关合约: {', '.join(touched_labels)}") - - if transfer_lines: - lines.append("Token 动作:") - lines.extend(transfer_lines[:6]) - - if approval_lines: - lines.append("授权动作:") - lines.extend(approval_lines[:4]) - - lines.append(f"Tx: {tx_hash}") - lines.append(f"交易链接: {_polygon_scan_tx_url(tx_hash)}") - lines.append(f"钱包链接: {_polygon_scan_addr_url(matched_wallet)}") - return "\n".join(lines) - - -def start_polygon_wallet_watch_loop(bot: Any) -> Optional[threading.Thread]: - enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False) - chat_ids = get_telegram_chat_ids_from_env() - rpc_url = os.getenv("POLYGON_RPC_URL") - watch_set = _parse_addresses(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES")) - polymarket_only = _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True) - pm_contracts = _build_polymarket_contract_map() - - if not enabled: - logger.info("polygon wallet watcher disabled") - return None - if not chat_ids: - logger.warning("polygon wallet watcher skipped: TELEGRAM_CHAT_IDS is not set") - return None - if not rpc_url: - logger.warning("polygon wallet watcher skipped: POLYGON_RPC_URL is not set") - return None - if not watch_set: - logger.warning("polygon wallet watcher skipped: POLYGON_WALLET_WATCH_ADDRESSES is empty") - return None - if polymarket_only and not pm_contracts: - logger.warning("polygon wallet watcher skipped: no polymarket contracts configured") - return None - - poll_sec = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8)) - confirmations = max(0, _env_int("POLYGON_WALLET_WATCH_CONFIRMATIONS", 2)) - max_blocks_per_cycle = max(1, _env_int("POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE", 30)) - seen_ttl_sec = max(3600, _env_int("POLYGON_WALLET_WATCH_SEEN_TTL_SEC", 7 * 86400)) - state_path = _state_file() - - provider_timeout = max(5, _env_int("POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC", 10)) - w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": provider_timeout})) - - def _runner() -> None: - token_meta_cache: Dict[str, Tuple[str, int]] = {} - state = _load_state(state_path) - - if not w3.is_connected(): - logger.error("polygon wallet watcher failed: cannot connect to POLYGON_RPC_URL") - return - - try: - chain_id = int(w3.eth.chain_id) - if chain_id != 137: - logger.warning(f"polygon wallet watcher connected to unexpected chain_id={chain_id}") - except Exception as exc: - logger.warning(f"polygon wallet watcher cannot read chain id: {exc}") - - latest_block = int(w3.eth.block_number) - if int(state.get("last_scanned_block") or 0) <= 0: - state["last_scanned_block"] = max(0, latest_block - confirmations) - _save_state(state_path, state) - - logger.info( - f"polygon wallet watcher started wallets={len(watch_set)} " - f"polymarket_only={polymarket_only} pm_contracts={len(pm_contracts)} " - f"poll={poll_sec}s confirmations={confirmations} chat_targets={len(chat_ids)} " - f"state_path={state_path}" - ) - - while True: - cycle_ts = int(time.time()) - try: - _cleanup_seen_tx(state, cycle_ts, seen_ttl_sec) - - latest = int(w3.eth.block_number) - safe_latest = latest - confirmations - last_scanned = int(state.get("last_scanned_block") or 0) - - if safe_latest <= last_scanned: - time.sleep(poll_sec) - continue - - from_block = last_scanned + 1 - to_block = min(safe_latest, from_block + max_blocks_per_cycle - 1) - - for block_num in range(from_block, to_block + 1): - block = w3.eth.get_block(block_num, full_transactions=True) - block_ts = int(block.get("timestamp") or cycle_ts) - - for tx in block.transactions or []: - tx_hash = tx["hash"].hex().lower() - from_addr = _safe_lower(tx.get("from")) - to_addr = _safe_lower(tx.get("to")) - - matched_wallet = "" - if from_addr in watch_set: - matched_wallet = from_addr - elif to_addr in watch_set: - matched_wallet = to_addr - - if not matched_wallet: - continue - - if tx_hash in state.get("seen_tx", {}): - continue - - tx_to = _normalize_addr(tx.get("to")) - tx_to_label = pm_contracts.get(tx_to) - pm_hit = bool(tx_to_label) - transfer_lines: List[str] = [] - approval_lines: List[str] = [] - touched_labels: List[str] = [tx_to_label] if tx_to_label else [] - - try: - receipt = w3.eth.get_transaction_receipt(tx["hash"]) - parsed = _extract_receipt_signals( - w3=w3, - receipt=receipt, - watch_set=watch_set, - pm_contracts=pm_contracts, - token_meta_cache=token_meta_cache, - ) - pm_hit = pm_hit or bool(parsed.get("pm_hit")) - transfer_lines = parsed.get("transfer_lines") or [] - approval_lines = parsed.get("approval_lines") or [] - touched = parsed.get("touched_labels") or [] - touched_labels = sorted(set(touched_labels + touched)) - except Exception: - transfer_lines = [] - approval_lines = [] - - if polymarket_only and not pm_hit: - continue - - message = _build_message( - tx=tx, - block_ts=block_ts, - matched_wallet=matched_wallet, - touched_labels=touched_labels, - transfer_lines=transfer_lines, - approval_lines=approval_lines, - tx_to_label=tx_to_label, - ) - sent_count = 0 - for chat_id in chat_ids: - try: - bot.send_message( - chat_id, - message, - disable_web_page_preview=True, - ) - sent_count += 1 - except Exception as exc: - logger.warning( - "polygon wallet alert push failed wallet={} chat_id={} error={}", - matched_wallet, - chat_id, - exc, - ) - if sent_count <= 0: - continue - state.setdefault("seen_tx", {})[tx_hash] = cycle_ts - logger.info( - f"polygon wallet alert pushed wallet={matched_wallet} " - f"tx={tx_hash} block={block_num} polymarket={pm_hit} chat_targets={sent_count}" - ) - - state["last_scanned_block"] = block_num - _save_state(state_path, state) - - time.sleep(1) - except Exception: - logger.exception("polygon wallet watcher cycle failed") - time.sleep(poll_sec) - - thread = threading.Thread( - target=_runner, - name="polygon-wallet-watcher", - daemon=True, - ) - thread.start() - return thread - diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index a4b92e42..55c45271 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -22,7 +22,6 @@ DEFAULT_POLYGON_CHAIN_ID = 137 DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" DEFAULT_USDT_ADDRESS = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f" -DEFAULT_PUSD_ADDRESS = "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb" PAYMENT_CONTRACT_ABI = [ { @@ -392,8 +391,6 @@ class PaymentContractCheckoutService: return {"code": "usdc", "symbol": "USDC", "name": "Native USDC"} if normalized == _normalize_address(DEFAULT_USDT_ADDRESS): return {"code": "usdt", "symbol": "USDT", "name": "USDT"} - if normalized == _normalize_address(DEFAULT_PUSD_ADDRESS): - return {"code": "pusd", "symbol": "pUSD", "name": "Polymarket pUSD"} if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS): return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"} short = f"{normalized[:6]}...{normalized[-4:]}" diff --git a/src/utils/config_validation.py b/src/utils/config_validation.py index 85c73b09..e834d4f9 100644 --- a/src/utils/config_validation.py +++ b/src/utils/config_validation.py @@ -71,7 +71,6 @@ def validate_runtime_env( entitlement_guard = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False) payment_enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False) weekly_reward_enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False) - polygon_watch_enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False) if component_key == "bot": missing = _missing(["TELEGRAM_BOT_TOKEN"]) @@ -111,11 +110,6 @@ def validate_runtime_env( "已启用支付,但未配置 POLYWEATHER_PAYMENT_RECEIVER_CONTRACT 或 POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON" ) - if polygon_watch_enabled: - if not _has("POLYGON_WALLET_WATCH_ADDRESSES"): - report.warnings.append( - "已启用 polygon watcher,但未配置 POLYGON_WALLET_WATCH_ADDRESSES" - ) if component_key == "web": if auth_enabled and not auth_required: diff --git a/tests/test_bot_runtime_coordinator.py b/tests/test_bot_runtime_coordinator.py index 570e1e60..3bbfd76d 100644 --- a/tests/test_bot_runtime_coordinator.py +++ b/tests/test_bot_runtime_coordinator.py @@ -6,7 +6,6 @@ class DummyBot: def test_startup_coordinator_respects_disable_flags(monkeypatch): - monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false") monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) coordinator = StartupCoordinator( @@ -19,7 +18,8 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch): runtime = coordinator.start_all() loop_map = runtime.loop_map() - assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env" + assert "weekly_reward" in loop_map + assert "polygon_wallet_watch" not in loop_map def test_render_runtime_status_html_contains_key_fields():