feat: support ethereum usdc payment route

This commit is contained in:
2569718930@qq.com
2026-05-29 01:57:42 +08:00
parent 3cc2251b9b
commit f2e90fbcda
28 changed files with 1155 additions and 150 deletions
+11
View File
@@ -171,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments
POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
+1
View File
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
# Wallet / payments
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
+6 -2
View File
@@ -27,7 +27,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
@@ -102,7 +102,7 @@ flowchart LR
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"]
```
@@ -198,6 +198,10 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime
```
### Payment chains
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
## Telegram Commands
| Command | Purpose |
+2 -2
View File
@@ -14,13 +14,13 @@
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-28
## 当前产品状态(2026-05-29
- 已上线订阅制:`Pro 月付 10 USDC`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
+7
View File
@@ -238,6 +238,13 @@ DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
### 支付状态建议
`POST /api/payments/intents` 支持的关键字段:
- `plan_code`:套餐,例如 `pro_monthly`
- `payment_mode``wallet``direct`
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
前端流程建议:
1. `POST /intents`
+3 -2
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production
最后更新:`2026-05-28`
最后更新:`2026-05-29`
## 1. 定位
@@ -21,7 +21,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
+30 -1
View File
@@ -110,6 +110,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -131,6 +132,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY`
@@ -259,7 +261,8 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
@@ -267,6 +270,32 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 6.3 支付多链配置示例
当前生产推荐:
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
```env
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
```
注意:
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
### 6.5 机器人市场监控建议配置
这套配置围绕市场本身做两类推送:
+4 -1
View File
@@ -1,6 +1,6 @@
# 前端部署配置(Vercel
最后更新:`2026-05-28`
最后更新:`2026-05-29`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
@@ -127,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果你变更过支付收款地址,建议同步执行:
+4 -2
View File
@@ -1,6 +1,6 @@
# 外部服务依赖总览
最后更新:`2026-05-28`
最后更新:`2026-05-29`
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
@@ -52,7 +52,8 @@
| --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | 备用 |
| Polygon RPC | 链上支付、自动确认 | ✅ |
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 运维口径
@@ -61,3 +62,4 @@
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
+16 -6
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.8.1)
最后更新:`2026-03-14`
最后更新:`2026-05-29`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
- 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置
@@ -66,20 +66,24 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
# 支持多链多币种(示例)
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
# 套餐(当前只保留月付)
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"10","duration_days":30}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# 积分抵扣
@@ -100,10 +104,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
4. 支付流程:
- `POST /api/payments/intents`
- `POST /api/payments/intents`;多链时前端会带 `chain_id``token_address`
- 发链上交易
- `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
## 6. 多链支付口径
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id``token_address``receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
+4 -4
View File
@@ -2,9 +2,9 @@
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
> **2026-05-28 更新v1.8.1**:终端图表已切换为 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构;DEB hourly consensus (`deb_hourly_consensus.v1`) 成为峰值窗口和 DEB 曲线展示的优先小时路径;README 产品截图迁移到 `frontend/public/static`
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
@@ -15,7 +15,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw]
SB[Supabase Auth/REST]
RPC[Polygon RPC]
RPC[Polygon / Ethereum RPC]
end
subgraph Payments
+30 -10
View File
@@ -1,6 +1,6 @@
# PolyWeather 支付审计与防护说明
最后更新:`2026-03-21`
最后更新:`2026-05-29`
## 1. 当前已落地的防护
@@ -19,9 +19,9 @@
### 事件确认边界
- 后端只认链上 `OrderPaid` 事件
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`
- 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent
- `confirm_loop` 会再次按 intent 的 `chain_id``token_address`、收款地址、金额与确认数校验链上交易。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括:
@@ -29,19 +29,23 @@
- `receiver_mismatch`
- `sender_mismatch`
- `event_mismatch`
- `token_mismatch`
- `tx_reverted`
### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- 格式示例:
```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
```
- 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
### 事件重放
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- 已付款但未开通
- 打到旧收款地址
- 交易事件不匹配
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
## 2. 当前合约的授权边界
## 2. 当前支付链路边界
当前支持两类链路:
1. Polygon checkout 合约
- 前端钱包支付会先切到 Polygon。
- 后端创建 intent 后给出合约 `tx_payload`
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`
2. Ethereum 主网 USDC 直转
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 确认时校验 USDC `Transfer(from, to, amount)``to``token_address` 和金额。
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
## 3. 当前合约的授权边界
合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
4. 订单边界
- 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断
## 4. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是:
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- **最小可用支付合约**
- 不是“全功能强防护合约”
## 4. 当前静态审计结论
## 5. 当前静态审计结论
已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
- 是否使用 SafeERC20
- 是否在链上绑定套餐价格
## 5. 当前主要剩余风险
## 6. 当前主要剩余风险
1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
- 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作
## 7. 推荐操作
### 每次支付配置变更后
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
- 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议
## 8. 下一版合约建议
如果后续升级合约,优先级建议:
+4 -2
View File
@@ -45,6 +45,7 @@ PolyWeather Pro 的生产前端工程。
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss
## 本地开发
@@ -176,7 +177,8 @@ Ops
2. 若 `receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着:
@@ -206,4 +208,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-05-28`
最后更新:`2026-05-29`
+62 -4
View File
@@ -124,6 +124,7 @@ export function AccountCenter() {
boundWallets,
walletAddress,
selectedPlanCode,
selectedPaymentChainId,
selectedTokenAddress,
selectedWallet,
providerMode,
@@ -133,6 +134,7 @@ export function AccountCenter() {
// Shared setters
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -149,6 +151,8 @@ export function AccountCenter() {
selectedPaymentToken,
selectedTokenLabel,
availableTokenList,
availableChainList,
selectedPaymentChain,
effectivePlanList,
resolvedSelectedTokenAddress,
paymentReceiverAddress,
@@ -810,7 +814,7 @@ export function AccountCenter() {
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
chainId={paymentConfig?.chain_id || 137}
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl=""
@@ -959,7 +963,10 @@ export function AccountCenter() {
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={chainIdToDisplayName(paymentConfig?.chain_id)}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
@@ -970,6 +977,57 @@ export function AccountCenter() {
{copy.paymentGuardHint}
</p>
</div>
{availableChainList.length > 1 && (
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentNetwork}
</p>
<div className="grid grid-cols-2 gap-2">
{availableChainList.map((chain) => {
const active = chain.chain_id === selectedPaymentChainId;
return (
<button
type="button"
key={chain.chain_id}
onClick={() => {
setSelectedPaymentChainId(chain.chain_id);
const nextToken =
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id &&
token.is_default,
) ||
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id,
);
if (nextToken?.address) {
setSelectedTokenAddress(
String(nextToken.address).toLowerCase(),
);
}
}}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div>
)}
{availableTokenList.length > 0 && (
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
@@ -984,7 +1042,7 @@ export function AccountCenter() {
return (
<button
type="button"
key={token.address}
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
@@ -1073,7 +1131,7 @@ export function AccountCenter() {
)}
</div>
<div className="text-[10px]">
{copy.polygonChain}
{chainIdToDisplayName(w.chain_id)}
</div>
<div className="mt-2 flex justify-end">
<button
@@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const useAccountPaymentSource = fs.readFileSync(
path.join(accountDir, "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
const useWalletBindSource = fs.readFileSync(
path.join(accountDir, "useWalletBind.ts"),
"utf8",
);
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const opsPaymentsSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
assert(
useAccountPaymentSource.includes("selectedPaymentChainId") &&
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
"account payment state must track the selected payment chain separately from the legacy default chain",
);
assert(
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
usePaymentFlowSource.includes("chain_id: targetChainId"),
"payment intent creation must send the selected chain_id to the backend",
);
assert(
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
);
assert(
useWalletBindSource.includes("chainName") &&
useWalletBindSource.includes("wallet_addEthereumChain") &&
useWalletBindSource.includes("Ethereum Mainnet"),
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
);
assert(
accountCenterSource.includes("availableChainList") &&
accountCenterSource.includes("setSelectedPaymentChainId") &&
accountCenterSource.includes("paymentNetwork"),
"account center must expose a payment network selector when multiple chains are configured",
);
assert(
opsPaymentsSource.includes("etherscan.io") &&
opsPaymentsSource.includes("polygonscan.com"),
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
);
}
+2 -2
View File
@@ -218,8 +218,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Current receiver contract: {address}"
: "当前收款合约: {address}",
manualOrderCreated: isEn
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below."
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。",
? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
: "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver}完成后在下方提交 tx hash。",
manualOrderRequired: isEn
? "Please create a manual transfer order first."
: "请先创建手动转账订单。",
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
return "Polygon";
}
export function chainIdToExplorerBase(chainId: number | undefined | null): string {
if (chainId === 1) return "https://etherscan.io";
if (chainId === 137) return "https://polygonscan.com";
return "";
}
export function formatTime(value: string | undefined | null, locale: string) {
if (!value) return "--";
try {
+21
View File
@@ -40,7 +40,24 @@ export type PaymentTokenOption = {
name: string;
address: string;
decimals: number;
chain_id?: number;
chain_code?: string;
chain_name?: string;
receiver_contract?: string;
direct_receiver_address?: string;
explorer_tx_url?: string;
supports_contract_checkout?: boolean;
supports_direct_transfer?: boolean;
is_default?: boolean;
};
export type PaymentChainOption = {
chain_id: number;
code?: string;
name?: string;
native_currency_symbol?: string;
block_explorer_url?: string;
explorer_tx_url?: string;
is_default?: boolean;
};
@@ -54,9 +71,11 @@ export type PaymentConfig = {
enabled?: boolean;
configured?: boolean;
chain_id?: number;
default_chain_id?: number;
token_address?: string;
token_decimals?: number;
default_token_address?: string;
chains?: PaymentChainOption[];
tokens?: PaymentTokenOption[];
receiver_contract?: string;
confirmations?: number;
@@ -93,6 +112,7 @@ export type CreatedIntent = {
direct_payment?: {
chain_id: number;
chain?: string;
chain_name?: string;
token_symbol?: string;
token_address: string;
token_decimals?: number;
@@ -101,6 +121,7 @@ export type CreatedIntent = {
amount_usdc: string;
intent_id: string;
expires_at: string;
explorer_tx_url?: string;
};
};
@@ -211,6 +211,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
@@ -218,7 +219,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
// ── Chain ID derived from payment config ────────────────
const chainId = paymentConfig?.chain_id ?? 137;
const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
// ── loadPaymentSnapshot ──────────────────────────────────
// Defined in master because it sets state across multiple sub-hook domains.
@@ -243,8 +244,35 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
tokenOptions.find((row) => row.is_default)?.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address ||
configJson.token_address || "",
@@ -283,6 +311,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
backend?.authenticated,
buildAuthedHeaders,
selectedPlanCode,
selectedPaymentChainId,
selectedWallet,
walletAddress,
]);
@@ -371,6 +400,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedPaymentChainId: chainId,
setSelectedPaymentChainId,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
@@ -451,6 +482,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
boundWallets,
walletAddress,
selectedPlanCode,
selectedPaymentChainId: chainId,
selectedTokenAddress,
selectedWallet,
providerMode,
@@ -460,6 +492,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -476,6 +509,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
selectedPaymentToken: paymentFlow.selectedPaymentToken,
selectedTokenLabel: paymentFlow.selectedTokenLabel,
availableTokenList: paymentFlow.availableTokenList,
availableChainList: paymentFlow.availableChainList,
selectedPaymentChain: paymentFlow.selectedPaymentChain,
effectivePlanList,
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
+125 -12
View File
@@ -8,6 +8,7 @@ import type {
CreatedIntent,
EvmProvider,
IntentStatusResponse,
PaymentChainOption,
PaymentConfig,
PaymentTokenOption,
ProviderMode,
@@ -16,7 +17,7 @@ import type {
import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
import {
buildAllowanceCalldata,
buildApproveCalldata,
@@ -43,6 +44,8 @@ export interface UsePaymentFlowParams {
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedPaymentChainId: number;
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
@@ -100,7 +103,7 @@ export interface UsePaymentFlowParams {
loadSnapshot: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
@@ -120,6 +123,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedPaymentChainId,
setSelectedPaymentChainId,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
@@ -171,6 +176,42 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured
.map((row) => ({
...row,
chain_id: Number(row.chain_id),
name: String(row.name || chainIdToDisplayName(Number(row.chain_id))),
}))
.filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0);
if (clean.length) return clean;
const chainIds = new Set<number>();
const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137);
if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId);
(paymentConfig?.tokens || []).forEach((token) => {
const chainId = Number(token.chain_id || defaultChainId);
if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId);
});
return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({
chain_id: chainId,
name: chainIdToDisplayName(chainId),
is_default: chainId === defaultChainId,
}));
}, [paymentConfig]);
const selectedPaymentChain =
availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) ||
availableChainList.find((chain) => chain.is_default) ||
availableChainList[0];
const effectivePaymentChainId = Number(
selectedPaymentChain?.chain_id ||
selectedPaymentChainId ||
paymentConfig?.default_chain_id ||
paymentConfig?.chain_id ||
137,
);
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
const clean = configured
@@ -181,22 +222,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"),
chain_id: Number(row.chain_id || effectivePaymentChainId),
decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6),
}));
}))
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
if (clean.length) return clean;
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
if (!fallbackAddress.startsWith("0x")) return [];
return [{
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
chain_id: effectivePaymentChainId,
decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}];
}, [paymentConfig]);
}, [effectivePaymentChainId, paymentConfig]);
const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address ||
(
selectedTokenAddress &&
availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase())
? selectedTokenAddress
: ""
) ||
availableTokenList.find((row) => row.is_default)?.address ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase();
@@ -225,21 +276,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase()));
setSelectedTokenAddress((prev: string) =>
prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress,
);
}
}
return configJson;
},
[buildAuthedHeaders, selectedPlanCode],
[buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
);
// ── pollIntentUntilConfirmed ────────────────────────────
@@ -345,7 +425,30 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase();
const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : [];
const targetChain =
latestChains.find((chain) => Number(chain.chain_id) === targetChainId) ||
selectedPaymentChain;
const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : [];
const selectedLatestToken =
latestTokens.find(
(token) =>
Number(token.chain_id || targetChainId) === targetChainId &&
String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress,
) ||
latestTokens.find(
(token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default,
) ||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
if (selectedLatestToken?.supports_contract_checkout === false) {
throw new Error(
isEn
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
);
}
const expectedReceiver = String(selectedLatestToken?.receiver_contract || latestConfig.receiver_contract || "").toLowerCase();
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
@@ -353,8 +456,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
}
const targetChainId = Number(latestConfig.chain_id || 137);
await ensureTargetChain(eth, targetChainId);
await ensureTargetChain(eth, targetChainId, targetChain);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
@@ -363,7 +465,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined,
chain_id: targetChainId,
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
@@ -537,6 +640,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
chain_id: effectivePaymentChainId,
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
@@ -563,7 +667,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
setPaymentInfo(
copy.manualOrderCreated
.replace("{amount}", direct.amount_usdc)
.replace("{symbol}", direct.token_symbol || selectedTokenLabel)
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackAppEvent("checkout_started", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
@@ -700,6 +811,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
validateTxHash,
handleOverlayCheckout,
availableTokenList,
availableChainList,
selectedPaymentChain,
resolvedSelectedTokenAddress,
selectedPaymentToken,
selectedTokenLabel,
+36 -10
View File
@@ -7,6 +7,7 @@ import type {
Eip6963ProviderDetail,
EvmProvider,
InjectedProviderOption,
PaymentChainOption,
ProviderMode,
ProviderSelection,
} from "./types";
@@ -190,12 +191,30 @@ export function useWalletBind(params: UseWalletBindParams) {
}
};
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => {
const ensureTargetChain = async (
eth: EvmProvider,
targetChainId: number,
chain?: PaymentChainOption,
): Promise<void> => {
const currentChainIdHex = String(
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
);
const targetChainHex = `0x${targetChainId.toString(16)}`;
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
const chainName =
String(chain?.name || "").trim() ||
(targetChainId === 1 ? "Ethereum Mainnet" : targetChainId === 137 ? "Polygon Mainnet" : `Chain ${targetChainId}`);
const nativeSymbol =
String(chain?.native_currency_symbol || "").trim() ||
(targetChainId === 137 ? "POL" : "ETH");
const explorerBase = String(chain?.block_explorer_url || "").trim() ||
(targetChainId === 1 ? "https://etherscan.io" : targetChainId === 137 ? "https://polygonscan.com" : "");
const defaultRpc =
targetChainId === 137
? "https://polygon-rpc.com"
: targetChainId === 1
? "https://ethereum-rpc.publicnode.com"
: "";
try {
await requestWalletWithTimeout(
eth,
@@ -206,24 +225,31 @@ export function useWalletBind(params: UseWalletBindParams) {
const code = Number(err?.code);
if (code === 4902 || targetChainId === 137) {
try {
const addParams: Record<string, any> = {
chainId: targetChainHex,
chainName,
nativeCurrency: { name: nativeSymbol, symbol: nativeSymbol, decimals: 18 },
};
if (defaultRpc) addParams.rpcUrls = [defaultRpc];
if (explorerBase) addParams.blockExplorerUrls = [explorerBase];
await requestWalletWithTimeout(
eth,
{
method: "wallet_addEthereumChain",
params: [{
chainId: "0x89",
chainName: "Polygon Mainnet",
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
rpcUrls: ["https://polygon-rpc.com"],
blockExplorerUrls: ["https://polygonscan.com"],
}],
params: [addParams],
},
copy.chainAddPolygon,
isEn ? `Add ${chainName}` : `添加 ${chainName}`,
);
return;
} catch (addErr: any) { err = addErr; }
}
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`);
throw new Error(
`${
isEn
? `Please manually switch to ${chainName} in your wallet and try again.`
: `请在钱包中手动切换到 ${chainName} 后再试。`
} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
);
}
};
@@ -61,7 +61,17 @@ export function PaymentsPageClient() {
value,
}));
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
function paymentExplorerUrl(payment: PaymentRecord): string {
const txHash = String(payment.tx_hash || "").trim();
if (!txHash) return "";
const chain = String(payment.chain || "").trim().toLowerCase();
const base = chain.includes("eth")
? "https://etherscan.io"
: "https://polygonscan.com";
return `${base}/tx/${txHash}`;
}
return (
<div className="space-y-6">
@@ -210,7 +220,7 @@ export function PaymentsPageClient() {
<td className="py-2 pr-4 text-xs">
{p.tx_hash ? (
<a
href={`https://polygonscan.com/tx/${p.tx_hash}`}
href={paymentExplorerUrl(p)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 font-mono inline-flex items-center gap-1"
+396 -80
View File
@@ -19,6 +19,8 @@ from src.auth.telegram_group_pricing import TelegramGroupPricing
from src.database.db_manager import DBManager
DEFAULT_POLYGON_CHAIN_ID = 137
DEFAULT_ETHEREUM_CHAIN_ID = 1
DEFAULT_ETHEREUM_USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
DEFAULT_USDT_ADDRESS = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"
@@ -217,7 +219,16 @@ class PaymentTokenConfig:
name: str
address: str
decimals: int
chain_id: int
chain_code: str
chain_name: str
receiver_contract: str
direct_receiver_address: str
rpc_urls: List[str]
explorer_tx_url: str
confirmations: Optional[int]
supports_contract_checkout: bool
supports_direct_transfer: bool
is_default: bool
@@ -267,6 +278,12 @@ class PaymentContractCheckoutService:
legacy_receiver_contract = _normalize_address(
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
)
legacy_direct_receiver_address = (
_normalize_address(
os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or ""
)
or legacy_receiver_contract
)
legacy_token_address = (
os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS")
or DEFAULT_NATIVE_USDC_ADDRESS
@@ -274,33 +291,44 @@ class PaymentContractCheckoutService:
self.supported_tokens = self._load_supported_tokens(
os.getenv("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON") or "",
fallback_receiver_contract=legacy_receiver_contract,
fallback_direct_receiver_address=legacy_direct_receiver_address,
fallback_token_address=legacy_token_address,
fallback_token_decimals=self.token_decimals,
)
self.default_token_address = next(
self.default_token_key = next(
(
address
for address, token in self.supported_tokens.items()
key
for key, token in self.supported_tokens.items()
if bool(token.is_default)
),
"",
)
if not self.default_token_address and self.supported_tokens:
self.default_token_address = next(iter(self.supported_tokens.keys()))
default_token = self.supported_tokens.get(self.default_token_address)
if not self.default_token_key and self.supported_tokens:
self.default_token_key = next(iter(self.supported_tokens.keys()))
default_token = self.supported_tokens.get(self.default_token_key)
self.default_chain_id = int(default_token.chain_id) if default_token else self.chain_id
self.default_token_address = default_token.address if default_token else ""
self.token_address = default_token.address if default_token else ""
self.receiver_contract = (
default_token.receiver_contract if default_token else ""
)
self.direct_receiver_address = (
_normalize_address(
os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or ""
)
or self.receiver_contract
default_token.direct_receiver_address if default_token else ""
)
self.token_decimals = (
int(default_token.decimals) if default_token else int(self.token_decimals)
)
self.rpc_urls_by_chain = self._load_rpc_urls_by_chain(
os.getenv("POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON") or "",
default_chain_id=self.chain_id,
default_rpc_urls=self.rpc_urls,
)
for token in self.supported_tokens.values():
if token.rpc_urls:
self.rpc_urls_by_chain.setdefault(int(token.chain_id), [])
for rpc_url in token.rpc_urls:
if rpc_url not in self.rpc_urls_by_chain[int(token.chain_id)]:
self.rpc_urls_by_chain[int(token.chain_id)].append(rpc_url)
self.intent_ttl_sec = max(
300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800)
)
@@ -344,6 +372,8 @@ class PaymentContractCheckoutService:
self._w3_lock = threading.Lock()
self._w3: Optional[Web3] = None
self._w3_url: str = ""
self._w3_by_chain: Dict[int, Web3] = {}
self._w3_url_by_chain: Dict[int, str] = {}
self._event_topic = Web3.keccak(
text="OrderPaid(bytes32,address,uint256,address,uint256)"
).hex()
@@ -354,14 +384,23 @@ class PaymentContractCheckoutService:
has_valid_token_routes = bool(
self.supported_tokens
and all(
token.address and token.receiver_contract
token.address
and token.direct_receiver_address
and (token.receiver_contract or token.supports_direct_transfer)
for token in self.supported_tokens.values()
)
)
has_rpc_for_token_chains = bool(
self.supported_tokens
and all(
bool(self.rpc_urls_by_chain.get(int(token.chain_id)))
for token in self.supported_tokens.values()
)
)
return bool(
self.supabase_url
and self.supabase_service_role_key
and bool(self.rpc_urls)
and has_rpc_for_token_chains
and has_valid_token_routes
)
@@ -379,14 +418,119 @@ class PaymentContractCheckoutService:
def _load_rpc_urls(self, raw: str) -> List[str]:
out: List[str] = []
for part in str(raw or "").split(","):
if isinstance(raw, list):
parts = raw
else:
parts = str(raw or "").split(",")
for part in parts:
url = str(part or "").strip()
if url and url not in out:
out.append(url)
return out
def _load_rpc_urls_by_chain(
self,
raw: str,
*,
default_chain_id: int,
default_rpc_urls: List[str],
) -> Dict[int, List[str]]:
out: Dict[int, List[str]] = {}
if default_rpc_urls:
out[int(default_chain_id)] = list(default_rpc_urls)
text = str(raw or "").strip()
if not text:
return out
try:
parsed = json.loads(text)
except Exception:
return out
if not isinstance(parsed, dict):
return out
for chain_id_raw, value in parsed.items():
try:
chain_id = int(chain_id_raw)
except Exception:
continue
urls = self._load_rpc_urls(value)
if not urls:
continue
out.setdefault(chain_id, [])
for url in urls:
if url not in out[chain_id]:
out[chain_id].append(url)
return out
def _token_key(self, chain_id: int, token_address: str) -> str:
return f"{int(chain_id)}:{_normalize_address(token_address)}"
def _chain_code_for(self, chain_id: int) -> str:
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
return "ethereum"
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
return "polygon"
return f"chain_{int(chain_id)}"
def _chain_name_for(self, chain_id: int) -> str:
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
return "Ethereum Mainnet"
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
return "Polygon"
return f"Chain ID {int(chain_id)}"
def _native_currency_for(self, chain_id: int) -> str:
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
return "ETH"
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
return "POL"
return "ETH"
def _explorer_base_for(self, chain_id: int) -> str:
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
return "https://etherscan.io"
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
return "https://polygonscan.com"
return ""
def _explorer_tx_url_for(self, chain_id: int) -> str:
base = self._explorer_base_for(chain_id)
return f"{base}/tx/{{tx_hash}}" if base else ""
def _chain_ids(self) -> List[int]:
ids = {int(token.chain_id) for token in self.supported_tokens.values()}
ids.update(int(chain_id) for chain_id in self.rpc_urls_by_chain.keys())
if self.default_chain_id:
ids.add(int(self.default_chain_id))
return sorted(ids)
def _chain_label_for(self, chain_id: int) -> str:
return self._chain_code_for(chain_id)
def _tokens_for_chain(self, chain_id: int) -> List[PaymentTokenConfig]:
return [
token
for token in self.supported_tokens.values()
if int(token.chain_id) == int(chain_id)
]
def _find_token_by_address(
self, token_address: str, chain_id: Optional[int] = None
) -> Optional[PaymentTokenConfig]:
normalized = _normalize_address(token_address)
if not normalized:
return None
for token in self.supported_tokens.values():
if token.address != normalized:
continue
if chain_id is not None and int(token.chain_id) != int(chain_id):
continue
return token
return None
def _default_token_meta(self, address: str) -> Dict[str, str]:
normalized = _normalize_address(address)
if normalized == _normalize_address(DEFAULT_ETHEREUM_USDC_ADDRESS):
return {"code": "usdc", "symbol": "USDC", "name": "USDC"}
if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS):
return {"code": "usdc", "symbol": "USDC", "name": "Native USDC"}
if normalized == _normalize_address(DEFAULT_USDT_ADDRESS):
@@ -400,10 +544,21 @@ class PaymentContractCheckoutService:
self,
row: Dict[str, Any],
fallback_receiver_contract: str,
fallback_direct_receiver_address: str,
fallback_token_decimals: int,
) -> Optional[PaymentTokenConfig]:
if not isinstance(row, dict):
return None
try:
chain_id = int(row.get("chain_id") or row.get("network_id") or self.chain_id)
except Exception:
chain_id = int(self.chain_id)
chain_code = str(
row.get("chain_code") or row.get("network") or self._chain_code_for(chain_id)
).strip().lower()
chain_name = str(
row.get("chain_name") or row.get("network_name") or self._chain_name_for(chain_id)
).strip()
address = _normalize_address(
row.get("address") or row.get("token_address") or row.get("contract")
)
@@ -415,7 +570,13 @@ class PaymentContractCheckoutService:
or row.get("contract_address")
or fallback_receiver_contract
)
if not receiver_contract:
direct_receiver_address = _normalize_address(
row.get("direct_receiver_address")
or row.get("direct_receiver")
or fallback_direct_receiver_address
or receiver_contract
)
if not receiver_contract and not direct_receiver_address:
return None
default_meta = self._default_token_meta(address)
code = str(row.get("code") or default_meta["code"]).strip().lower()
@@ -437,13 +598,37 @@ class PaymentContractCheckoutService:
decimals = int(fallback_token_decimals)
decimals = max(0, decimals)
is_default = bool(row.get("is_default"))
rpc_urls = self._load_rpc_urls(row.get("rpc_urls") or row.get("rpc_url") or "")
explorer_tx_url = str(
row.get("explorer_tx_url") or self._explorer_tx_url_for(chain_id)
).strip()
try:
confirmations_raw = row.get("confirmations")
confirmations = (
int(confirmations_raw) if confirmations_raw is not None else None
)
except Exception:
confirmations = None
supports_direct_transfer = bool(row.get("supports_direct_transfer", True))
supports_contract_checkout = bool(
row.get("supports_contract_checkout", row.get("supports_contract", chain_id == self.chain_id))
)
return PaymentTokenConfig(
code=code,
symbol=symbol,
name=name,
address=address,
decimals=decimals,
chain_id=chain_id,
chain_code=chain_code or self._chain_code_for(chain_id),
chain_name=chain_name or self._chain_name_for(chain_id),
receiver_contract=receiver_contract,
direct_receiver_address=direct_receiver_address or receiver_contract,
rpc_urls=rpc_urls,
explorer_tx_url=explorer_tx_url,
confirmations=confirmations,
supports_contract_checkout=supports_contract_checkout,
supports_direct_transfer=supports_direct_transfer,
is_default=is_default,
)
@@ -452,6 +637,7 @@ class PaymentContractCheckoutService:
raw: str,
*,
fallback_receiver_contract: str,
fallback_direct_receiver_address: str,
fallback_token_address: str,
fallback_token_decimals: int,
) -> Dict[str, PaymentTokenConfig]:
@@ -483,11 +669,12 @@ class PaymentContractCheckoutService:
token = self._to_token_config(
row,
fallback_receiver_contract=fallback_receiver_contract,
fallback_direct_receiver_address=fallback_direct_receiver_address,
fallback_token_decimals=fallback_token_decimals,
)
if not token:
continue
out[token.address] = token
out[self._token_key(token.chain_id, token.address)] = token
if out:
return out
@@ -502,41 +689,68 @@ class PaymentContractCheckoutService:
name=fallback_meta["name"],
address=fallback_address,
decimals=max(0, int(fallback_token_decimals)),
chain_id=int(self.chain_id),
chain_code=self._chain_code_for(self.chain_id),
chain_name=self._chain_name_for(self.chain_id),
receiver_contract=fallback_receiver_contract,
direct_receiver_address=fallback_direct_receiver_address
or fallback_receiver_contract,
rpc_urls=[],
explorer_tx_url=self._explorer_tx_url_for(self.chain_id),
confirmations=None,
supports_contract_checkout=True,
supports_direct_transfer=True,
is_default=True,
)
return {fallback_token.address: fallback_token}
return {self._token_key(fallback_token.chain_id, fallback_token.address): fallback_token}
def _resolve_supported_token(
self,
token_address: Optional[str] = None,
chain_id: Optional[int] = None,
) -> PaymentTokenConfig:
selected_chain_id = int(chain_id) if chain_id is not None else None
normalized = _normalize_address(token_address or "")
if normalized:
token = self.supported_tokens.get(normalized)
token = self._find_token_by_address(normalized, selected_chain_id)
if token:
return token
available = ", ".join(
f"{item.symbol}:{item.address}"
f"{item.chain_code}/{item.symbol}:{item.address}"
for item in self.supported_tokens.values()
)
raise PaymentCheckoutError(
400,
f"token_address not supported: {normalized}. available={available}",
)
default_token = self.supported_tokens.get(self.default_token_address)
if selected_chain_id is not None:
chain_tokens = self._tokens_for_chain(selected_chain_id)
default_for_chain = next(
(token for token in chain_tokens if bool(token.is_default)),
chain_tokens[0] if chain_tokens else None,
)
if default_for_chain:
return default_for_chain
raise PaymentCheckoutError(
400, f"payment chain_id not supported: {selected_chain_id}"
)
default_token = self.supported_tokens.get(self.default_token_key)
if default_token:
return default_token
raise PaymentCheckoutError(503, "no supported payment token configured")
def _token_decimals_for(self, token_address: str) -> int:
token = self.supported_tokens.get(_normalize_address(token_address))
def _token_decimals_for(
self, token_address: str, chain_id: Optional[int] = None
) -> int:
token = self._find_token_by_address(token_address, chain_id)
if token:
return int(token.decimals)
return int(self.token_decimals)
def _token_symbol_for(self, token_address: str) -> str:
token = self.supported_tokens.get(_normalize_address(token_address))
def _token_symbol_for(
self, token_address: str, chain_id: Optional[int] = None
) -> str:
token = self._find_token_by_address(token_address, chain_id)
if token and token.symbol:
return str(token.symbol)
normalized = _normalize_address(token_address)
@@ -544,6 +758,25 @@ class PaymentContractCheckoutService:
return f"{normalized[:6]}...{normalized[-4:]}"
return "Unknown"
def _token_config_for_intent(
self, intent: PaymentIntentRecord
) -> Optional[PaymentTokenConfig]:
return self._find_token_by_address(intent.token_address, intent.chain_id)
def _confirmations_for_chain(self, chain_id: int) -> int:
chain_tokens = self._tokens_for_chain(chain_id)
token_confirmations = next(
(
int(token.confirmations)
for token in chain_tokens
if token.confirmations is not None and int(token.confirmations) > 0
),
None,
)
if token_confirmations:
return max(1, int(token_confirmations))
return int(self.confirmations)
def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
headers = {
"apikey": self.supabase_service_role_key,
@@ -882,45 +1115,78 @@ class PaymentContractCheckoutService:
Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": self.timeout_sec})
)
def _try_connect_rpc(self, rpc_url: str) -> Optional[Web3]:
def _try_connect_rpc(self, rpc_url: str, chain_id: int) -> 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):
if int(w3.eth.chain_id) != int(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)
def _rotate_rpc(self, chain_id: Optional[int] = None) -> Optional[Web3]:
target_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
for rpc_url in self.rpc_urls_by_chain.get(target_chain_id, []):
w3 = self._try_connect_rpc(rpc_url, target_chain_id)
if w3 is not None:
self._w3 = w3
self._w3_url = rpc_url
self._w3_by_chain[target_chain_id] = w3
self._w3_url_by_chain[target_chain_id] = rpc_url
if target_chain_id == int(self.default_chain_id or self.chain_id):
self._w3 = w3
self._w3_url = rpc_url
return w3
self._w3 = None
self._w3_url = ""
self._w3_by_chain.pop(target_chain_id, None)
self._w3_url_by_chain.pop(target_chain_id, None)
if target_chain_id == int(self.default_chain_id or self.chain_id):
self._w3 = None
self._w3_url = ""
return None
def _get_web3(self, force_refresh: bool = False) -> Web3:
def _get_web3(
self,
chain_id: Optional[int] = None,
force_refresh: bool = False,
) -> Web3:
target_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
with self._w3_lock:
if self._w3 is None or force_refresh:
self._rotate_rpc()
assert self._w3 is not None
return self._w3
if self._w3_by_chain.get(target_chain_id) is None or force_refresh:
self._rotate_rpc(target_chain_id)
w3 = self._w3_by_chain.get(target_chain_id)
assert w3 is not None
return w3
def get_rpc_runtime_status(self) -> Dict[str, Any]:
candidates = list(self.rpc_urls)
default_chain_id = int(self.default_chain_id or self.chain_id)
candidates = list(self.rpc_urls_by_chain.get(default_chain_id, []))
chains = {
str(chain_id): {
"chain_id": chain_id,
"chain_code": self._chain_code_for(chain_id),
"chain_name": self._chain_name_for(chain_id),
"configured_rpc_count": len(urls),
"active_rpc_url": self._w3_url_by_chain.get(chain_id)
or (urls[0] if urls else ""),
"all_rpc_urls": list(urls),
}
for chain_id, urls in sorted(self.rpc_urls_by_chain.items())
}
return {
"configured_rpc_count": len(candidates),
"active_rpc_url": self._w3_url or (candidates[0] if candidates else ""),
"active_rpc_url": self._w3_url_by_chain.get(default_chain_id)
or self._w3_url
or (candidates[0] if candidates else ""),
"all_rpc_urls": candidates,
"chains": chains,
}
def _get_contract(self, receiver_address: Optional[str] = None):
w3 = self._get_web3()
def _get_contract(
self,
receiver_address: Optional[str] = None,
chain_id: Optional[int] = None,
):
w3 = self._get_web3(chain_id=chain_id)
contract_address = _normalize_address(
receiver_address or self.receiver_contract
)
@@ -932,6 +1198,19 @@ class PaymentContractCheckoutService:
)
def get_config_payload(self) -> Dict[str, Any]:
default_chain_id = int(self.default_chain_id or self.chain_id)
chains_payload = [
{
"chain_id": chain_id,
"code": self._chain_code_for(chain_id),
"name": self._chain_name_for(chain_id),
"native_currency_symbol": self._native_currency_for(chain_id),
"block_explorer_url": self._explorer_base_for(chain_id),
"explorer_tx_url": self._explorer_tx_url_for(chain_id),
"is_default": chain_id == default_chain_id,
}
for chain_id in self._chain_ids()
]
tokens_payload = [
{
"code": token.code,
@@ -939,24 +1218,36 @@ class PaymentContractCheckoutService:
"name": token.name,
"address": token.address,
"decimals": int(token.decimals),
"chain_id": int(token.chain_id),
"chain_code": token.chain_code,
"chain_name": token.chain_name,
"receiver_contract": token.receiver_contract,
"direct_receiver_address": token.direct_receiver_address,
"explorer_tx_url": token.explorer_tx_url,
"supports_contract_checkout": bool(token.supports_contract_checkout),
"supports_direct_transfer": bool(token.supports_direct_transfer),
"is_default": bool(
token.is_default or token.address == self.default_token_address
token.is_default
or self._token_key(token.chain_id, token.address)
== self.default_token_key
),
}
for token in sorted(
self.supported_tokens.values(), key=lambda row: row.code
self.supported_tokens.values(),
key=lambda row: (int(row.chain_id), row.code),
)
]
return {
"enabled": self.enabled,
"configured": self.configured,
"chain_id": self.chain_id,
"chain_id": default_chain_id,
"default_chain_id": default_chain_id,
"token_address": self.token_address,
"token_decimals": self.token_decimals,
"receiver_contract": self.receiver_contract,
"direct_receiver_address": self.direct_receiver_address,
"default_token_address": self.default_token_address or self.token_address,
"chains": chains_payload,
"tokens": tokens_payload,
"confirmations": self.confirmations,
"intent_ttl_sec": self.intent_ttl_sec,
@@ -979,10 +1270,11 @@ class PaymentContractCheckoutService:
}
def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord:
chain_id = int(row.get("chain_id") or self.chain_id)
token_address = _normalize_address(
row.get("token_address") or self.token_address
)
token_decimals = self._token_decimals_for(token_address)
token_decimals = self._token_decimals_for(token_address, chain_id)
amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0")))
amount_display = _units_to_decimal(amount_units, token_decimals)
return PaymentIntentRecord(
@@ -990,12 +1282,12 @@ class PaymentContractCheckoutService:
order_id_hex=str(row.get("order_id_hex")),
plan_code=str(row.get("plan_code")),
plan_id=int(row.get("plan_id") or 0),
chain_id=int(row.get("chain_id") or self.chain_id),
chain_id=chain_id,
amount_units=amount_units,
amount_usdc=_format_decimal(amount_display),
token_address=token_address,
token_decimals=token_decimals,
token_symbol=self._token_symbol_for(token_address),
token_symbol=self._token_symbol_for(token_address, chain_id),
receiver_address=_normalize_address(
row.get("receiver_address") or self.receiver_contract
),
@@ -1359,7 +1651,7 @@ class PaymentContractCheckoutService:
return out
def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]:
contract = self._get_contract(intent.receiver_address)
contract = self._get_contract(intent.receiver_address, intent.chain_id)
tx_data = contract.encode_abi(
"pay",
args=[
@@ -1370,7 +1662,7 @@ class PaymentContractCheckoutService:
],
)
return {
"chain_id": self.chain_id,
"chain_id": int(intent.chain_id),
"to": Web3.to_checksum_address(intent.receiver_address),
"data": tx_data,
"value": "0x0",
@@ -1389,6 +1681,7 @@ class PaymentContractCheckoutService:
payment_mode: str = "strict",
allowed_wallet: Optional[str] = None,
token_address: Optional[str] = None,
chain_id: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
use_points: bool = False,
points_to_consume: Optional[int] = None,
@@ -1398,7 +1691,8 @@ class PaymentContractCheckoutService:
user_id,
self._select_plan(plan_code),
)
selected_token = self._resolve_supported_token(token_address)
selected_token = self._resolve_supported_token(token_address, chain_id)
selected_chain_id = int(selected_token.chain_id)
mode = str(payment_mode or "strict").strip().lower()
if mode == "manual":
mode = "direct"
@@ -1406,6 +1700,16 @@ class PaymentContractCheckoutService:
raise PaymentCheckoutError(
400, "payment_mode must be strict, flex, or direct"
)
if mode == "direct" and not selected_token.supports_direct_transfer:
raise PaymentCheckoutError(
400,
f"{selected_token.chain_name} {selected_token.symbol} does not support direct transfer",
)
if mode != "direct" and not selected_token.supports_contract_checkout:
raise PaymentCheckoutError(
400,
f"{selected_token.chain_name} {selected_token.symbol} supports manual transfer only",
)
bound_wallets = [] if mode == "direct" else self.list_wallets(user_id)
if mode != "direct" and not bound_wallets:
raise PaymentCheckoutError(403, "bind wallet first")
@@ -1439,10 +1743,13 @@ class PaymentContractCheckoutService:
combined_metadata = dict(metadata or {})
combined_metadata["token_code"] = str(selected_token.code)
combined_metadata["token_symbol"] = str(selected_token.symbol)
combined_metadata["chain_id"] = selected_chain_id
combined_metadata["chain_code"] = selected_token.chain_code
combined_metadata["chain_name"] = selected_token.chain_name
if isinstance(plan.get("telegram_pricing"), dict):
combined_metadata["telegram_pricing"] = plan["telegram_pricing"]
receiver_address = (
self.direct_receiver_address
selected_token.direct_receiver_address
if mode == "direct"
else selected_token.receiver_contract
)
@@ -1480,7 +1787,7 @@ class PaymentContractCheckoutService:
"user_id": user_id,
"plan_code": plan["plan_code"],
"plan_id": plan["plan_id"],
"chain_id": self.chain_id,
"chain_id": selected_chain_id,
"token_address": selected_token.address,
"receiver_address": receiver_address,
"amount_units": str(amount_units),
@@ -1530,8 +1837,9 @@ class PaymentContractCheckoutService:
}
if mode == "direct":
response["direct_payment"] = {
"chain_id": self.chain_id,
"chain": "polygon",
"chain_id": selected_chain_id,
"chain": selected_token.chain_code,
"chain_name": selected_token.chain_name,
"token_symbol": intent.token_symbol,
"token_address": intent.token_address,
"token_decimals": int(intent.token_decimals),
@@ -1540,6 +1848,8 @@ class PaymentContractCheckoutService:
"amount_usdc": intent.amount_usdc,
"intent_id": intent.intent_id,
"expires_at": intent.expires_at,
"explorer_tx_url": selected_token.explorer_tx_url
or self._explorer_tx_url_for(selected_chain_id),
}
return response
@@ -1578,7 +1888,6 @@ class PaymentContractCheckoutService:
"select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id",
"status": "eq.submitted",
"tx_hash": "not.is.null",
"chain_id": f"eq.{self.chain_id}",
"order": "updated_at.asc",
"limit": str(safe_limit),
},
@@ -1601,6 +1910,7 @@ class PaymentContractCheckoutService:
"intent_id": intent_id,
"user_id": user_id,
"tx_hash": tx_hash,
"chain_id": int(row.get("chain_id") or self.chain_id),
"status": str(row.get("status") or ""),
"updated_at": row.get("updated_at"),
"created_at": row.get("created_at"),
@@ -1631,7 +1941,6 @@ class PaymentContractCheckoutService:
"amount_units,payment_mode,allowed_wallet,chain_id,created_at,updated_at"
),
"order_id_hex": f"eq.{normalized_order}",
"chain_id": f"eq.{self.chain_id}",
"status": "in.(created,submitted,confirmed)",
"order": "created_at.desc",
"limit": str(safe_limit),
@@ -1658,6 +1967,7 @@ class PaymentContractCheckoutService:
"tx_hash": str(row.get("tx_hash") or "").strip().lower(),
"order_id_hex": str(row.get("order_id_hex") or "").strip().lower(),
"plan_id": int(row.get("plan_id") or 0),
"chain_id": int(row.get("chain_id") or self.chain_id),
"token_address": _normalize_address(row.get("token_address")),
"amount_units": int(row.get("amount_units") or 0),
"payment_mode": str(row.get("payment_mode") or "").strip().lower(),
@@ -1714,7 +2024,7 @@ class PaymentContractCheckoutService:
params={"on_conflict": "tx_hash"},
payload={
"intent_id": intent.intent_id,
"chain_id": self.chain_id,
"chain_id": int(intent.chain_id),
"tx_hash": tx_hash_text,
"from_address": _normalize_address(from_address) or None,
"to_address": _normalize_address(to_address)
@@ -1775,12 +2085,12 @@ class PaymentContractCheckoutService:
"checks": {"intent_expired": True},
}
w3 = self._get_web3()
w3 = self._get_web3(chain_id=intent.chain_id)
try:
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
except Exception:
try:
w3 = self._get_web3(force_refresh=True)
w3 = self._get_web3(chain_id=intent.chain_id, force_refresh=True)
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
except Exception:
receipt = None
@@ -1991,7 +2301,7 @@ class PaymentContractCheckoutService:
params={"on_conflict": "tx_hash"},
payload={
"intent_id": intent.intent_id,
"chain_id": self.chain_id,
"chain_id": int(intent.chain_id),
"tx_hash": tx_hash_text,
"from_address": from_addr,
"to_address": intent.receiver_address,
@@ -2014,26 +2324,26 @@ class PaymentContractCheckoutService:
else None,
}
def _wait_receipt(self, tx_hash: str) -> Any:
def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any:
import time as _time
start = _now_utc()
while (_now_utc() - start).total_seconds() < self.max_wait_sec:
try:
w3 = self._get_web3()
w3 = self._get_web3(chain_id=chain_id)
receipt = w3.eth.get_transaction_receipt(tx_hash)
except Exception:
try:
w3 = self._get_web3(force_refresh=True)
w3 = self._get_web3(chain_id=chain_id, 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()
latest_w3 = self._get_web3(chain_id=chain_id)
if not latest_w3.is_connected():
self._get_web3(force_refresh=True)
self._get_web3(chain_id=chain_id, force_refresh=True)
except Exception:
receipt = None
_time.sleep(self.poll_interval_sec)
@@ -2042,7 +2352,7 @@ class PaymentContractCheckoutService:
def _extract_matching_event(
self, receipt: Any, intent: PaymentIntentRecord
) -> Optional[Dict[str, Any]]:
contract = self._get_contract(intent.receiver_address)
contract = self._get_contract(intent.receiver_address, intent.chain_id)
try:
events = contract.events.OrderPaid().process_receipt(receipt)
except Exception:
@@ -2089,14 +2399,14 @@ class PaymentContractCheckoutService:
token_addresses: List[str] = []
if intent.token_address:
token_addresses.append(_normalize_address(intent.token_address))
for addr in self.supported_tokens:
normalized = _normalize_address(addr)
for token in self._tokens_for_chain(intent.chain_id):
normalized = _normalize_address(token.address)
if normalized and normalized not in token_addresses:
token_addresses.append(normalized)
for token_addr in token_addresses:
try:
token_contract = self._get_web3().eth.contract(
token_contract = self._get_web3(chain_id=intent.chain_id).eth.contract(
address=Web3.to_checksum_address(token_addr),
abi=[ERC20_TRANSFER_EVENT_ABI],
)
@@ -2116,7 +2426,7 @@ class PaymentContractCheckoutService:
receiver = _normalize_address(args.get("to"))
amount = int(args.get("value") or 0)
if receiver == expected_to and amount >= expected_amount:
token_meta = self._token_symbol_for(token_addr)
token_meta = self._token_symbol_for(token_addr, intent.chain_id)
return {
"from": payer,
"to": receiver,
@@ -2137,10 +2447,12 @@ class PaymentContractCheckoutService:
amount_units: int,
token_address: str,
payload: Dict[str, Any],
chain_id: Optional[int] = None,
) -> Dict[str, Any]:
token_decimals = self._token_decimals_for(token_address)
payment_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
token_decimals = self._token_decimals_for(token_address, payment_chain_id)
amount_dec = _units_to_decimal(amount_units, token_decimals)
currency = self._token_symbol_for(token_address)
currency = self._token_symbol_for(token_address, payment_chain_id)
rows = self._rest(
"POST",
"payments",
@@ -2149,7 +2461,7 @@ class PaymentContractCheckoutService:
"user_id": user_id,
"amount": str(amount_dec),
"currency": currency,
"chain": "polygon",
"chain": self._chain_label_for(payment_chain_id),
"tx_hash": tx_hash,
"status": "confirmed",
"raw_payload": payload,
@@ -2295,6 +2607,7 @@ class PaymentContractCheckoutService:
tx_hash=tx_hash,
amount_units=int(intent.amount_units),
token_address=intent.token_address,
chain_id=intent.chain_id,
payload={
"tx_hash": tx_hash,
"intent_id": intent.intent_id,
@@ -2380,7 +2693,7 @@ class PaymentContractCheckoutService:
params={"on_conflict": "tx_hash"},
payload={
"intent_id": intent.intent_id,
"chain_id": self.chain_id,
"chain_id": int(intent.chain_id),
"tx_hash": str(tx_hash).strip().lower(),
"from_address": None,
"to_address": intent.receiver_address,
@@ -2470,13 +2783,13 @@ class PaymentContractCheckoutService:
raise PaymentCheckoutError(400, "tx_hash required")
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
raise PaymentCheckoutError(400, "invalid tx_hash")
w3 = self._get_web3()
w3 = self._get_web3(chain_id=intent.chain_id)
if not w3.is_connected():
raise PaymentCheckoutError(503, "cannot connect payment rpc")
if int(w3.eth.chain_id) != int(self.chain_id):
if int(w3.eth.chain_id) != int(intent.chain_id):
raise PaymentCheckoutError(503, "payment rpc chain mismatch")
# Wait for receipt first to avoid transient RPC lag on eth_getTransaction.
receipt = self._wait_receipt(tx_hash_text)
receipt = self._wait_receipt(tx_hash_text, chain_id=intent.chain_id)
if int(receipt.get("status") or 0) != 1:
self._mark_intent_failed(
user_id=user_id,
@@ -2502,9 +2815,11 @@ class PaymentContractCheckoutService:
block_number = int(receipt.get("blockNumber") or 0)
latest_block = int(w3.eth.block_number)
confirmations = max(0, latest_block - block_number + 1) if block_number else 0
if confirmations < self.confirmations:
required_confirmations = self._confirmations_for_chain(intent.chain_id)
if confirmations < required_confirmations:
raise PaymentCheckoutError(
409, f"confirmations not enough: {confirmations}/{self.confirmations}"
409,
f"confirmations not enough: {confirmations}/{required_confirmations}",
)
is_direct = intent.payment_mode == "direct"
if is_direct:
@@ -2651,7 +2966,7 @@ class PaymentContractCheckoutService:
payload={
"intent_id": intent.intent_id,
"tx_hash": tx_hash_text,
"chain_id": self.chain_id,
"chain_id": int(intent.chain_id),
"from_address": tx_from,
"to_address": tx_to,
"block_number": block_number,
@@ -2680,6 +2995,7 @@ class PaymentContractCheckoutService:
tx_hash=tx_hash_text,
amount_units=intent.amount_units,
token_address=intent.token_address,
chain_id=intent.chain_id,
payload=payload,
)
subscription_row = self._grant_subscription(
+7 -3
View File
@@ -98,10 +98,14 @@ def validate_runtime_env(
)
if payment_enabled:
payment_missing = _missing(["POLYWEATHER_PAYMENT_RPC_URL"])
if payment_missing:
has_payment_rpc = (
_has("POLYWEATHER_PAYMENT_RPC_URL")
or _has("POLYWEATHER_PAYMENT_RPC_URLS")
or _has("POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON")
)
if not has_payment_rpc:
report.errors.append(
f"已启用支付,但缺少变量: {', '.join(payment_missing)}"
"已启用支付,但缺少变量: POLYWEATHER_PAYMENT_RPC_URL / POLYWEATHER_PAYMENT_RPC_URLS / POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON"
)
has_receiver = _has("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT")
has_tokens_json = _has("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON")
+263 -2
View File
@@ -1,6 +1,11 @@
from src.payments.contract_checkout import PaymentContractCheckoutService, PaymentIntentRecord
ETHEREUM_USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
POLYGON_USDC = "0x3c499c542cef5E3811e1192ce70d8cc03d5c3359"
RECEIVER = "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32"
def _setup_env(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
@@ -14,6 +19,262 @@ def _setup_env(monkeypatch, tmp_path):
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
def _setup_multichain_env(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_URL", "https://polygon-rpc.example")
monkeypatch.setenv(
"POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON",
'{"1":["https://ethereum-rpc.example"],"137":["https://polygon-rpc.example"]}',
)
monkeypatch.setenv(
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
(
"["
f'{{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon",'
f'"chain_id":137,"chain_code":"polygon","chain_name":"Polygon",'
f'"address":"{POLYGON_USDC}","decimals":6,'
f'"receiver_contract":"{RECEIVER}","is_default":true}},'
f'{{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum",'
f'"chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet",'
f'"address":"{ETHEREUM_USDC}","decimals":6,'
f'"receiver_contract":"{RECEIVER}",'
f'"direct_receiver_address":"{RECEIVER}",'
f'"supports_contract_checkout":false,'
f'"explorer_tx_url":"https://etherscan.io/tx/{{tx_hash}}"}}'
"]"
),
)
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
def test_config_payload_exposes_polygon_and_ethereum_payment_routes(
monkeypatch, tmp_path
):
_setup_multichain_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
payload = service.get_config_payload()
assert payload["default_chain_id"] == 137
assert {row["chain_id"] for row in payload["chains"]} == {1, 137}
eth_token = next(
row for row in payload["tokens"] if row["chain_id"] == 1
)
assert eth_token["chain_code"] == "ethereum"
assert eth_token["address"] == ETHEREUM_USDC.lower()
assert eth_token["supports_direct_transfer"] is True
assert eth_token["supports_contract_checkout"] is False
def test_create_direct_intent_can_target_ethereum_usdc(monkeypatch, tmp_path):
_setup_multichain_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
posts = []
def fake_rest(method, table, **kwargs):
if method == "POST" and table == "payment_intents":
payload = dict(kwargs["payload"])
payload["id"] = "intent-eth-1"
payload["tx_hash"] = None
posts.append(payload)
return [payload]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.create_intent(
"user-1",
"pro_monthly",
payment_mode="direct",
chain_id=1,
token_address=ETHEREUM_USDC,
)
assert posts[0]["chain_id"] == 1
assert posts[0]["token_address"] == ETHEREUM_USDC.lower()
assert result["direct_payment"]["chain_id"] == 1
assert result["direct_payment"]["chain"] == "ethereum"
assert result["direct_payment"]["chain_name"] == "Ethereum Mainnet"
assert result["direct_payment"]["explorer_tx_url"] == (
"https://etherscan.io/tx/{tx_hash}"
)
def test_pending_confirm_intents_include_supported_non_default_chains(
monkeypatch, tmp_path
):
_setup_multichain_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
captured_params = {}
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "payment_intents":
captured_params.update(kwargs["params"])
return [
{
"id": "intent-eth-1",
"user_id": "user-1",
"tx_hash": "0x" + "a" * 64,
"status": "submitted",
"chain_id": 1,
"updated_at": "2026-05-29T00:00:00+00:00",
"created_at": "2026-05-29T00:00:00+00:00",
}
]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
rows = service.list_pending_confirm_intents(limit=5)
assert "chain_id" not in captured_params
assert rows[0]["intent_id"] == "intent-eth-1"
assert rows[0]["chain_id"] == 1
def test_insert_payment_record_stores_ethereum_chain(monkeypatch, tmp_path):
_setup_multichain_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
inserted = {}
def fake_rest(method, table, **kwargs):
if method == "POST" and table == "payments":
inserted.update(kwargs["payload"])
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
service._insert_payment_record(
user_id="user-1",
tx_hash="0x" + "b" * 64,
amount_units=10_000_000,
token_address=ETHEREUM_USDC,
chain_id=1,
payload={},
)
assert inserted["chain"] == "ethereum"
assert inserted["currency"] == "USDC"
def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
_setup_multichain_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "c" * 64
intent = PaymentIntentRecord(
intent_id="intent-direct-eth",
order_id_hex="0x" + "1" * 64,
plan_code="pro_monthly",
plan_id=101,
chain_id=1,
amount_units=10_000_000,
amount_usdc="10",
token_address=ETHEREUM_USDC.lower(),
token_decimals=6,
token_symbol="USDC",
receiver_address=RECEIVER.lower(),
status="submitted",
payment_mode="direct",
allowed_wallet=None,
expires_at="2099-01-01T00:00:00+00:00",
tx_hash=tx_hash,
metadata={},
)
confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"})
intents = [intent, confirmed_intent]
requested_chains = []
tx_rows = []
monkeypatch.setattr(
service,
"get_intent",
lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent,
)
class _Eth:
chain_id = 1
block_number = 20
@staticmethod
def get_transaction(_tx_hash):
return {
"to": ETHEREUM_USDC,
"from": "0x9999999999999999999999999999999999999999",
}
class _Web3:
eth = _Eth()
@staticmethod
def is_connected():
return True
def fake_get_web3(*args, **kwargs):
requested_chains.append(kwargs.get("chain_id") or (args[0] if args else None))
return _Web3()
monkeypatch.setattr(service, "_get_web3", fake_get_web3)
monkeypatch.setattr(
service,
"_wait_receipt",
lambda _tx_hash, *args, **kwargs: {
"status": 1,
"to": ETHEREUM_USDC,
"from": "0x9999999999999999999999999999999999999999",
"blockNumber": 10,
},
)
monkeypatch.setattr(
service,
"_extract_direct_transfer_event",
lambda receipt, local_intent: {
"from": "0x9999999999999999999999999999999999999999",
"to": local_intent.receiver_address,
"token_address": local_intent.token_address,
"amount_units": local_intent.amount_units,
},
)
monkeypatch.setattr(
service,
"_consume_points_for_intent",
lambda user_id, local_intent: {"applied": False},
)
monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30})
monkeypatch.setattr(
service,
"_insert_payment_record",
lambda **kwargs: {"chain_id": kwargs["chain_id"], "tx_hash": kwargs["tx_hash"]},
)
monkeypatch.setattr(
service,
"_grant_subscription",
lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]},
)
monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None)
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "payment_transactions":
return []
if method == "PATCH" and table == "payment_intents":
return [{"id": intent.intent_id, "status": "confirmed"}]
if method == "POST" and table == "payment_transactions":
tx_rows.append(kwargs["payload"])
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
assert 1 in requested_chains
assert tx_rows[0]["chain_id"] == 1
assert result["payment"]["chain_id"] == 1
def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
@@ -137,11 +398,11 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
def is_connected():
return True
monkeypatch.setattr(service, "_get_web3", lambda: _Web3())
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _Web3())
monkeypatch.setattr(
service,
"_wait_receipt",
lambda _tx_hash: {
lambda _tx_hash, *args, **kwargs: {
"status": 1,
"to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"from": "0x9999999999999999999999999999999999999999",
+2 -2
View File
@@ -303,11 +303,11 @@ def test_confirm_intent_tx_repairs_side_effect_failure(monkeypatch, tmp_path):
def is_connected():
return True
monkeypatch.setattr(service, "_get_web3", lambda: _Web3())
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _Web3())
monkeypatch.setattr(
service,
"_wait_receipt",
lambda _tx_hash: {
lambda _tx_hash, *args, **kwargs: {
"status": 1,
"to": "0xed2f13aa5ff033c58fb436e178451cd07f693f32",
"from": "0x1111111111111111111111111111111111111111",
+1
View File
@@ -437,6 +437,7 @@ class CreatePaymentIntentRequest(BaseModel):
payment_mode: str = Field(default="strict")
allowed_wallet: Optional[str] = None
token_address: Optional[str] = None
chain_id: Optional[int] = None
use_points: bool = False
points_to_consume: Optional[int] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
+1
View File
@@ -123,6 +123,7 @@ def create_payment_intent(
payment_mode=body.payment_mode,
allowed_wallet=body.allowed_wallet,
token_address=body.token_address,
chain_id=body.chain_id,
use_points=body.use_points,
points_to_consume=body.points_to_consume,
metadata=body.metadata,