From af2561d019082adb7e865a7b830ba9ad07fbe693 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 22:11:55 +0800 Subject: [PATCH] feat: Introduce a runtime coordinator for bot loops and add new payment checkout and confirmation modules. --- src/analysis/market_alert_engine.py | 2 +- src/bot/runtime_coordinator.py | 28 ++++++ src/payments/confirm_loop.py | 138 ++++++++++++++++++++++++++++ src/payments/contract_checkout.py | 43 +++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/payments/confirm_loop.py diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py index 8962940c..cde92c17 100644 --- a/src/analysis/market_alert_engine.py +++ b/src/analysis/market_alert_engine.py @@ -1092,7 +1092,7 @@ def _build_telegram_messages_mispricing( if anchor_high_c is not None and anchor_settle is not None: if anchor_model: lines_zh.append( - f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}C(结算参考 {anchor_settle}C)" + f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}°C (结算参考 {anchor_settle}°C )" ) else: lines_zh.append( diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 23bb4eb7..ebe52ee6 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -87,6 +87,7 @@ class StartupCoordinator: self._start_polygon_wallet_loop(), self._start_polymarket_wallet_activity_loop(), self._start_weekly_reward_loop(), + self._start_payment_confirm_loop(), ] self._runtime_status = RuntimeStatus( started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), @@ -272,6 +273,33 @@ class StartupCoordinator: ), ) + def _start_payment_confirm_loop(self) -> LoopStatus: + enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True) + details = { + "interval_sec": max( + 5, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20) + ), + "batch_size": max( + 1, min(200, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_BATCH_SIZE", 20)) + ), + "payment_enabled": _env_bool("POLYWEATHER_PAYMENT_ENABLED", False), + "chain_id": _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", 137), + "confirmations": max(1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2)), + } + validation_error = None + if not bool(details["payment_enabled"]): + validation_error = "payment_service_disabled" + return self._start_with_validation( + key="payment_confirm", + label="支付自动补单", + configured_enabled=enabled, + details=details, + validation_error=validation_error, + starter=lambda: import_module( + "src.payments.confirm_loop" + ).start_payment_confirm_loop(), + ) + def render_runtime_status_html(status: RuntimeStatus) -> str: lines = [ diff --git a/src/payments/confirm_loop.py b/src/payments/confirm_loop.py new file mode 100644 index 00000000..6eba680e --- /dev/null +++ b/src/payments/confirm_loop.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os +import threading +import time +from typing import Any, Dict + +from loguru import logger + +from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError + + +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, min_value: int = 0) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(raw) + except Exception: + return default + return max(min_value, value) + + +def _is_pending_confirm_error(exc: PaymentCheckoutError) -> bool: + detail = str(exc.detail or "").lower() + if exc.status_code in {404, 408}: + return True + if exc.status_code == 409 and ( + "confirmations not enough" in detail or "tx indexed partially" in detail + ): + return True + return False + + +def _short_hash(tx_hash: str) -> str: + text = str(tx_hash or "") + if len(text) < 18: + return text + return f"{text[:10]}...{text[-6:]}" + + +def _runner() -> None: + enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True) + if not enabled: + logger.info("payment confirm loop disabled") + return + + if not PAYMENT_CHECKOUT.enabled: + logger.info("payment confirm loop skipped: payment service disabled") + return + + interval_sec = _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20, 5) + batch_size = _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_BATCH_SIZE", 20, 1) + batch_size = min(batch_size, 200) + + logger.info( + "payment confirm loop started interval={}s batch={} chain_id={} confirmations={}", + interval_sec, + batch_size, + PAYMENT_CHECKOUT.chain_id, + PAYMENT_CHECKOUT.confirmations, + ) + + while True: + try: + intents = PAYMENT_CHECKOUT.list_pending_confirm_intents(limit=batch_size) + scanned = len(intents) + confirmed = 0 + already_confirmed = 0 + pending = 0 + failed = 0 + + for row in intents: + intent_id = str(row.get("intent_id") or "").strip() + user_id = str(row.get("user_id") or "").strip() + tx_hash = str(row.get("tx_hash") or "").strip().lower() + if not intent_id or not user_id or not tx_hash: + continue + try: + result: Dict[str, Any] = PAYMENT_CHECKOUT.confirm_intent_tx( + user_id=user_id, + intent_id=intent_id, + tx_hash=tx_hash, + ) + if bool(result.get("already_confirmed")): + already_confirmed += 1 + else: + confirmed += 1 + logger.info( + "payment auto-confirmed intent={} user={} tx={}", + intent_id, + user_id, + _short_hash(tx_hash), + ) + except PaymentCheckoutError as exc: + if _is_pending_confirm_error(exc): + pending += 1 + continue + failed += 1 + logger.warning( + "payment auto-confirm failed intent={} user={} tx={} status={} detail={}", + intent_id, + user_id, + _short_hash(tx_hash), + exc.status_code, + exc.detail, + ) + + if scanned and (confirmed or already_confirmed or failed): + logger.info( + "payment confirm cycle scanned={} confirmed={} already={} pending={} failed={}", + scanned, + confirmed, + already_confirmed, + pending, + failed, + ) + except Exception as exc: + logger.warning(f"payment confirm cycle failed: {exc}") + time.sleep(interval_sec) + + +def start_payment_confirm_loop(): + thread = threading.Thread( + target=_runner, + daemon=True, + name="payment-confirm-loop", + ) + thread.start() + return thread + diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index b9be1142..59b3ab52 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -1278,6 +1278,49 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError(404, "payment intent not found") return self._serialize_intent(rows[0]) + def list_pending_confirm_intents(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + List submitted intents that already have tx_hash and need background confirm. + """ + self._ensure_enabled() + safe_limit = max(1, min(int(limit or 20), 200)) + rows = self._rest( + "GET", + "payment_intents", + params={ + "select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id", + "status": "eq.submitted", + "tx_hash": "not.is.null", + "chain_id": f"eq.{self.chain_id}", + "order": "updated_at.asc", + "limit": str(safe_limit), + }, + allowed_status=[200], + ) + if not isinstance(rows, list): + return [] + + out: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + intent_id = str(row.get("id") or "").strip() + user_id = str(row.get("user_id") or "").strip() + tx_hash = str(row.get("tx_hash") or "").strip().lower() + if not intent_id or not user_id or not tx_hash: + continue + out.append( + { + "intent_id": intent_id, + "user_id": user_id, + "tx_hash": tx_hash, + "status": str(row.get("status") or ""), + "updated_at": row.get("updated_at"), + "created_at": row.get("created_at"), + } + ) + return out + def submit_intent_tx( self, user_id: str,