265 lines
6.7 KiB
Markdown
265 lines
6.7 KiB
Markdown
# PolyWeather 支付审计与防护说明
|
|
|
|
最后更新:`2026-05-29`
|
|
|
|
## 1. 当前已落地的防护
|
|
|
|
### 链下运行态
|
|
|
|
- 支付事件扫描与确认循环已把运行态写入 SQLite:
|
|
- `payment_runtime_state`
|
|
- `payment_audit_events`
|
|
- 关键循环现在会记录:
|
|
- `event_loop_started`
|
|
- `event_loop_cycle`
|
|
- `event_loop_error`
|
|
- `confirm_loop_started`
|
|
- `confirm_loop_cycle`
|
|
- `confirm_loop_error`
|
|
|
|
### 事件确认边界
|
|
|
|
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`。
|
|
- 前端提交 intent 不会直接视为支付完成。
|
|
- `confirm_loop` 会再次按 intent 的 `chain_id`、`token_address`、收款地址、金额与确认数校验链上交易。
|
|
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
|
|
|
当前已显式识别的失败原因包括:
|
|
|
|
- `receiver_mismatch`
|
|
- `sender_mismatch`
|
|
- `event_mismatch`
|
|
- `token_mismatch`
|
|
- `tx_reverted`
|
|
|
|
### RPC 多节点容灾
|
|
|
|
- 支持默认链 `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。
|
|
|
|
### 事件重放
|
|
|
|
- 已提供脚本:
|
|
- [replay_payment_events.py](/E:/web/PolyWeather/scripts/replay_payment_events.py)
|
|
|
|
用途:
|
|
- 审计某个区块范围内的 `OrderPaid`
|
|
- 事后补查漏单
|
|
- 排查 RPC 抖动导致的监听遗漏
|
|
|
|
命令示例:
|
|
|
|
```bash
|
|
python scripts/replay_payment_events.py --from-block 10000000 --to-block 10001000
|
|
```
|
|
|
|
### 运行态检查
|
|
|
|
- 已提供接口:
|
|
- `GET /api/payments/runtime`
|
|
|
|
可查看:
|
|
- checkout 配置摘要
|
|
- 当前活跃 RPC
|
|
- 候选 RPC 列表
|
|
- event loop 最新状态
|
|
- 最近审计事件
|
|
|
|
### Ops 事故单
|
|
|
|
现在 `/ops` 已提供单独的支付异常单列表,默认展示:
|
|
|
|
- `payment_intent_failed`
|
|
|
|
支持:
|
|
|
|
- 按 `reason` 过滤
|
|
- 标记已处理
|
|
|
|
这让下面这类事故不再需要翻日志定位:
|
|
|
|
- 已付款但未开通
|
|
- 打到旧收款地址
|
|
- 交易事件不匹配
|
|
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
|
|
|
|
## 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)
|
|
|
|
当前边界:
|
|
|
|
1. `owner`
|
|
- 可执行:
|
|
- `setTreasury`
|
|
- `setTokenAllowed`
|
|
|
|
2. 普通用户
|
|
- 只能调用:
|
|
- `pay(orderId, planId, amount, token)`
|
|
|
|
3. 代币边界
|
|
- 只有 `allowedToken[token] == true` 的 token 可支付
|
|
|
|
4. 订单边界
|
|
- 同一个 `orderId` 只能成功支付一次
|
|
|
|
## 4. 重入与重复支付判断
|
|
|
|
当前合约的 `pay` 逻辑顺序是:
|
|
|
|
1. 检查 token allowlist
|
|
2. 检查 `amount > 0`
|
|
3. 检查 `paidOrder[orderId] == false`
|
|
4. 先写入 `paidOrder[orderId] = true`
|
|
5. 再执行 `transferFrom`
|
|
6. 发出 `OrderPaid`
|
|
|
|
这意味着:
|
|
|
|
- 同一 `orderId` 的重复支付会被拦住
|
|
- 典型“转账外部调用后再回调重复执行同订单”的路径会被 `paidOrder` 状态挡住
|
|
|
|
但要注意:
|
|
|
|
- 当前合约没有 `Pausable`
|
|
- 当前合约没有 `SafeERC20`
|
|
- 当前合约没有在链上校验 `planId -> amount`
|
|
|
|
所以它属于:
|
|
- **最小可用支付合约**
|
|
- 不是“全功能强防护合约”
|
|
|
|
## 5. 当前静态审计结论
|
|
|
|
已提供脚本:
|
|
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
|
|
|
命令:
|
|
|
|
```bash
|
|
python scripts/check_payment_contract_security.py
|
|
```
|
|
|
|
输出会检查这些项目:
|
|
|
|
- 是否有 `onlyOwner`
|
|
- `setTreasury` / `setTokenAllowed` 是否受 owner 保护
|
|
- constructor / setter 是否检查零地址
|
|
- 是否校验 allowlist
|
|
- 是否校验 `amount > 0`
|
|
- 是否校验重复订单
|
|
- 是否在 `transferFrom` 前写入 `paidOrder`
|
|
- 是否有 pause 开关
|
|
- 是否使用 SafeERC20
|
|
- 是否在链上绑定套餐价格
|
|
|
|
## 6. 当前主要剩余风险
|
|
|
|
1. 单地址 owner
|
|
- 建议把 `owner` 迁移到多签钱包
|
|
|
|
2. 无暂停开关
|
|
- 发现紧急问题时,无法直接暂停 `pay`
|
|
|
|
3. 金额校验主要在链下
|
|
- 当前 `planId / amount / token` 绑定主要靠后端 intent 和确认逻辑
|
|
|
|
4. ERC20 兼容性假设
|
|
- 当前使用 `IERC20.transferFrom`
|
|
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
|
|
|
## 7. 推荐操作
|
|
|
|
### 每次支付配置变更后
|
|
|
|
执行:
|
|
|
|
```bash
|
|
python scripts/check_payment_contract_security.py
|
|
python scripts/replay_payment_events.py --from-block <from> --to-block <to>
|
|
```
|
|
|
|
### 线上巡检
|
|
|
|
执行:
|
|
|
|
```bash
|
|
curl http://127.0.0.1:8000/api/payments/runtime
|
|
```
|
|
|
|
重点看:
|
|
|
|
- `rpc.active_rpc_url`
|
|
- `rpc.configured_rpc_count`
|
|
- `event_loop_state.last_scanned_block`
|
|
- `recent_audit_events`
|
|
|
|
如果你在 `/ops` 或脚本里看到:
|
|
|
|
- `receiver_mismatch`
|
|
|
|
其含义通常不是“缓存没刷新”,而是:
|
|
|
|
- 用户这笔交易的 `to` 地址不是当前生产收款合约
|
|
- 常见原因是旧页面、旧 deployment、旧钱包会话,或历史收款地址仍被命中
|
|
|
|
此时应优先做:
|
|
|
|
1. 确认链上真实 `to` 地址
|
|
2. 确认当前 `/api/payments/config` 返回的 `receiver_contract`
|
|
3. 如确已收款,再走人工恢复或补开订阅
|
|
|
|
### 按邮箱恢复最近支付
|
|
|
|
已提供脚本:
|
|
|
|
- [reconcile_subscription_by_email.py](/E:/web/PolyWeather/scripts/reconcile_subscription_by_email.py)
|
|
|
|
命令:
|
|
|
|
```bash
|
|
docker compose exec polyweather_web python scripts/reconcile_subscription_by_email.py --email user@example.com
|
|
```
|
|
|
|
适用场景:
|
|
|
|
- 用户声称已付费但未开通
|
|
- 需要快速确认最近一笔 intent 是否能自动恢复
|
|
|
|
## 8. 下一版合约建议
|
|
|
|
如果后续升级合约,优先级建议:
|
|
|
|
1. `Ownable` -> 多签 owner
|
|
2. `SafeERC20`
|
|
3. `Pausable`
|
|
4. 链上 plan/amount/token 绑定
|
|
5. 必要时增加 rescue/sweep 能力
|