feat: Implement payment processing with contract auditing, event loops, and web observability endpoints.

This commit is contained in:
2569718930@qq.com
2026-03-20 23:33:50 +08:00
parent 7225b8bcc7
commit a0b8a3595c
15 changed files with 786 additions and 15 deletions
+1
View File
@@ -101,6 +101,7 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
POLYWEATHER_PAYMENT_ENABLED=false POLYWEATHER_PAYMENT_ENABLED=false
POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6 POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
+18
View File
@@ -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 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: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 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}
+4 -4
View File
@@ -5,7 +5,7 @@
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等。 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`)。 从工程实现看,截至 `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、可门禁判断的阶段。 这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`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 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。 因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这三条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
## 项目概览 ## 项目概览
@@ -35,7 +35,7 @@ DEBDynamic 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/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/*` | 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/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/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` 持久缓存。 | | 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 报告脚本。 | | 工程与运维 | `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) 增加巡检脚本或告警通道 | | 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/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` 后才考虑上线 | | 高 | **稳定 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,减少解析失败 | | 中 | **市场层升级为 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(如升级)并更新验证流程 | | 中 | **支付合约从“最小可用”升级到“更强合约防护** | 12 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 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) 明确热修例外流程 | | 中 | **将 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) 若要商用,优先选择可商用权重或自研/购买授权 | | 低 | **引入外部 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/批量接口(若可用)。 **外部 API 速率限制/格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
**密钥泄露与权限滥用**Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。 **密钥泄露与权限滥用**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 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。 **引入外部 AI 预报模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。 **Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接 ## 参考链接
+187
View File
@@ -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())
+68
View File
@@ -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
View File
@@ -3,7 +3,7 @@ import os
import hashlib import hashlib
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from loguru import logger from loguru import logger
@@ -83,9 +83,27 @@ class DBManager:
pro_granted INTEGER DEFAULT 0, pro_granted INTEGER DEFAULT 0,
pro_error TEXT, pro_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS supabase_bindings ( CREATE TABLE IF NOT EXISTS supabase_bindings (
supabase_user_id TEXT PRIMARY KEY, supabase_user_id TEXT PRIMARY KEY,
@@ -123,6 +141,91 @@ class DBManager:
conn.commit() conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}") 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 @staticmethod
def _safe_week_key(value: str) -> str: def _safe_week_key(value: str) -> str:
text = str(value or "").strip() text = str(value or "").strip()
+28 -1
View File
@@ -7,8 +7,11 @@ from typing import Any, Dict
from loguru import logger from loguru import logger
from src.database.db_manager import DBManager
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
_DB = DBManager()
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name) raw = os.getenv(name)
@@ -46,6 +49,13 @@ def _short_hash(tx_hash: str) -> str:
return f"{text[:10]}...{text[-6:]}" 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: def _runner() -> None:
enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True) enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True)
if not enabled: if not enabled:
@@ -67,6 +77,15 @@ def _runner() -> None:
PAYMENT_CHECKOUT.chain_id, PAYMENT_CHECKOUT.chain_id,
PAYMENT_CHECKOUT.confirmations, 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: while True:
try: try:
@@ -114,6 +133,13 @@ def _runner() -> None:
) )
if scanned and (confirmed or already_confirmed or failed): 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( logger.info(
"payment confirm cycle scanned={} confirmed={} already={} pending={} failed={}", "payment confirm cycle scanned={} confirmed={} already={} pending={} failed={}",
scanned, scanned,
@@ -122,8 +148,10 @@ def _runner() -> None:
pending, pending,
failed, failed,
) )
_append_audit_event("confirm_loop_cycle", cycle_summary)
except Exception as exc: except Exception as exc:
logger.warning(f"payment confirm cycle failed: {exc}") logger.warning(f"payment confirm cycle failed: {exc}")
_append_audit_event("confirm_loop_error", {"error": str(exc)})
time.sleep(interval_sec) time.sleep(interval_sec)
@@ -135,4 +163,3 @@ def start_payment_confirm_loop():
) )
thread.start() thread.start()
return thread return thread
+147
View File
@@ -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,
}
+63 -8
View File
@@ -247,6 +247,9 @@ class PaymentContractCheckoutService:
self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID) self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID)
self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6) self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6)
self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip() 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( legacy_receiver_contract = _normalize_address(
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or "" os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
) )
@@ -315,6 +318,7 @@ class PaymentContractCheckoutService:
) )
self._w3_lock = threading.Lock() self._w3_lock = threading.Lock()
self._w3: Optional[Web3] = None self._w3: Optional[Web3] = None
self._w3_url: str = ""
self._event_topic = Web3.keccak( self._event_topic = Web3.keccak(
text="OrderPaid(bytes32,address,uint256,address,uint256)" text="OrderPaid(bytes32,address,uint256,address,uint256)"
).hex() ).hex()
@@ -332,7 +336,7 @@ class PaymentContractCheckoutService:
return bool( return bool(
self.supabase_url self.supabase_url
and self.supabase_service_role_key and self.supabase_service_role_key
and self.rpc_url and bool(self.rpc_urls)
and has_valid_token_routes 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]: def _default_token_meta(self, address: str) -> Dict[str, str]:
normalized = _normalize_address(address) normalized = _normalize_address(address)
if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS): if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS):
@@ -821,15 +833,48 @@ class PaymentContractCheckoutService:
result["discount_usdc"] = _format_decimal(discount_usdc) result["discount_usdc"] = _format_decimal(discount_usdc)
return result 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: with self._w3_lock:
if self._w3 is None: if self._w3 is None or force_refresh:
self._w3 = Web3( self._rotate_rpc()
Web3.HTTPProvider(self.rpc_url, request_kwargs={"timeout": self.timeout_sec})
)
assert self._w3 is not None assert self._w3 is not None
return self._w3 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): def _get_contract(self, receiver_address: Optional[str] = None):
w3 = self._get_web3() w3 = self._get_web3()
contract_address = _normalize_address(receiver_address or self.receiver_contract) contract_address = _normalize_address(receiver_address or self.receiver_contract)
@@ -1561,15 +1606,25 @@ class PaymentContractCheckoutService:
def _wait_receipt(self, tx_hash: str) -> Any: def _wait_receipt(self, tx_hash: str) -> Any:
import time as _time import time as _time
w3 = self._get_web3()
start = _now_utc() start = _now_utc()
while (_now_utc() - start).total_seconds() < self.max_wait_sec: while (_now_utc() - start).total_seconds() < self.max_wait_sec:
try: try:
w3 = self._get_web3()
receipt = w3.eth.get_transaction_receipt(tx_hash) receipt = w3.eth.get_transaction_receipt(tx_hash)
except Exception: 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"): if receipt and receipt.get("blockNumber"):
return receipt 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) _time.sleep(self.poll_interval_sec)
raise PaymentCheckoutError(408, "tx receipt timeout") raise PaymentCheckoutError(408, "tx receipt timeout")
+39
View File
@@ -10,8 +10,11 @@ from typing import Any, Dict, List, Optional
from loguru import logger from loguru import logger
from web3 import Web3 from web3 import Web3
from src.database.db_manager import DBManager
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
_DB = DBManager()
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name) raw = os.getenv(name)
@@ -70,6 +73,9 @@ def _state_file() -> str:
def _load_state(path: str) -> Dict[str, Any]: 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): if not os.path.exists(path):
return {} return {}
try: try:
@@ -82,6 +88,7 @@ def _load_state(path: str) -> Dict[str, Any]:
def _save_state(path: str, state: Dict[str, Any]) -> None: 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) os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp" tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh: 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) 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: def _is_pending_confirm_error(exc: PaymentCheckoutError) -> bool:
detail = str(exc.detail or "").lower() detail = str(exc.detail or "").lower()
if exc.status_code in {404, 408, 502, 503}: if exc.status_code in {404, 408, 502, 503}:
@@ -233,6 +247,17 @@ def _runner() -> None:
len(receiver_contracts), len(receiver_contracts),
PAYMENT_CHECKOUT.chain_id, 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: while True:
cycle_started = time.time() cycle_started = time.time()
@@ -392,6 +417,18 @@ def _runner() -> None:
cursor = to_block + 1 cursor = to_block + 1
if scanned_blocks > 0: 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( logger.info(
"payment event cycle blocks={} events={} matched={} submitted={} " "payment event cycle blocks={} events={} matched={} submitted={} "
"confirmed={} already={} pending={} failed={} ignored={}", "confirmed={} already={} pending={} failed={} ignored={}",
@@ -405,8 +442,10 @@ def _runner() -> None:
failed, failed,
ignored, ignored,
) )
_append_audit_event("event_loop_cycle", cycle_summary)
except Exception as exc: except Exception as exc:
logger.warning(f"payment event cycle failed: {exc}") logger.warning(f"payment event cycle failed: {exc}")
_append_audit_event("event_loop_error", {"error": str(exc)})
elapsed = time.time() - cycle_started elapsed = time.time() - cycle_started
time.sleep(max(0.0, interval_sec - elapsed)) time.sleep(max(0.0, interval_sec - elapsed))
+18
View File
@@ -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"])
+40
View File
@@ -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"
+10
View File
@@ -35,3 +35,13 @@ def test_metrics_endpoint_returns_prometheus_payload():
response = client.get('/metrics') response = client.get('/metrics')
assert response.status_code == 200 assert response.status_code == 200
assert 'polyweather_http_requests_total' in response.text 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
+17
View File
@@ -234,6 +234,23 @@ async def payment_config(request: Request):
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc 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") @router.get("/api/payments/wallets")
async def payment_wallets(request: Request): async def payment_wallets(request: Request):
_assert_entitlement(request) _assert_entitlement(request)