feat: support ethereum usdc payment route
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -14,13 +14,13 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(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`(会员、周榜、补分、支付异常单)。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商业化说明(Production)
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 定位
|
||||
|
||||
@@ -21,7 +21,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
|
||||
|
||||
@@ -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 机器人市场监控建议配置
|
||||
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
@@ -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
@@ -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 后无法自动确认。
|
||||
|
||||
@@ -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` 事件。
|
||||
|
||||
@@ -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 @@ DEB(Dynamic 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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user