feat: Implement payment processing with contract auditing, event loops, and web observability endpoints.
This commit is contained in:
@@ -101,6 +101,7 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
|
||||
@@ -35,3 +35,21 @@
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等。
|
||||
从工程实现看,截至 `2026-03-20`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存向 SQLite 的渐进迁移,以及基础可观测性接口(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py` 与 `web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
|
||||
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这三条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
|
||||
## 项目概览
|
||||
|
||||
@@ -35,7 +35,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描与确认循环。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态与缓存仓储 | 已接入 `daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 持久缓存。 |
|
||||
| 工程与运维 | `docker-compose.yml`、`Dockerfile`、`.github/workflows/ci.yml`、`scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
|
||||
@@ -176,7 +176,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
| 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/metrics` 建立抓取、阈值与巡检 | 3–7 天 | 不再只靠日志定位问题;可以监控第三方源错误率、缓存命中与 HTTP 延迟 | 指标不分层会导致噪音高、告警无用 | 1) 抓取 `/metrics` → 2) 先围绕 HTTP、Open-Meteo、MGM、METAR 建立最小仪表板 → 3) 为 429/403/error/stale_cache 设阈值 → 4) 增加巡检脚本或告警通道 |
|
||||
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| 中 | **支付合约/链上交互加强审计与防护**:事件重放、重入/授权边界、RPC 多节点容灾 | 1 周 | 提升资金链路可信度;减少链上卡单 | 合约升级需要迁移/再验证 | 1) 为 event loop 增加“最后处理区块高度”持久化与重放工具 → 2) RPC 端支持多 URL fallback → 3) 合约侧考虑 OpenZeppelin Ownable/SafeERC20(如升级)并更新验证流程 |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
| 低 | **引入外部 AI 预报模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
|
||||
@@ -242,7 +242,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
|
||||
**外部 API 速率限制/格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。缓解:保持“事件监听 + 确认补单”双路径,并增加“事件重放/对账工具”、多 RPC fallback、以及链上高度持久化。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部 AI 预报模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
### 链下运行态
|
||||
|
||||
- 支付事件扫描与确认循环已把运行态写入 SQLite:
|
||||
- `payment_runtime_state`
|
||||
- `payment_audit_events`
|
||||
- 关键循环现在会记录:
|
||||
- `event_loop_started`
|
||||
- `event_loop_cycle`
|
||||
- `event_loop_error`
|
||||
- `confirm_loop_started`
|
||||
- `confirm_loop_cycle`
|
||||
- `confirm_loop_error`
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
```
|
||||
|
||||
- 启动时按顺序探活。
|
||||
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
|
||||
|
||||
### 事件重放
|
||||
|
||||
- 已提供脚本:
|
||||
- [replay_payment_events.py](/E:/web/PolyWeather/scripts/replay_payment_events.py)
|
||||
|
||||
用途:
|
||||
- 审计某个区块范围内的 `OrderPaid`
|
||||
- 事后补查漏单
|
||||
- 排查 RPC 抖动导致的监听遗漏
|
||||
|
||||
命令示例:
|
||||
|
||||
```bash
|
||||
python scripts/replay_payment_events.py --from-block 10000000 --to-block 10001000
|
||||
```
|
||||
|
||||
### 运行态检查
|
||||
|
||||
- 已提供接口:
|
||||
- `GET /api/payments/runtime`
|
||||
|
||||
可查看:
|
||||
- checkout 配置摘要
|
||||
- 当前活跃 RPC
|
||||
- 候选 RPC 列表
|
||||
- event loop 最新状态
|
||||
- 最近审计事件
|
||||
|
||||
## 2. 当前合约的授权边界
|
||||
|
||||
合约源码:
|
||||
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
|
||||
|
||||
当前边界:
|
||||
|
||||
1. `owner`
|
||||
- 可执行:
|
||||
- `setTreasury`
|
||||
- `setTokenAllowed`
|
||||
|
||||
2. 普通用户
|
||||
- 只能调用:
|
||||
- `pay(orderId, planId, amount, token)`
|
||||
|
||||
3. 代币边界
|
||||
- 只有 `allowedToken[token] == true` 的 token 可支付
|
||||
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
|
||||
当前合约的 `pay` 逻辑顺序是:
|
||||
|
||||
1. 检查 token allowlist
|
||||
2. 检查 `amount > 0`
|
||||
3. 检查 `paidOrder[orderId] == false`
|
||||
4. 先写入 `paidOrder[orderId] = true`
|
||||
5. 再执行 `transferFrom`
|
||||
6. 发出 `OrderPaid`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一 `orderId` 的重复支付会被拦住
|
||||
- 典型“转账外部调用后再回调重复执行同订单”的路径会被 `paidOrder` 状态挡住
|
||||
|
||||
但要注意:
|
||||
|
||||
- 当前合约没有 `Pausable`
|
||||
- 当前合约没有 `SafeERC20`
|
||||
- 当前合约没有在链上校验 `planId -> amount`
|
||||
|
||||
所以它属于:
|
||||
- **最小可用支付合约**
|
||||
- 不是“全功能强防护合约”
|
||||
|
||||
## 4. 当前静态审计结论
|
||||
|
||||
已提供脚本:
|
||||
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
```
|
||||
|
||||
输出会检查这些项目:
|
||||
|
||||
- 是否有 `onlyOwner`
|
||||
- `setTreasury` / `setTokenAllowed` 是否受 owner 保护
|
||||
- constructor / setter 是否检查零地址
|
||||
- 是否校验 allowlist
|
||||
- 是否校验 `amount > 0`
|
||||
- 是否校验重复订单
|
||||
- 是否在 `transferFrom` 前写入 `paidOrder`
|
||||
- 是否有 pause 开关
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
|
||||
2. 无暂停开关
|
||||
- 发现紧急问题时,无法直接暂停 `pay`
|
||||
|
||||
3. 金额校验主要在链下
|
||||
- 当前 `planId / amount / token` 绑定主要靠后端 intent 和确认逻辑
|
||||
|
||||
4. ERC20 兼容性假设
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
python scripts/replay_payment_events.py --from-block <from> --to-block <to>
|
||||
```
|
||||
|
||||
### 线上巡检
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
重点看:
|
||||
|
||||
- `rpc.active_rpc_url`
|
||||
- `rpc.configured_rpc_count`
|
||||
- `event_loop_state.last_scanned_block`
|
||||
- `recent_audit_events`
|
||||
|
||||
## 7. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
1. `Ownable` -> 多签 owner
|
||||
2. `SafeERC20`
|
||||
3. `Pausable`
|
||||
4. 链上 plan/amount/token 绑定
|
||||
5. 必要时增加 rescue/sweep 能力
|
||||
@@ -0,0 +1,41 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from src.payments.contract_audit import analyze_checkout_contract
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Static security review for PolyWeather checkout contract."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--contract",
|
||||
default=os.path.join(PROJECT_ROOT, "contracts", "PolyWeatherCheckout.sol"),
|
||||
help="Path to Solidity contract source.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="",
|
||||
help="Optional JSON output path.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = analyze_checkout_contract(args.contract)
|
||||
text = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
output_path = os.path.abspath(args.output)
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from src.payments import PAYMENT_CHECKOUT # noqa: E402
|
||||
from src.payments.event_loop import _decode_order_paid_log # noqa: E402
|
||||
|
||||
|
||||
def _collect_logs(from_block: int, to_block: int) -> List[Dict[str, Any]]:
|
||||
w3 = PAYMENT_CHECKOUT._get_web3(force_refresh=True) # noqa: SLF001
|
||||
receiver_contracts = sorted(
|
||||
{
|
||||
token.receiver_contract
|
||||
for token in PAYMENT_CHECKOUT.supported_tokens.values()
|
||||
if token.receiver_contract
|
||||
}
|
||||
)
|
||||
if not receiver_contracts:
|
||||
return []
|
||||
topic0 = str(PAYMENT_CHECKOUT._event_topic or "").strip() # noqa: SLF001
|
||||
params: Dict[str, Any] = {
|
||||
"fromBlock": int(from_block),
|
||||
"toBlock": int(to_block),
|
||||
"topics": [topic0],
|
||||
"address": receiver_contracts if len(receiver_contracts) > 1 else receiver_contracts[0],
|
||||
}
|
||||
logs = w3.eth.get_logs(params)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for log_item in logs:
|
||||
decoded = _decode_order_paid_log(log_item)
|
||||
if decoded:
|
||||
out.append(decoded)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Replay payment OrderPaid events across a block range.")
|
||||
parser.add_argument("--from-block", type=int, required=True)
|
||||
parser.add_argument("--to-block", type=int, required=True)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=os.path.join(PROJECT_ROOT, "artifacts", "payments", "replay_payment_events.json"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = _collect_logs(args.from_block, args.to_block)
|
||||
payload = {
|
||||
"from_block": int(args.from_block),
|
||||
"to_block": int(args.to_block),
|
||||
"count": len(rows),
|
||||
"events": rows,
|
||||
}
|
||||
output_dir = os.path.dirname(os.path.abspath(args.output))
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||
print(json.dumps({"count": len(rows), "output": args.output}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+105
-2
@@ -3,7 +3,7 @@ import os
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -83,9 +83,27 @@ class DBManager:
|
||||
pro_granted INTEGER DEFAULT 0,
|
||||
pro_error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (week_key, telegram_id)
|
||||
PRIMARY KEY (week_key, telegram_id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS payment_runtime_state (
|
||||
state_key TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS payment_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supabase_bindings (
|
||||
supabase_user_id TEXT PRIMARY KEY,
|
||||
@@ -123,6 +141,91 @@ class DBManager:
|
||||
conn.commit()
|
||||
logger.info(f"Database initialized successfully path={self.db_path}")
|
||||
|
||||
def get_payment_runtime_state(self, state_key: str) -> Optional[Dict[str, Any]]:
|
||||
key = str(state_key or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT payload_json
|
||||
FROM payment_runtime_state
|
||||
WHERE state_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||
except Exception:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
def set_payment_runtime_state(self, state_key: str, payload: Dict[str, Any]) -> None:
|
||||
key = str(state_key or "").strip()
|
||||
if not key:
|
||||
return
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO payment_runtime_state (state_key, payload_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(state_key) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, json.dumps(body, ensure_ascii=False), datetime.now().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def append_payment_audit_event(self, event_type: str, payload: Dict[str, Any]) -> None:
|
||||
kind = str(event_type or "").strip().lower()
|
||||
if not kind:
|
||||
return
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO payment_audit_events (event_type, payload_json, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(kind, json.dumps(body, ensure_ascii=False), datetime.now().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_payment_audit_events(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
safe_limit = max(1, min(int(limit or 50), 500))
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, event_type, payload_json, created_at
|
||||
FROM payment_audit_events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(safe_limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||
except Exception:
|
||||
payload = {}
|
||||
out.append(
|
||||
{
|
||||
"id": int(row["id"]),
|
||||
"event_type": str(row["event_type"] or ""),
|
||||
"payload": payload if isinstance(payload, dict) else {},
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _safe_week_key(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
|
||||
@@ -7,8 +7,11 @@ from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
|
||||
|
||||
_DB = DBManager()
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
@@ -46,6 +49,13 @@ def _short_hash(tx_hash: str) -> str:
|
||||
return f"{text[:10]}...{text[-6:]}"
|
||||
|
||||
|
||||
def _append_audit_event(event_type: str, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
_DB.append_payment_audit_event(event_type, payload)
|
||||
except Exception as exc:
|
||||
logger.debug(f"payment confirm audit append failed: {exc}")
|
||||
|
||||
|
||||
def _runner() -> None:
|
||||
enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True)
|
||||
if not enabled:
|
||||
@@ -67,6 +77,15 @@ def _runner() -> None:
|
||||
PAYMENT_CHECKOUT.chain_id,
|
||||
PAYMENT_CHECKOUT.confirmations,
|
||||
)
|
||||
_append_audit_event(
|
||||
"confirm_loop_started",
|
||||
{
|
||||
"interval_sec": interval_sec,
|
||||
"batch_size": batch_size,
|
||||
"chain_id": PAYMENT_CHECKOUT.chain_id,
|
||||
"confirmations": PAYMENT_CHECKOUT.confirmations,
|
||||
},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -114,6 +133,13 @@ def _runner() -> None:
|
||||
)
|
||||
|
||||
if scanned and (confirmed or already_confirmed or failed):
|
||||
cycle_summary = {
|
||||
"scanned": scanned,
|
||||
"confirmed": confirmed,
|
||||
"already_confirmed": already_confirmed,
|
||||
"pending": pending,
|
||||
"failed": failed,
|
||||
}
|
||||
logger.info(
|
||||
"payment confirm cycle scanned={} confirmed={} already={} pending={} failed={}",
|
||||
scanned,
|
||||
@@ -122,8 +148,10 @@ def _runner() -> None:
|
||||
pending,
|
||||
failed,
|
||||
)
|
||||
_append_audit_event("confirm_loop_cycle", cycle_summary)
|
||||
except Exception as exc:
|
||||
logger.warning(f"payment confirm cycle failed: {exc}")
|
||||
_append_audit_event("confirm_loop_error", {"error": str(exc)})
|
||||
time.sleep(interval_sec)
|
||||
|
||||
|
||||
@@ -135,4 +163,3 @@ def start_payment_confirm_loop():
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _has(pattern: str, text: str) -> bool:
|
||||
return re.search(pattern, text, re.MULTILINE | re.DOTALL) is not None
|
||||
|
||||
|
||||
def analyze_checkout_contract(source_path: str) -> Dict[str, Any]:
|
||||
path = os.path.abspath(source_path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
|
||||
checks = {
|
||||
"has_only_owner_modifier": _has(r"modifier\s+onlyOwner\s*\(", source),
|
||||
"owner_set_in_constructor": _has(r"owner\s*=\s*msg\.sender\s*;", source),
|
||||
"set_treasury_only_owner": _has(
|
||||
r"function\s+setTreasury\s*\([^)]*\)\s*external\s+onlyOwner", source
|
||||
),
|
||||
"set_token_allowed_only_owner": _has(
|
||||
r"function\s+setTokenAllowed\s*\([^)]*\)\s*external\s+onlyOwner", source
|
||||
),
|
||||
"zero_address_guard_in_constructor": _has(
|
||||
r"constructor\s*\([^)]*\)\s*\{\s*require\(\s*_token\s*!=\s*address\(0\)\s*&&\s*_treasury\s*!=\s*address\(0\)",
|
||||
source,
|
||||
),
|
||||
"zero_address_guard_in_setters": _has(
|
||||
r"function\s+setTreasury[\s\S]*?require\(\s*_treasury\s*!=\s*address\(0\)",
|
||||
source,
|
||||
)
|
||||
and _has(
|
||||
r"function\s+setTokenAllowed[\s\S]*?require\(\s*token\s*!=\s*address\(0\)",
|
||||
source,
|
||||
),
|
||||
"allowed_token_check": _has(r"require\(\s*allowedToken\[token\]", source),
|
||||
"amount_non_zero_check": _has(r"require\(\s*amount\s*>\s*0", source),
|
||||
"duplicate_order_check": _has(r"require\(\s*!paidOrder\[orderId\]", source),
|
||||
"paid_order_written_before_transfer": _has(
|
||||
r"paidOrder\[orderId\]\s*=\s*true\s*;\s*require\(IERC20\(token\)\.transferFrom",
|
||||
source,
|
||||
),
|
||||
"emits_order_paid": _has(r"emit\s+OrderPaid\s*\(", source),
|
||||
"uses_safe_erc20": _has(r"SafeERC20", source),
|
||||
"has_pause_switch": _has(r"\bpaused\b|\bPausable\b|\bwhenNotPaused\b", source),
|
||||
"has_rescue_function": _has(
|
||||
r"function\s+(rescue|sweep|withdraw|recover)", source
|
||||
),
|
||||
"binds_plan_amount_onchain": _has(
|
||||
r"mapping\s*\(\s*uint256\s*=>[\s\S]*plan|planAmount|require\(\s*amount\s*==",
|
||||
source,
|
||||
),
|
||||
}
|
||||
|
||||
strengths: List[str] = []
|
||||
risks: List[Dict[str, Any]] = []
|
||||
|
||||
if checks["has_only_owner_modifier"] and checks["set_treasury_only_owner"]:
|
||||
strengths.append("关键管理函数受 onlyOwner 保护。")
|
||||
if checks["allowed_token_check"]:
|
||||
strengths.append("支付代币有 allowlist,避免任意 token 进入收款流程。")
|
||||
if checks["duplicate_order_check"] and checks["paid_order_written_before_transfer"]:
|
||||
strengths.append("订单去重状态在外部 transferFrom 前写入,能拦住同订单重复支付与典型重入重放。")
|
||||
if checks["emits_order_paid"]:
|
||||
strengths.append("链上事件 OrderPaid 明确,可作为链下审计与补单的唯一确认源。")
|
||||
|
||||
if checks["uses_safe_erc20"]:
|
||||
strengths.append("使用了 SafeERC20 包装,兼容性更稳。")
|
||||
else:
|
||||
risks.append(
|
||||
{
|
||||
"id": "erc20_transfer_assumption",
|
||||
"severity": "medium",
|
||||
"title": "依赖 IERC20.transferFrom 直接返回 bool",
|
||||
"detail": "当前合约直接调用 IERC20.transferFrom。对非标准 ERC20 的兼容性弱于 SafeERC20,建议如未来升级合约时改为 OpenZeppelin SafeERC20。",
|
||||
}
|
||||
)
|
||||
|
||||
if not checks["has_pause_switch"]:
|
||||
risks.append(
|
||||
{
|
||||
"id": "no_pause_switch",
|
||||
"severity": "medium",
|
||||
"title": "缺少紧急暂停开关",
|
||||
"detail": "一旦发现代币配置错误、接收地址异常或链上风险,当前合约无法直接暂停 pay。建议升级版合约加入 Pausable。",
|
||||
}
|
||||
)
|
||||
|
||||
if not checks["binds_plan_amount_onchain"]:
|
||||
risks.append(
|
||||
{
|
||||
"id": "offchain_price_enforcement",
|
||||
"severity": "medium",
|
||||
"title": "套餐金额与 planId 绑定主要靠链下校验",
|
||||
"detail": "合约事件只记录 planId 与 amount,本身不校验 planId 对应价格。当前依赖后端 intent/confirm 流程校验,后续升级可考虑链上 plan 配置或签名校验。",
|
||||
}
|
||||
)
|
||||
|
||||
if not checks["has_rescue_function"]:
|
||||
risks.append(
|
||||
{
|
||||
"id": "no_rescue_function",
|
||||
"severity": "low",
|
||||
"title": "缺少误转资产救援函数",
|
||||
"detail": "当前合约把资金直接转 treasury,不太容易残留余额,但若未来支持更多资产或误转到合约地址,缺少救援路径。",
|
||||
}
|
||||
)
|
||||
|
||||
risks.append(
|
||||
{
|
||||
"id": "single_owner_admin",
|
||||
"severity": "medium",
|
||||
"title": "owner 为单地址管理模型",
|
||||
"detail": "setTreasury 和 setTokenAllowed 由单一 owner 控制。生产建议用多签地址持有 owner,降低单点密钥失窃风险。",
|
||||
}
|
||||
)
|
||||
|
||||
runtime_controls = [
|
||||
"后端只认链上 OrderPaid 事件,不认前端自报支付成功。",
|
||||
"payment event loop 与 confirm loop 已写入 SQLite 审计事件,可做对账与回放。",
|
||||
"支持 POLYWEATHER_PAYMENT_RPC_URLS 多 RPC 容灾,单节点故障时可轮换。",
|
||||
]
|
||||
|
||||
recommendations = [
|
||||
"生产 owner 建议迁移到多签钱包。",
|
||||
"下一版合约优先补 SafeERC20 与 Pausable。",
|
||||
"若要进一步收紧授权边界,可把 planId/amount/token 绑定做进链上或 EIP-712 签名校验。",
|
||||
"每次合约地址或 allowed token 变更后,都运行静态检查与链上回放脚本。",
|
||||
]
|
||||
|
||||
return {
|
||||
"contract_path": path,
|
||||
"contract_name": "PolyWeatherCheckout",
|
||||
"summary": {
|
||||
"strength_count": len(strengths),
|
||||
"risk_count": len(risks),
|
||||
"highest_severity": "medium" if risks else "none",
|
||||
},
|
||||
"checks": checks,
|
||||
"strengths": strengths,
|
||||
"runtime_controls": runtime_controls,
|
||||
"risks": risks,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
@@ -247,6 +247,9 @@ class PaymentContractCheckoutService:
|
||||
self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID)
|
||||
self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6)
|
||||
self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip()
|
||||
self.rpc_urls = self._load_rpc_urls(
|
||||
os.getenv("POLYWEATHER_PAYMENT_RPC_URLS") or self.rpc_url
|
||||
)
|
||||
legacy_receiver_contract = _normalize_address(
|
||||
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
|
||||
)
|
||||
@@ -315,6 +318,7 @@ class PaymentContractCheckoutService:
|
||||
)
|
||||
self._w3_lock = threading.Lock()
|
||||
self._w3: Optional[Web3] = None
|
||||
self._w3_url: str = ""
|
||||
self._event_topic = Web3.keccak(
|
||||
text="OrderPaid(bytes32,address,uint256,address,uint256)"
|
||||
).hex()
|
||||
@@ -332,7 +336,7 @@ class PaymentContractCheckoutService:
|
||||
return bool(
|
||||
self.supabase_url
|
||||
and self.supabase_service_role_key
|
||||
and self.rpc_url
|
||||
and bool(self.rpc_urls)
|
||||
and has_valid_token_routes
|
||||
)
|
||||
|
||||
@@ -348,6 +352,14 @@ class PaymentContractCheckoutService:
|
||||
),
|
||||
)
|
||||
|
||||
def _load_rpc_urls(self, raw: str) -> List[str]:
|
||||
out: List[str] = []
|
||||
for part in str(raw or "").split(","):
|
||||
url = str(part or "").strip()
|
||||
if url and url not in out:
|
||||
out.append(url)
|
||||
return out
|
||||
|
||||
def _default_token_meta(self, address: str) -> Dict[str, str]:
|
||||
normalized = _normalize_address(address)
|
||||
if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS):
|
||||
@@ -821,15 +833,48 @@ class PaymentContractCheckoutService:
|
||||
result["discount_usdc"] = _format_decimal(discount_usdc)
|
||||
return result
|
||||
|
||||
def _get_web3(self) -> Web3:
|
||||
def _build_web3(self, rpc_url: str) -> Web3:
|
||||
return Web3(
|
||||
Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": self.timeout_sec})
|
||||
)
|
||||
|
||||
def _try_connect_rpc(self, rpc_url: str) -> Optional[Web3]:
|
||||
try:
|
||||
w3 = self._build_web3(rpc_url)
|
||||
if not w3.is_connected():
|
||||
return None
|
||||
if int(w3.eth.chain_id) != int(self.chain_id):
|
||||
return None
|
||||
return w3
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _rotate_rpc(self) -> Optional[Web3]:
|
||||
for rpc_url in self.rpc_urls:
|
||||
w3 = self._try_connect_rpc(rpc_url)
|
||||
if w3 is not None:
|
||||
self._w3 = w3
|
||||
self._w3_url = rpc_url
|
||||
return w3
|
||||
self._w3 = None
|
||||
self._w3_url = ""
|
||||
return None
|
||||
|
||||
def _get_web3(self, force_refresh: bool = False) -> Web3:
|
||||
with self._w3_lock:
|
||||
if self._w3 is None:
|
||||
self._w3 = Web3(
|
||||
Web3.HTTPProvider(self.rpc_url, request_kwargs={"timeout": self.timeout_sec})
|
||||
)
|
||||
if self._w3 is None or force_refresh:
|
||||
self._rotate_rpc()
|
||||
assert self._w3 is not None
|
||||
return self._w3
|
||||
|
||||
def get_rpc_runtime_status(self) -> Dict[str, Any]:
|
||||
candidates = list(self.rpc_urls)
|
||||
return {
|
||||
"configured_rpc_count": len(candidates),
|
||||
"active_rpc_url": self._w3_url or (candidates[0] if candidates else ""),
|
||||
"all_rpc_urls": candidates,
|
||||
}
|
||||
|
||||
def _get_contract(self, receiver_address: Optional[str] = None):
|
||||
w3 = self._get_web3()
|
||||
contract_address = _normalize_address(receiver_address or self.receiver_contract)
|
||||
@@ -1561,15 +1606,25 @@ class PaymentContractCheckoutService:
|
||||
def _wait_receipt(self, tx_hash: str) -> Any:
|
||||
import time as _time
|
||||
|
||||
w3 = self._get_web3()
|
||||
start = _now_utc()
|
||||
while (_now_utc() - start).total_seconds() < self.max_wait_sec:
|
||||
try:
|
||||
w3 = self._get_web3()
|
||||
receipt = w3.eth.get_transaction_receipt(tx_hash)
|
||||
except Exception:
|
||||
receipt = None
|
||||
try:
|
||||
w3 = self._get_web3(force_refresh=True)
|
||||
receipt = w3.eth.get_transaction_receipt(tx_hash)
|
||||
except Exception:
|
||||
receipt = None
|
||||
if receipt and receipt.get("blockNumber"):
|
||||
return receipt
|
||||
try:
|
||||
latest_w3 = self._get_web3()
|
||||
if not latest_w3.is_connected():
|
||||
self._get_web3(force_refresh=True)
|
||||
except Exception:
|
||||
receipt = None
|
||||
_time.sleep(self.poll_interval_sec)
|
||||
raise PaymentCheckoutError(408, "tx receipt timeout")
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ from typing import Any, Dict, List, Optional
|
||||
from loguru import logger
|
||||
from web3 import Web3
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
|
||||
|
||||
_DB = DBManager()
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
@@ -70,6 +73,9 @@ def _state_file() -> str:
|
||||
|
||||
|
||||
def _load_state(path: str) -> Dict[str, Any]:
|
||||
db_state = _DB.get_payment_runtime_state("payment_event_loop")
|
||||
if isinstance(db_state, dict) and db_state:
|
||||
return db_state
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
@@ -82,6 +88,7 @@ def _load_state(path: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _save_state(path: str, state: Dict[str, Any]) -> None:
|
||||
_DB.set_payment_runtime_state("payment_event_loop", state)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp_path = f"{path}.tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
@@ -89,6 +96,13 @@ def _save_state(path: str, state: Dict[str, Any]) -> None:
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def _append_audit_event(event_type: str, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
_DB.append_payment_audit_event(event_type, payload)
|
||||
except Exception as exc:
|
||||
logger.debug(f"payment event audit append failed: {exc}")
|
||||
|
||||
|
||||
def _is_pending_confirm_error(exc: PaymentCheckoutError) -> bool:
|
||||
detail = str(exc.detail or "").lower()
|
||||
if exc.status_code in {404, 408, 502, 503}:
|
||||
@@ -233,6 +247,17 @@ def _runner() -> None:
|
||||
len(receiver_contracts),
|
||||
PAYMENT_CHECKOUT.chain_id,
|
||||
)
|
||||
_append_audit_event(
|
||||
"event_loop_started",
|
||||
{
|
||||
"interval_sec": interval_sec,
|
||||
"lookback_blocks": lookback_blocks,
|
||||
"step_blocks": step_blocks,
|
||||
"max_events": max_events,
|
||||
"receiver_contracts": receiver_contracts,
|
||||
"chain_id": PAYMENT_CHECKOUT.chain_id,
|
||||
},
|
||||
)
|
||||
|
||||
while True:
|
||||
cycle_started = time.time()
|
||||
@@ -392,6 +417,18 @@ def _runner() -> None:
|
||||
cursor = to_block + 1
|
||||
|
||||
if scanned_blocks > 0:
|
||||
cycle_summary = {
|
||||
"blocks": scanned_blocks,
|
||||
"events": scanned_events,
|
||||
"matched": matched_intents,
|
||||
"submitted": submitted,
|
||||
"confirmed": confirmed,
|
||||
"already": already,
|
||||
"pending": pending,
|
||||
"failed": failed,
|
||||
"ignored": ignored,
|
||||
"last_scanned_block": int(state.get("last_scanned_block") or 0),
|
||||
}
|
||||
logger.info(
|
||||
"payment event cycle blocks={} events={} matched={} submitted={} "
|
||||
"confirmed={} already={} pending={} failed={} ignored={}",
|
||||
@@ -405,8 +442,10 @@ def _runner() -> None:
|
||||
failed,
|
||||
ignored,
|
||||
)
|
||||
_append_audit_event("event_loop_cycle", cycle_summary)
|
||||
except Exception as exc:
|
||||
logger.warning(f"payment event cycle failed: {exc}")
|
||||
_append_audit_event("event_loop_error", {"error": str(exc)})
|
||||
|
||||
elapsed = time.time() - cycle_started
|
||||
time.sleep(max(0.0, interval_sec - elapsed))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
|
||||
from src.payments.contract_audit import analyze_checkout_contract
|
||||
|
||||
|
||||
def test_payment_contract_audit_detects_current_controls():
|
||||
report = analyze_checkout_contract(
|
||||
os.path.join("contracts", "PolyWeatherCheckout.sol")
|
||||
)
|
||||
|
||||
assert report["checks"]["has_only_owner_modifier"] is True
|
||||
assert report["checks"]["allowed_token_check"] is True
|
||||
assert report["checks"]["duplicate_order_check"] is True
|
||||
assert report["checks"]["paid_order_written_before_transfer"] is True
|
||||
assert report["checks"]["has_pause_switch"] is False
|
||||
assert report["checks"]["binds_plan_amount_onchain"] is False
|
||||
assert any(risk["id"] == "single_owner_admin" for risk in report["risks"])
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from src.database.db_manager import DBManager
|
||||
from src.payments.contract_checkout import PaymentContractCheckoutService
|
||||
|
||||
|
||||
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
||||
db_path = tmp_path / "payments.db"
|
||||
db = DBManager(str(db_path))
|
||||
|
||||
db.set_payment_runtime_state("payment_event_loop", {"last_scanned_block": 123})
|
||||
db.append_payment_audit_event("event_loop_cycle", {"blocks": 10, "events": 2})
|
||||
|
||||
state = db.get_payment_runtime_state("payment_event_loop")
|
||||
events = db.list_payment_audit_events(limit=10)
|
||||
|
||||
assert state == {"last_scanned_block": 123}
|
||||
assert events
|
||||
assert events[0]["event_type"] == "event_loop_cycle"
|
||||
assert events[0]["payload"]["events"] == 2
|
||||
|
||||
|
||||
def test_payment_checkout_parses_multiple_rpc_urls(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_RPC_URLS",
|
||||
"https://rpc-1.example,https://rpc-2.example",
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||
)
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||
|
||||
service = PaymentContractCheckoutService()
|
||||
status = service.get_rpc_runtime_status()
|
||||
|
||||
assert service.rpc_urls == ["https://rpc-1.example", "https://rpc-2.example"]
|
||||
assert status["configured_rpc_count"] == 2
|
||||
assert status["all_rpc_urls"][0] == "https://rpc-1.example"
|
||||
@@ -35,3 +35,13 @@ def test_metrics_endpoint_returns_prometheus_payload():
|
||||
response = client.get('/metrics')
|
||||
assert response.status_code == 200
|
||||
assert 'polyweather_http_requests_total' in response.text
|
||||
|
||||
|
||||
def test_payment_runtime_endpoint_returns_shape():
|
||||
response = client.get('/api/payments/runtime')
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert 'checkout' in payload
|
||||
assert 'rpc' in payload
|
||||
assert 'event_loop_state' in payload
|
||||
assert 'recent_audit_events' in payload
|
||||
|
||||
@@ -234,6 +234,23 @@ async def payment_config(request: Request):
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.get("/api/payments/runtime")
|
||||
async def payment_runtime(request: Request):
|
||||
_assert_entitlement(request)
|
||||
try:
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
db = DBManager()
|
||||
return {
|
||||
"checkout": PAYMENT_CHECKOUT.get_config_payload(),
|
||||
"rpc": PAYMENT_CHECKOUT.get_rpc_runtime_status(),
|
||||
"event_loop_state": db.get_payment_runtime_state("payment_event_loop") or {},
|
||||
"recent_audit_events": db.list_payment_audit_events(limit=20),
|
||||
}
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.get("/api/payments/wallets")
|
||||
async def payment_wallets(request: Request):
|
||||
_assert_entitlement(request)
|
||||
|
||||
Reference in New Issue
Block a user