21 Commits

Author SHA1 Message Date
Ginta 7d0217b618 tron ws 2026-04-15 21:21:21 +08:00
line-6000 097c716714 Merge pull request #33 from GMwalletApp/dev-payment
- Enable polygon,plasma supports
2026-04-15 18:43:46 +08:00
line-6000 f7c5f678c4 - Enable polygon,plasma supports
- Fix network select
- Fix store evm address logic
2026-04-15 18:36:50 +08:00
Astra3456 3e88d7a40c Enhance README with links to supported systems
Updated supported systems with links and improved clarity.
2026-04-15 05:16:44 +08:00
Astra3456 18dcb96822 Revise Telegram community joining instructions
Updated the description for joining the Telegram community.
2026-04-15 05:01:54 +08:00
Astra3456 4750d63db3 Update README.md 2026-04-15 04:57:14 +08:00
Astra3456 7af7306729 Update README.md 2026-04-15 04:55:24 +08:00
Astra3456 ff16db907a Update README.md 2026-04-15 04:50:49 +08:00
Astra3456 96c262a7dd Update README.md
Since version 0.0.5, EPUSDT has been upgraded to a multi-chain payment solution, not only for TRC20-USDT but also for other tokens.
2026-04-15 04:50:04 +08:00
Astra3456 8e96cc0058 Update README.md
just Readme update,Introduce the new features and add the official website
2026-04-15 04:41:37 +08:00
Ginta 9c003fbead feat: get support chain api 2026-04-15 03:18:38 +08:00
Ginta aff1055743 feat: get support chain api 2026-04-15 02:53:52 +08:00
Ginta f6a8fb47df Merge pull request #31 from GMwalletApp/dev-payment
Dev payment
2026-04-15 02:18:25 +08:00
Ginta d46bc7793e feat: get support chain api 2026-04-15 01:44:38 +08:00
Ginta 9d01443f3e Merge pull request #27 from jiamovo/fix/epay-submit-form-compat
support epay submit form params
2026-04-15 00:03:23 +08:00
Ginta 70f8ed4ea0 Merge pull request #25 from mar-heaven/dev-payment
test: bsc
2026-04-14 21:01:30 +08:00
jiamovo 8cd816c35e support epay submit form params 2026-04-14 20:58:53 +08:00
Ginta 2f6cd34610 feat: get support chain api 2026-04-14 19:03:39 +08:00
Ginta 1e2282ba2e feat: get support chain api 2026-04-14 18:57:38 +08:00
line-6000 786c5e8175 update router for support epay GET and POST 2026-04-14 18:43:20 +08:00
Ginta 5e68bf8309 test: bsc pol plasma 2026-04-13 07:50:07 +08:00
23 changed files with 1737 additions and 345 deletions
+151 -55
View File
@@ -1,73 +1,169 @@
## Epusdt (Easy Payment Usdt)
# Epusdt Easy Payment USDT
<p align="center">
<img src="wiki/img/usdtlogo.png">
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p>
<p align="center">
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
</p>
## 项目简介
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql``redis`
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
<p align="center">
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
</p>
## 项目特点
- 支持私有化部署,无需担心钱包被篡改和吞单😁
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
- 多钱包地址轮询,提高订单并发率
- 异步队列响应,优雅及高性能
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
- 支持`http api`,其他系统亦可接入
- `Telegram`机器人接入,便捷使用和支付消息快速通知
<p align="center">
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
<a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
</p>
---
## 🌍 What is Epusdt?
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
### 🔗 已支持网络与代币
| 网络 | 代币 |
|------|------|
| **TRC20** (Tron) | USDT、TRX|
| **ERC20** (Ethereum) | USDT、USDC、ETH |
| **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
---
## 🔌 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
| 领域 | 已支持系统 |
|------|-----------|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
| **更多** | 简易HTTP API 10分钟内接入 |
👉 查看更多集成列表与插件:[plugins/](plugins/)
---
## ✨ 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
- **异步队列** — 高性能消息回调,优雅处理高并发场景
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
- **Telegram Bot** — 实时支付通知,快捷管理与监控
---
## 📖 文档与教程
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
快速入门:
| 教程 | 说明 |
|------|------|
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
---
## 🏗️ 项目结构
## 项目结构
```
Epusdt
├── plugins ---> (已集成的插件库,例如dujiaoka)
├── src ---> (项目核心目录)
├── sdk ---> (接入SDK)
├── sql ---> (安装sql文件或更新sql文件)
└── wiki ---> (知识库)
├── plugins/ → 已集成的系统插件(独角数卡等)
├── src/ → 项目核心代码
├── sdk/ → 接入 SDK
├── sql/ → 数据库安装 / 升级脚本
└── wiki/ → 文档与知识库
```
## 教程:
- docker 运行教程 [docker-RUN](wiki/docker-RUN.md)
- API 文档( 自定义汇率接口 ) [API](wiki/LEGACY_API_MIGRATION.md)
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
---
## 已适配系统插件
- 独角数卡[插件地址](plugins/dujiaoka)
## 🔧 实现原理
## 加入交流/意见反馈
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
## 设计实现
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
监听钱包地址`usdt`代币入账事件,通过`金额差异``时效性`来判定交易归属信息,
可参考下方`流程图`
```
简单的原理
1.客户需支付20.05usdt
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
3.发起支付的时候,我们可以判定钱包address_120.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功
工作流程
1. 客户发起支付,需支付 20.05 USDT
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
3.address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
```
### 流程图:
![Implementation principle](wiki/img/implementation_principle.jpg)
![Epusdt 支付流程图](wiki/img/implementation_principle.jpg)
## 声明
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
---
## 💬 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
| 渠道 | 链接 |
|------|------|
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
---
## Star History
<a href="https://www.star-history.com/?type=date&repos=gmwalletapp%2Fepusdt">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
</picture>
</a>
---
## 📜 开源协议
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
---
## ⚠️ 免责声明
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
---
<p align="center">
<sub>
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
</sub>
</p>
@@ -0,0 +1,147 @@
package comm
import (
"sort"
"strconv"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
type addSupportedAssetRequest struct {
Network string `json:"network" validate:"required"`
Token string `json:"token" validate:"required"`
Status int64 `json:"status" validate:"required|in:1,2"`
}
type updateSupportedAssetRequest struct {
Network string `json:"network" validate:"required"`
Token string `json:"token" validate:"required"`
Status int64 `json:"status" validate:"required|in:1,2"`
}
// GetSupportedAssets 对外公开可用链与 token 列表(无需鉴权,仅返回已启用项)。
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
list, err := data.ListEnabledSupportedAssets()
if err != nil {
return c.FailJson(ctx, err)
}
wallets, err := data.GetAvailableWalletAddress()
if err != nil {
return c.FailJson(ctx, err)
}
networkSet := make(map[string]struct{})
for _, w := range wallets {
networkSet[w.Network] = struct{}{}
}
grouped := make(map[string][]string)
for _, item := range list {
if _, ok := networkSet[item.Network]; !ok {
continue
}
grouped[item.Network] = append(grouped[item.Network], item.Token)
}
networks := make([]string, 0, len(grouped))
for network := range grouped {
networks = append(networks, network)
}
sort.Strings(networks)
supports := make([]response.NetworkTokenSupport, 0, len(networks))
for _, network := range networks {
tokens := grouped[network]
sort.Strings(tokens)
supports = append(supports, response.NetworkTokenSupport{
Network: network,
Tokens: tokens,
})
}
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
}
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
network := ctx.QueryParam("network")
list, err := data.ListSupportedAssets(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, list)
}
// GetSupportedAsset 查询单条支持项(无需鉴权)。
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
asset, err := data.GetSupportedAssetByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if asset.ID <= 0 {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
return c.SucJson(ctx, asset)
}
// AddSupportedAsset 新增支持项(鉴权)。
func (c *BaseCommController) AddSupportedAsset(ctx echo.Context) error {
req := new(addSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.AddSupportedAsset(req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// UpdateSupportedAsset 修改支持项(鉴权)。
func (c *BaseCommController) UpdateSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req := new(updateSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.UpdateSupportedAsset(id, req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// DeleteSupportedAsset 删除支持项(鉴权)。
func (c *BaseCommController) DeleteSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
asset, err := data.GetSupportedAssetByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if asset.ID <= 0 {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
if err := data.DeleteSupportedAssetByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+34
View File
@@ -22,5 +22,39 @@ func MdbTableInit() {
// panic(err)
return
}
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
return
}
seedSupportedAssets()
})
}
func seedSupportedAssets() {
var cnt int64
if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil {
color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err)
return
}
if cnt > 0 {
return
}
defaults := []mdb.SupportedAsset{
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
}
if err := Mdb.Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err)
}
}
+123
View File
@@ -0,0 +1,123 @@
package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
)
func normalizeNetworkToken(network, token string) (string, string) {
return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token))
}
func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 {
return nil, constant.SupportedAssetAlreadyExists
}
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
if err != nil {
return nil, err
}
if deleted.ID > 0 && deleted.DeletedAt.Valid {
deleted.Status = status
deleted.DeletedAt.Valid = false
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
return nil, err
}
return deleted, nil
}
asset := &mdb.SupportedAsset{
Network: network,
Token: token,
Status: status,
}
err = dao.Mdb.Create(asset).Error
return asset, err
}
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Unscoped().Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) {
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error
return asset, err
}
func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset, err := GetSupportedAssetByID(id)
if err != nil {
return nil, err
}
if asset.ID <= 0 {
return nil, constant.SupportedAssetNotFound
}
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 && exist.ID != id {
return nil, constant.SupportedAssetAlreadyExists
}
asset.Network = network
asset.Token = token
asset.Status = status
if err := dao.Mdb.Save(asset).Error; err != nil {
return nil, err
}
return asset, nil
}
func DeleteSupportedAssetByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error
}
func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) {
network = strings.ToLower(strings.TrimSpace(network))
var list []mdb.SupportedAsset
tx := dao.Mdb.Model(&mdb.SupportedAsset{})
if network != "" {
tx = tx.Where("network = ?", network)
}
err := tx.Order("network asc, token asc").Find(&list).Error
return list, err
}
func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) {
var list []mdb.SupportedAsset
err := dao.Mdb.Model(&mdb.SupportedAsset{}).
Where("status = ?", mdb.TokenStatusEnable).
Order("network asc, token asc").
Find(&list).Error
return list, err
}
+15
View File
@@ -13,10 +13,25 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
}
// isEVMNetwork 判断是否是 EVM 网络
func isEVMNetwork(network string) bool {
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
}
return false
}
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
// evm 网络地址统一小写,tron 和 solana 保持原样
if isEVMNetwork(network) {
address = strings.ToLower(address)
}
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil {
return nil, err
+12
View File
@@ -0,0 +1,12 @@
package mdb
type SupportedAsset struct {
Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"`
Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel
}
func (s *SupportedAsset) TableName() string {
return "supported_asset"
}
+3
View File
@@ -9,6 +9,9 @@ const (
NetworkTron = "tron"
NetworkSolana = "solana"
NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
)
type WalletAddress struct {
+10
View File
@@ -0,0 +1,10 @@
package response
type NetworkTokenSupport struct {
Network string `json:"network"`
Tokens []string `json:"tokens"`
}
type SupportedAssetsResponse struct {
Supports []NetworkTokenSupport `json:"supports"`
}
+142 -24
View File
@@ -258,66 +258,104 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
}
}
func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
func evmChainLogLabel(chainNetwork string) string {
switch chainNetwork {
case mdb.NetworkEthereum:
return "ETH"
case mdb.NetworkBsc:
return "BSC"
case mdb.NetworkPolygon:
return "POLYGON"
case mdb.NetworkPlasma:
return "PLASMA"
default:
return "EVM"
}
}
// TryProcessEvmERC20Transfer 处理各 EVM 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[ETH-WS] TryProcessEthereumERC20Transfer panic: %v", err)
log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
}
}()
usdt := common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdc := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
var tokenSym string
switch contract {
case usdt:
tokenSym = "USDT"
case usdc:
tokenSym = "USDC"
var usdt, usdc common.Address
var polygonUsdcE common.Address
switch chainNetwork {
case mdb.NetworkEthereum:
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
case mdb.NetworkBsc:
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
case mdb.NetworkPolygon:
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
case mdb.NetworkPlasma:
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
default:
log.Sugar.Warnf("[ETH-WS] skip unsupported contract %s", contract.Hex())
return
}
var tokenSym string
switch {
case contract == usdt:
tokenSym = "USDT"
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
tokenSym = "USDC"
default:
net := evmChainLogLabel(chainNetwork)
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
return
}
net := evmChainLogLabel(chainNetwork)
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[ETH-%s][%s] skip non-positive or nil amount", tokenSym, walletAddr)
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[ETH-%s][%s] skip non-positive amount %.2f", tokenSym, walletAddr, amount)
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, walletAddr, tokenSym, amount)
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(chainNetwork, walletAddr, tokenSym, amount)
if err != nil {
log.Sugar.Warnf("[ETH-%s][%s] lock lookup: %v", tokenSym, walletAddr, err)
log.Sugar.Warnf("[%s-%s][%s] lock lookup: %v", net, tokenSym, walletAddr, err)
return
}
if tradeID == "" {
log.Sugar.Warnf("[ETH-%s][%s] skip unmatched tx hash=%s amount=%.2f", tokenSym, walletAddr, txHash, amount)
log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
return
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Warnf("[ETH-%s][%s] load order: %v", tokenSym, walletAddr, err)
log.Sugar.Warnf("[%s-%s][%s] load order: %v", net, tokenSym, walletAddr, err)
return
}
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkEthereum {
log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s network=%q", tokenSym, walletAddr, tradeID, order.Network)
if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
return
}
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, walletAddr, tradeID, order.Token)
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
Token: tokenSym,
Network: mdb.NetworkEthereum,
Network: chainNetwork,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
@@ -325,15 +363,89 @@ func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Addr
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[ETH-%s][%s] skip resolved trade_id=%s hash=%s err=%v", tokenSym, walletAddr, tradeID, txHash, err)
log.Sugar.Infof("[%s-%s][%s] skip resolved trade_id=%s hash=%s err=%v", net, tokenSym, walletAddr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[ETH-%s][%s] OrderProcessing: %v", tokenSym, walletAddr, err)
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[ETH-%s][%s] payment processed trade_id=%s hash=%s", tokenSym, walletAddr, tradeID, txHash)
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
}
// TryProcessTronTRC20Transfer 处理 Tron 链上 TRC20-USDT 的 Transfer 入账。
func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRON-WS] TryProcessTronTRC20Transfer panic: %v", err)
}
}()
walletAddr := strings.TrimSpace(toAddr)
if walletAddr == "" {
return
}
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[TRON-USDT][%s] skip non-positive or nil amount", walletAddr)
return
}
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[TRON-USDT][%s] skip non-positive amount %.2f", walletAddr, amount)
return
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, walletAddr, "USDT", amount)
if err != nil {
log.Sugar.Warnf("[TRON-USDT][%s] lock lookup: %v", walletAddr, err)
return
}
if tradeID == "" {
log.Sugar.Debugf("[TRON-USDT][%s] skip unmatched tx hash=%s amount=%.2f", walletAddr, txHash, amount)
return
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Warnf("[TRON-USDT][%s] load order: %v", walletAddr, err)
return
}
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRON-USDT][%s] skip tx %s because block time %d is before order create time %d", walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTron {
log.Sugar.Warnf("[TRON-USDT][%s] skip trade_id=%s network=%q", walletAddr, tradeID, order.Network)
return
}
if strings.ToUpper(strings.TrimSpace(order.Token)) != "USDT" {
log.Sugar.Warnf("[TRON-USDT][%s] skip trade_id=%s token mismatch order=%s", walletAddr, tradeID, order.Token)
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
Token: "USDT",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRON-USDT][%s] skip resolved trade_id=%s hash=%s err=%v", walletAddr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[TRON-USDT][%s] OrderProcessing: %v", walletAddr, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRON-USDT][%s] payment processed trade_id=%s hash=%s", walletAddr, tradeID, txHash)
}
func sendPaymentNotification(order *mdb.Orders) {
@@ -372,6 +484,12 @@ func networkDisplay(n string) string {
return "Solana"
case mdb.NetworkEthereum:
return "Ethereum"
case mdb.NetworkBsc:
return "BSC"
case mdb.NetworkPolygon:
return "Polygon"
case mdb.NetworkPlasma:
return "Plasma"
default:
if n == "" {
return "Tron"
+55 -17
View File
@@ -64,6 +64,12 @@ func RegisterRoute(e *echo.Echo) {
// gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken())
// wallet management routes
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
@@ -75,25 +81,51 @@ func RegisterRoute(e *echo.Echo) {
// epay v1 routes
epayV1 := paymentRoute.Group("/epay/v1")
epayV1.GET("/order/create-transaction/submit.php", func(ctx echo.Context) error {
money := ctx.QueryParam("money")
name := ctx.QueryParam("name")
notifyURL := ctx.QueryParam("notify_url")
outTradeNo := ctx.QueryParam("out_trade_no")
returnURL := ctx.QueryParam("return_url")
signstr := ctx.QueryParam("sign")
// signType := ctx.QueryParam("sign_type")
m := map[string]interface{}{
"money": money,
"name": name,
"notify_url": notifyURL,
"out_trade_no": outTradeNo,
"pid": config.GetEpayPid(), // 注意:验签时需要包含 pid 参数
"return_url": returnURL,
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
params := make(map[string]interface{})
copyParams := func(values map[string][]string) {
for k, v := range values {
if len(v) == 0 {
continue
}
params[k] = v[0]
}
}
checkSignature, err := sign.Get(m, config.GetApiAuthToken())
copyParams(ctx.QueryParams())
formParams, err := ctx.FormParams()
if err != nil && ctx.Request().Method == http.MethodPost {
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
}
if err == nil {
copyParams(formParams)
}
getString := func(m map[string]interface{}, key string) string {
v, ok := m[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
signstr := getString(params, "sign")
if signstr == "" {
return constant.SignatureErr
}
delete(params, "sign")
delete(params, "sign_type")
// we need to add pid to params for signature verification
params["pid"] = config.GetEpayPid()
checkSignature, err := sign.Get(params, config.GetApiAuthToken())
if err != nil {
return constant.SignatureErr
}
@@ -101,6 +133,12 @@ func RegisterRoute(e *echo.Echo) {
return constant.SignatureErr
}
money := getString(params, "money")
name := getString(params, "name")
notifyURL := getString(params, "notify_url")
outTradeNo := getString(params, "out_trade_no")
returnURL := getString(params, "return_url")
amountFloat, err := strconv.ParseFloat(money, 64)
if err != nil {
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
+202 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
@@ -28,6 +29,7 @@ func setupTestEnv(t *testing.T) *echo.Echo {
viper.Reset()
viper.Set("db_type", "sqlite")
viper.Set("api_auth_token", testAPIToken)
viper.Set("epay_pid", 1)
viper.Set("app_uri", "http://localhost:8080")
viper.Set("order_expiration_time", 10)
viper.Set("api_rate_url", "")
@@ -52,11 +54,30 @@ func setupTestEnv(t *testing.T) *echo.Echo {
}
// ensure tables exist (MdbTableInit uses sync.Once, so migrate directly)
dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{})
dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}, &mdb.SupportedAsset{})
// seed wallet addresses
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable})
// seed supported assets if empty
var supportCnt int64
dao.Mdb.Model(&mdb.SupportedAsset{}).Count(&supportCnt)
if supportCnt == 0 {
dao.Mdb.Create(&[]mdb.SupportedAsset{
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
})
}
e := echo.New()
RegisterRoute(e)
@@ -78,6 +99,28 @@ func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.Re
return rec
}
func signEpayValues(values url.Values) url.Values {
signParams := make(map[string]interface{})
for key, items := range values {
if key == "sign" || key == "sign_type" || len(items) == 0 {
continue
}
signParams[key] = items[0]
}
sig, _ := sign.Get(signParams, testAPIToken)
values.Set("sign", sig)
values.Set("sign_type", "MD5")
return values
}
func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network.
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
e := setupTestEnv(t)
@@ -207,6 +250,115 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac
return resp
}
func TestGetSupportedAssetsPublic(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("expected status_code=200, got %v", resp["status_code"])
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected object data, got %T", resp["data"])
}
supports, ok := data["supports"].([]interface{})
if !ok {
t.Fatalf("expected supports array, got %T", data["supports"])
}
if len(supports) < 2 {
t.Fatalf("expected >= 2 network supports, got %d", len(supports))
}
seen := map[string]bool{}
for _, item := range supports {
row := item.(map[string]interface{})
network := row["network"].(string)
seen[network] = true
}
for _, n := range []string{"tron", "solana"} {
if !seen[n] {
t.Fatalf("missing network support: %s", n)
}
}
}
func TestSupportedAssetCRUD(t *testing.T) {
e := setupTestEnv(t)
// public query list (no auth)
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/records", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public list failed: %v", resp)
}
// create requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arb",
"token": "usdt",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add supported asset failed: %v", resp)
}
created := resp["data"].(map[string]interface{})
assetID := fmt.Sprintf("%.0f", created["id"].(float64))
// get by id is public
req = httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/"+assetID, nil)
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public get by id failed: %v", resp)
}
// update requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/update", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("update supported asset failed: %v", resp)
}
updated := resp["data"].(map[string]interface{})
if updated["network"] != "arbitrum" || updated["token"] != "USDC" {
t.Fatalf("unexpected updated data: %v", updated)
}
// delete requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/delete", nil)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("delete supported asset failed: %v", resp)
}
// recreate after delete should restore soft-deleted row, not unique-conflict
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("recreate after delete failed: %v", resp)
}
}
// TestWalletAddAndList tests adding wallets via API and listing them.
func TestWalletAddAndList(t *testing.T) {
e := setupTestEnv(t)
@@ -401,3 +553,52 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
t.Errorf("expected SolTestAddress001, got %v", data["receive_address"])
}
}
func TestEpaySubmitPhpGetCompatible(t *testing.T) {
e := setupTestEnv(t)
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-get-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-get-001"},
"notify_url": {"http://localhost/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
}
}
func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
e := setupTestEnv(t)
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-post-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-post-001"},
"notify_url": {"http://localhost/notify"},
"return_url": {"http://localhost/return"},
"sitename": {"example-shop"},
})
rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
}
}
-1
View File
@@ -539,7 +539,6 @@
createdAt: "{{.CreatedAt}}",
is_selected: "{{.IsSelected}}"
};
var PAYMENT_OPTIONS = null;
</script>
<script defer src="/static/payment.js"></script>
</body>
+182 -132
View File
@@ -1,10 +1,11 @@
/* =============================================================
* GM Pay — payment.js
* 后端对接只需关注:
* 1. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址)
* 2. CONFIG.api.checkStatus — 轮询接口 URL
* 3. CONFIG.api.statusMap状态码映射
* 4. index.html 底部 <script> 中 ORDER / PAYMENT_OPTIONS 变量
* 1. CONFIG.api.supportedAssets — 获取支持的网络和币种
* 2. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址)
* 3. CONFIG.api.checkStatus 轮询接口 URL
* 4. CONFIG.api.statusMap — 状态码映射
* 5. index.html 底部 <script> 中 ORDER 变量
* ============================================================= */
'use strict';
@@ -23,6 +24,78 @@ const setHtml = (id, value) => {
return el;
};
// ── API 错误码映射 ────────────────────────────────────────────
const API_ERRORS = {
400: { en: 'System error', zh: '系统错误', 'zh-hk': '系統錯誤', ja: 'システムエラー', ko: '시스템 오류', ru: 'Системная ошибка' },
401: { en: 'Signature / authentication failed', zh: '签名/认证失败', 'zh-hk': '簽名/認證失敗', ja: '署名/認証に失敗しました', ko: '서명/인증 실패', ru: 'Ошибка подписи / аутентификации' },
10001: { en: 'Wallet address already exists', zh: '钱包地址已存在', 'zh-hk': '錢包地址已存在', ja: 'ウォレットアドレスが既に存在します', ko: '지갑 주소가 이미 존재합니다', ru: 'Адрес кошелька уже существует' },
10002: { en: 'Order already exists', zh: '订单已存在', 'zh-hk': '訂單已存在', ja: '注文が既に存在します', ko: '주문이 이미 존재합니다', ru: 'Заказ уже существует' },
10003: { en: 'No wallet address available', zh: '无可用钱包地址', 'zh-hk': '無可用錢包地址', ja: '利用可能なウォレットアドレスがありません', ko: '사용 가능한 지갑 주소 없음', ru: 'Нет доступного адреса кошелька' },
10004: { en: 'Invalid payment amount', zh: '无效支付金额', 'zh-hk': '無效支付金額', ja: '無効な支払い金額', ko: '유효하지 않은 결제 금액', ru: 'Недопустимая сумма платежа' },
10005: { en: 'No available amount channel', zh: '无可用金额通道', 'zh-hk': '無可用金額通道', ja: '利用可能な金額チャンネルがありません', ko: '사용 가능한 금액 채널 없음', ru: 'Нет доступного канала суммы' },
10006: { en: 'Exchange rate calculation failed', zh: '汇率计算失败', 'zh-hk': '匯率計算失敗', ja: '為替レート計算に失敗しました', ko: '환율 계산 실패', ru: 'Ошибка расчёта курса' },
10007: { en: 'Block transaction already processed', zh: '区块交易已处理', 'zh-hk': '區塊交易已處理', ja: 'ブロックトランザクションは処理済みです', ko: '블록 트랜잭션이 이미 처리됨', ru: 'Транзакция блока уже обработана' },
10008: { en: 'Order not found', zh: '订单不存在', 'zh-hk': '訂單不存在', ja: '注文が見つかりません', ko: '주문 없음', ru: 'Заказ не найден' },
10009: { en: 'Request parameter parsing failed', zh: '请求参数解析失败', 'zh-hk': '請求參數解析失敗', ja: 'リクエストパラメータの解析に失敗', ko: '요청 매개변수 파싱 실패', ru: 'Ошибка разбора параметров запроса' },
10010: { en: 'Order status has changed', zh: '订单状态已变更', 'zh-hk': '訂單狀態已變更', ja: '注文ステータスが変更されました', ko: '주문 상태가 변경됨', ru: 'Статус заказа изменился' },
10011: { en: 'Sub-order quantity exceeded', zh: '子订单数量超限', 'zh-hk': '子訂單數量超限', ja: 'サブ注文数が超過しました', ko: '하위 주문 수량 초과', ru: 'Превышено количество подзаказов' },
10012: { en: 'Cannot switch network for sub-orders', zh: '不能对子订单切换网络', 'zh-hk': '不能對子訂單切換網絡', ja: 'サブ注文のネットワーク切替不可', ko: '하위 주문에 네트워크 전환 불가', ru: 'Нельзя переключить сеть для подзаказов' },
10013: { en: 'Order is not in pending payment status', zh: '订单不是待支付状态', 'zh-hk': '訂單不是待支付狀態', ja: '注文は支払い待ち状態ではありません', ko: '주문이 결제 대기 상태가 아님', ru: 'Заказ не в статусе ожидания оплаты' },
};
/**
* 根据 API 错误码返回当前语言的提示文字;未匹配时返回 null
* @param {number|string|null|undefined} code
* @returns {string|null}
*/
function getApiErrorMsg(code) {
if (code == null) return null;
const map = API_ERRORS[Number(code)];
if (!map) return null;
return map[currentLang] ?? map.en;
}
/** API 业务异常(区别于网络异常),携带错误码 */
class ApiError extends Error {
constructor(code, message) { super(message); this.code = code; }
}
/** 错误码 → 终态面板映射;命中即跳转面板,不弹 toast */
const _TERMINAL_ERROR_CODES = {
10008: 'not-found', // 订单不存在
10010: 'expired', // 订单状态已变更
10013: 'not-found', // 订单不是待支付状态
};
/**
* 统一 API 请求:发送 → 解析 JSON → 校验业务码 → 返回 data
* 业务异常抛 ApiError,网络/超时异常原样冒泡
*/
async function apiFetch(url, opts = {}) {
const { timeout = 10000, ...fetchOpts } = opts;
fetchOpts.headers = { Accept: 'application/json', ...fetchOpts.headers };
if (!fetchOpts.signal) fetchOpts.signal = AbortSignal.timeout(timeout);
const res = await fetch(url, fetchOpts);
const resp = await res.json().catch(() => null);
if (!res.ok || (resp?.code != null && resp.code !== 200)) {
const code = resp?.code ? Number(resp.code) : res.status;
throw new ApiError(code, getApiErrorMsg(code) ?? resp?.msg ?? `HTTP ${res.status}`);
}
return resp?.data ?? resp;
}
/**
* 统一处理 API 异常:终态错误码 → 跳转面板;其余 → 弹 toast
* @returns {boolean} true = 终态,已跳转面板
*/
function handleApiError(err) {
const panel = (err instanceof ApiError) && _TERMINAL_ERROR_CODES[err.code];
if (panel) { _enterTerminalState(panel); return true; }
console.error('[API]', err);
showToast('⚠ ' + (err.message || t('network_timeout')));
return false;
}
// ── 全局配置(可按项目调整)─────────────────────────────────
const CONFIG = {
poll: {
@@ -35,6 +108,8 @@ const CONFIG = {
},
// ---- 后端接口 ----
api: {
// 获取支持的网络和币种
supportedAssets: () => '/payments/gmpay/v1/supported-assets',
// 切换网络接口:POST { trade_id, token, network },返回完整订单对象
selectMethod: () => '/pay/switch-network',
// 轮询接口:GET,返回 { data: { status: number } }
@@ -98,8 +173,7 @@ const _CHAIN_ALIASES = {
const IMAGE_PREFIX = {
chain: '/static/images/',
tokenTrigger: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
tokenMenu: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
token: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
};
function _resolveChain(name) {
@@ -132,7 +206,7 @@ function _networkTriggerHtml(network) {
/** 选择器触发器显示:Token */
function _tokenTriggerHtml(token) {
if (!token) return `<span class="text-sm font-semibold" style="color:var(--card-foreground)">--</span>`;
const src = `${IMAGE_PREFIX.tokenTrigger}${token.toLowerCase()}.png`;
const src = `${IMAGE_PREFIX.token}${token.toLowerCase()}.png`;
return `<span style="display:inline-flex;align-items:center;gap:7px">
<img src="${src}" width="18" height="18" style="width:18px;height:18px;border-radius:50%;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'" />
<span class="text-sm font-semibold" style="color:var(--card-foreground)">${token}</span>
@@ -164,8 +238,8 @@ function applyTheme(t) {
currentTheme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
$('icon-moon').style.display = t === 'light' ? 'block' : 'none';
$('icon-sun').style.display = t === 'dark' ? 'block' : 'none';
const moon = $('icon-moon'); if (moon) moon.style.display = t === 'light' ? 'block' : 'none';
const sun = $('icon-sun'); if (sun) sun.style.display = t === 'dark' ? 'block' : 'none';
}
function toggleTheme(e) {
@@ -385,10 +459,7 @@ function setLang(lang) {
o.classList.toggle('is-selected', o.dataset.lang === lang));
closeAllSelects();
// 重新渲染含翻译文案的动态行
if (ORDER?.tradeId) renderOrderId();
if (ORDER?.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
renderOrderCard();
}
@@ -538,8 +609,6 @@ function goBack() {
}
}
function syncStatusCardHeight() {} // 兼容旧调用,保留空桩
// ═══════════════════════════════════════════════════════════════
// SECTION 7 — 步骤进度条
@@ -564,22 +633,26 @@ function setStepBar(step) {
// SECTION 8 — Step 1 — 币种 & 网络选择
// ═══════════════════════════════════════════════════════════════
/**
* 从后端获取支持的网络和币种,转换为 PAYMENT_OPTIONS 格式
* @returns {Array|null} 成功返回选项数组,失败返回 null
*/
async function fetchSupportedAssets() {
try {
const data = await apiFetch(CONFIG.api.supportedAssets());
if (!data?.supports?.length) return null;
return data.supports.flatMap(s => s.tokens.map(token => ({ token, network: s.network })));
} catch (e) {
console.warn('[supportedAssets]', e);
return null;
}
}
let _step1Token = null;
let _step1Opt = null;
// 内置优先网络,按顺序匹配第一个可用选项
const _PREFERRED_NETWORKS = ['TRON', 'Solana'];
function initStep1() {
// 按内置优先顺序找到首个可用选项
_step1Opt = null;
for (const net of _PREFERRED_NETWORKS) {
_step1Opt = PAYMENT_OPTIONS.find(
o => (_resolveChain(o.network)?.label ?? o.network) === net
);
if (_step1Opt) break;
}
_step1Opt = _step1Opt ?? PAYMENT_OPTIONS[0];
_step1Opt = PAYMENT_OPTIONS[0];
_step1Token = _step1Opt.token;
_renderNetworkMenu();
_renderTokenMenu();
@@ -597,7 +670,7 @@ function _renderTokenMenu() {
const menu = $('dd-token-menu');
if (!menu) return;
menu.innerHTML = tokens.map(tok => {
const src = `${IMAGE_PREFIX.tokenMenu}${tok.toLowerCase()}.png`;
const src = `${IMAGE_PREFIX.token}${tok.toLowerCase()}.png`;
return `<div class="select-option menu-item${tok === _step1Token ? ' is-selected' : ''}"
onclick="step1SetToken('${tok}')"
style="display:flex;align-items:center;gap:8px">
@@ -643,8 +716,28 @@ function step1SetNetwork(networkName) {
closeAllSelects();
}
/** 用后端返回的订单字段更新 ORDER */
function _applyOrderData(data, opt) {
ORDER.tradeId = data?.trade_id ?? ORDER.tradeId;
if (data?.trade_id) {
const _url = new URL(window.location.href);
_url.pathname = _url.pathname.replace(/\/[^/]+$/, '/' + encodeURIComponent(data.trade_id));
history.replaceState(null, '', _url.toString());
renderOrderCard();
}
ORDER.token = data?.token ?? opt.token;
ORDER.network = data?.network ?? opt.network;
ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : ORDER.actualAmount;
ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount;
ORDER.currency = data?.currency ?? ORDER.currency;
ORDER.receiveAddress = data?.receive_address ?? ORDER.receiveAddress;
if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time);
if (data?.created_at != null) ORDER.createdAt = String(data.created_at);
if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url;
}
async function confirmStep1() {
const opt = _step1Opt || PAYMENT_OPTIONS[0];
const opt = _step1Opt;
const btn = $('btn-confirm-step1');
const span = btn?.querySelector('span') ?? btn;
const origText = span?.textContent;
@@ -653,45 +746,22 @@ async function confirmStep1() {
if (btn) { btn.disabled = true; if (span) span.textContent = '…'; }
try {
const res = await fetch(CONFIG.api.selectMethod(), {
const data = await apiFetch(CONFIG.api.selectMethod(), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trade_id: ORDER.tradeId,
token: opt.token.toLowerCase(),
network: opt.network.toLowerCase(),
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const resp = await res.json();
const data = resp?.data ?? resp;
// 2. 用后端返回的完整订单字段更新 ORDER,本地选项作为兜底
ORDER.tradeId = data?.trade_id ?? ORDER.tradeId;
// 同步新 trade_id 到 URL(路径末段),轮询将自动使用最新值
if (data?.trade_id) {
const _url = new URL(window.location.href);
_url.pathname = _url.pathname.replace(/\/[^/]+$/, '/' + encodeURIComponent(data.trade_id));
history.replaceState(null, '', _url.toString());
renderOrderId();
}
ORDER.token = data?.token ?? opt.token;
ORDER.network = data?.network ?? opt.network;
ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : opt.actualAmount;
ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount;
ORDER.currency = data?.currency ?? ORDER.currency;
ORDER.receiveAddress = data?.receive_address ?? opt.receiveAddress;
if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time);
if (data?.created_at != null) ORDER.createdAt = String(data.created_at);
if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url;
_applyOrderData(data, opt);
setStepBar(2);
slideTo('payment');
initOrder();
} catch (err) {
console.error('[confirmStep1]', err);
showToast('⚠ ' + (err.message || 'Request failed'));
handleApiError(err);
} finally {
if (btn) { btn.disabled = false; if (span && origText) span.textContent = origText; }
}
@@ -702,10 +772,6 @@ async function confirmStep1() {
// SECTION 9 — 订单渲染 & 倒计时
// ═══════════════════════════════════════════════════════════════
function truncateAddr(addr) {
return addr && addr.length > 10 ? `${addr.slice(0, 4)}...${addr.slice(-6)}` : addr;
}
function formatAddr(addr) {
if (!addr || addr.length <= 10) return addr;
return `<span style="font-weight:800;color:var(--primary)">${addr.slice(0, 4)}</span>`
@@ -720,10 +786,14 @@ function renderRow(id, label, value) {
<td style="color:var(--card-foreground);font-weight:500;word-break:break-all">${value}</td>`;
}
function renderOrderId() {
/** 填充订单信息卡片(订单号 + 法币金额行) */
function renderOrderCard() {
if (ORDER?.tradeId && !ORDER.tradeId.startsWith('{{')) {
renderRow('display-order-id', t('order_id'), ORDER.tradeId);
}
if (ORDER?.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
}
function initOrder() {
@@ -732,14 +802,11 @@ function initOrder() {
setText('display-amount', `${ORDER.actualAmount} ${ORDER.token}`);
setHtml('field-address', formatAddr(ORDER.receiveAddress));
setHtml('display-network', networkTag(ORDER.network));
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
renderOrderId();
renderOrderCard();
const qrcodeEl = $('qrcode');
if (qrcodeEl) {
qrcodeEl.innerHTML = ''; // 防止重复调用时叠加渲染
new QRCode(qrcodeEl, {
text: ORDER.receiveAddress,
width: 176,
@@ -772,6 +839,8 @@ function _ratioColor(ratio) {
}
function initCountdown() {
// 清除上一次的倒计时(initOrder 可能被重复调用)
if (_countdownInterval) { clearInterval(_countdownInterval); _countdownInterval = null; }
const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
_expiresAt = parseTime(ORDER.expirationTime);
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
@@ -797,10 +866,10 @@ function _tickCountdown() {
const color = _ratioColor(_totalSeconds > 0 ? remaining / _totalSeconds : 0);
const offset = CIRCUMFERENCE * (1 - (_totalSeconds > 0 ? remaining / _totalSeconds : 0));
$('countdown').textContent = timeStr;
$('countdown').style.color = color;
$('ring').style.stroke = color;
$('ring').style.strokeDashoffset = offset;
const cdEl = $('countdown');
const ringEl = $('ring');
if (cdEl) { cdEl.textContent = timeStr; cdEl.style.color = color; }
if (ringEl) { ringEl.style.stroke = color; ringEl.style.strokeDashoffset = offset; }
const inlineEl = $('countdown-inline');
if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
@@ -818,28 +887,28 @@ let _pollTimer = null;
let _pollStopped = false;
let _pollErrors = 0;
function checkOrderStatus() {
async function checkOrderStatus() {
if (_pollStopped || !ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) return;
fetch(CONFIG.api.checkStatus(ORDER.tradeId), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CONFIG.poll.timeout),
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(({ data }) => {
try {
const data = await apiFetch(CONFIG.api.checkStatus(ORDER.tradeId), {
timeout: CONFIG.poll.timeout,
});
_pollErrors = 0;
const { paid, expired } = CONFIG.api.statusMap;
if (data?.status === paid) onPaymentSuccess();
else if (data?.status === expired) showExpired();
else _scheduleNextPoll();
} catch (err) {
if (err instanceof ApiError) {
_pollErrors = 0;
const { paid, expired } = CONFIG.api.statusMap;
if (data?.status === paid) onPaymentSuccess();
else if (data?.status === expired) showExpired();
else _scheduleNextPoll();
})
.catch(() => {
if (!handleApiError(err)) _scheduleNextPoll();
} else {
// 网络 / 超时异常:累计错误次数
if (++_pollErrors >= CONFIG.poll.maxErrors) showTimeout();
else _scheduleNextPoll();
});
}
}
}
function _scheduleNextPoll() {
@@ -851,47 +920,42 @@ function stopPolling() {
clearTimeout(_pollTimer);
}
function retryPolling() {
/** 重置轮询状态并立即发起一次请求 */
function _resumePolling() {
_pollErrors = 0;
_pollStopped = false;
hideStatePanel();
clearTimeout(_pollTimer);
checkOrderStatus();
}
function retryPolling() {
hideStatePanel();
_resumePolling();
}
// ═══════════════════════════════════════════════════════════════
// SECTION 11 — 支付状态处理
// ═══════════════════════════════════════════════════════════════
function onPaymentSuccess() {
/** 进入终态面板:停止轮询,可选清除倒计时 / 禁用转账按钮 */
function _enterTerminalState(panel, { clearTimer = true, disableBtn = true } = {}) {
stopPolling();
clearInterval(_countdownInterval);
showStatePanel('success');
if (clearTimer && _countdownInterval) { clearInterval(_countdownInterval); _countdownInterval = null; }
if (disableBtn) { const btn = $('btn-transferred'); if (btn) btn.disabled = true; }
showStatePanel(panel);
}
function onPaymentSuccess() {
_enterTerminalState('success');
if (ORDER?.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
setTimeout(() => { window.location.href = ORDER.redirectUrl; }, CONFIG.redirect.delay);
}
}
function showExpired() {
stopPolling();
clearInterval(_countdownInterval);
const btn = $('btn-transferred');
if (btn) btn.disabled = true;
showStatePanel('expired');
}
function showTimeout() {
stopPolling();
showStatePanel('timeout');
}
function showNotFound() {
stopPolling();
clearInterval(_countdownInterval);
const btn = $('btn-transferred');
if (btn) btn.disabled = true;
showStatePanel('not-found');
}
function showExpired() { _enterTerminalState('expired'); }
function showTimeout() { _enterTerminalState('timeout', { clearTimer: false, disableBtn: false }); }
function showNotFound() { _enterTerminalState('not-found'); }
// ═══════════════════════════════════════════════════════════════
@@ -905,10 +969,7 @@ function handleTransfer() {
if (!span) return;
span.textContent = t('verifying');
btn.disabled = true;
_pollErrors = 0;
_pollStopped = false;
clearTimeout(_pollTimer);
checkOrderStatus();
_resumePolling();
setTimeout(() => {
span.textContent = t('i_have_transferred');
btn.disabled = false;
@@ -924,7 +985,7 @@ function connectWallet() {
// SECTION 13 — 初始化入口
// ═══════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
setLang(detectLang());
if (!ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) {
@@ -933,23 +994,14 @@ document.addEventListener('DOMContentLoaded', () => {
}
// 在 Step 1 阶段就填充订单信息卡片
renderOrderCard();
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
setText('display-amount', `${ORDER.amount} ${ORDER.currency || ''}`);
}
renderOrderId();
// 确保 PAYMENT_OPTIONS 存在(服务端未注入时降级为内置默认选项)
if (typeof PAYMENT_OPTIONS === 'undefined' || !Array.isArray(PAYMENT_OPTIONS) || !PAYMENT_OPTIONS.length) {
window.PAYMENT_OPTIONS = [
{ token: 'USDT', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'TRX', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDT', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDC', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDT', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDC', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
];
}
// 从 API 获取支持的网络和币种
window.PAYMENT_OPTIONS = await fetchSupportedAssets();
if (!PAYMENT_OPTIONS?.length) return;
// isselect=true 时跳过 Step 1,直接进入支付面板
const _isSelect = ORDER.is_selected && ORDER.is_selected !== 'false' && !ORDER.is_selected.startsWith('{{');
@@ -968,5 +1020,3 @@ document.addEventListener('DOMContentLoaded', () => {
const walletBtn = $('btn-connect-wallet');
if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none';
});
+5 -8
View File
@@ -8,17 +8,14 @@ import (
func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
go StartEthereumWebSocketListener()
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
go StartTronTrc20WebSocketListener()
c := cron.New()
// trc20钱包监听
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
// solana钱包监听
_, err = c.AddJob("@every 5s", ListenSolJob{})
_, err := c.AddJob("@every 5s", ListenSolJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
return
+112
View File
@@ -0,0 +1,112 @@
package task
import (
"context"
"math/big"
"strings"
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
var (
// USDT / USDC 合约地址(BSC 主网)
bscUsdtContract = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
bscUsdcContract = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
)
type bscRecipientSnapshot struct {
addrs map[string]struct{}
}
var bscWatchedRecipients atomic.Pointer[bscRecipientSnapshot]
func StartBscWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
if err != nil {
log.Sugar.Errorf("[BSC-WS] Failed to get wallet addresses: %v", err)
return
}
storeBscRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
if err != nil {
log.Sugar.Warnf("[BSC-WS] refresh wallet addresses: %v", err)
continue
}
storeBscRecipientsFromWallets(w)
}
}()
wsURL := "wss://bsc.drpc.org"
query := ethereum.FilterQuery{
Addresses: []common.Address{
bscUsdtContract,
bscUsdcContract,
},
Topics: [][]common.Hash{},
}
runWsLogListener("[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
event := vLog.Topics[0].String()
if event != transferEventHash.String() {
return
}
amount := new(big.Int).SetBytes(vLog.Data)
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedBscRecipient(toAddr) {
return
}
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessEvmERC20Transfer(mdb.NetworkBsc, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
})
}
func storeBscRecipientsFromWallets(wallets []mdb.WalletAddress) int {
m := make(map[string]struct{})
for _, w := range wallets {
a := strings.TrimSpace(w.Address)
if !common.IsHexAddress(a) {
continue
}
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
}
bscWatchedRecipients.Store(&bscRecipientSnapshot{addrs: m})
return len(m)
}
func isWatchedBscRecipient(to common.Address) bool {
snap := bscWatchedRecipients.Load()
if snap == nil || len(snap.addrs) == 0 {
return false
}
_, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok
}
+29 -64
View File
@@ -2,7 +2,6 @@ package task
import (
"context"
"fmt"
"math/big"
"strings"
"sync/atomic"
@@ -38,7 +37,7 @@ var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
func StartEthereumWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil {
log.Sugar.Fatalf("Failed to get wallet addresses: %v", err)
log.Sugar.Errorf("Failed to get wallet addresses: %v", err)
return
}
StoreEthRecipientsFromWallets(wallets)
@@ -55,16 +54,6 @@ func StartEthereumWebSocketListener() {
}
}()
wsURL := "wss://ethereum.publicnode.com"
client, err := ethclient.Dial(wsURL)
if err != nil {
log.Sugar.Fatal("连接失败:", err)
}
// 创建日志通道
logsCh := make(chan types.Log)
// 订阅条件
query := ethereum.FilterQuery{
Addresses: []common.Address{
usdtContract,
@@ -73,59 +62,35 @@ func StartEthereumWebSocketListener() {
Topics: [][]common.Hash{},
}
// 订阅日志(核心)
sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh)
if err != nil {
log.Sugar.Fatal("订阅失败:", err)
}
fmt.Println("🚀 开始监听 USDT / USDC 收款...")
for {
select {
case err := <-sub.Err():
log.Sugar.Fatal("订阅错误:", err)
case vLog := <-logsCh:
// topics:
// [0] Transfer event
// [1] from
// [2] to
if len(vLog.Topics) < 3 {
continue
}
event := vLog.Topics[0].String()
if event != transferEventHash.String() {
continue
}
// value from data
amount := new(big.Int).SetBytes(vLog.Data)
if event != transferEventHash.String() {
continue
}
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedEthRecipient(toAddr) {
continue
}
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessEthereumERC20Transfer(vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
runWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
}
event := vLog.Topics[0].String()
if event != transferEventHash.String() {
return
}
amount := new(big.Int).SetBytes(vLog.Data)
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedEthRecipient(toAddr) {
return
}
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessEvmERC20Transfer(mdb.NetworkEthereum, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
})
}
func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int {
+81
View File
@@ -0,0 +1,81 @@
package task
import (
"context"
"time"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
func runWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
const (
minBackoff = 2 * time.Second
maxBackoff = 60 * time.Second
rejoinWait = 3 * time.Second
)
failWait := minBackoff
for {
client, err := ethclient.Dial(wsURL)
if err != nil {
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
failWait = nextBackoff(failWait, maxBackoff)
continue
}
logsCh := make(chan types.Log)
sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh)
if err != nil {
client.Close()
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
failWait = nextBackoff(failWait, maxBackoff)
continue
}
failWait = minBackoff
log.Sugar.Infof("%s connected, subscribed to USDT/USDC Transfer logs", logPrefix)
recvLoop(client, sub, logsCh, logPrefix, handleLog)
time.Sleep(rejoinWait)
}
}
func recvLoop(client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
defer func() {
sub.Unsubscribe()
client.Close()
}()
for {
select {
case err := <-sub.Err():
if err != nil {
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
} else {
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
}
return
case vLog, ok := <-logsCh:
if !ok {
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
return
}
handleLog(client, vLog)
}
}
}
func nextBackoff(cur, max time.Duration) time.Duration {
n := cur * 2
if n > max {
return max
}
return n
}
+108
View File
@@ -0,0 +1,108 @@
package task
import (
"context"
"math/big"
"strings"
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
var (
plasmaUsdt0Contract = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
)
type plasmaRecipientSnapshot struct {
addrs map[string]struct{}
}
var plasmaWatchedRecipients atomic.Pointer[plasmaRecipientSnapshot]
func StartPlasmaWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
if err != nil {
log.Sugar.Errorf("[PLASMA-WS] Failed to get wallet addresses: %v", err)
return
}
storePlasmaRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
if err != nil {
log.Sugar.Warnf("[PLASMA-WS] refresh wallet addresses: %v", err)
continue
}
storePlasmaRecipientsFromWallets(w)
}
}()
// 文档提供 https://rpc.plasma.to;常见实现同主机 wss
wsURL := "wss://rpc.plasma.to"
query := ethereum.FilterQuery{
Addresses: []common.Address{plasmaUsdt0Contract},
Topics: [][]common.Hash{},
}
runWsLogListener("[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
event := vLog.Topics[0].String()
if event != transferEventHash.String() {
return
}
amount := new(big.Int).SetBytes(vLog.Data)
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedPlasmaRecipient(toAddr) {
return
}
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessEvmERC20Transfer(mdb.NetworkPlasma, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
})
}
func storePlasmaRecipientsFromWallets(wallets []mdb.WalletAddress) int {
m := make(map[string]struct{})
for _, w := range wallets {
a := strings.TrimSpace(w.Address)
if !common.IsHexAddress(a) {
continue
}
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
}
plasmaWatchedRecipients.Store(&plasmaRecipientSnapshot{addrs: m})
return len(m)
}
func isWatchedPlasmaRecipient(to common.Address) bool {
snap := plasmaWatchedRecipients.Load()
if snap == nil || len(snap.addrs) == 0 {
return false
}
_, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok
}
+114
View File
@@ -0,0 +1,114 @@
package task
import (
"context"
"math/big"
"strings"
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
var (
// Polygon PoS 主网 USDT / USDC(原生)/ USDC.e(桥接)
polygonUsdtContract = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
polygonUsdcContract = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
polygonUsdcEContract = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
)
type polygonRecipientSnapshot struct {
addrs map[string]struct{}
}
var polygonWatchedRecipients atomic.Pointer[polygonRecipientSnapshot]
func StartPolygonWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
if err != nil {
log.Sugar.Errorf("[POLYGON-WS] Failed to get wallet addresses: %v", err)
return
}
storePolygonRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
if err != nil {
log.Sugar.Warnf("[POLYGON-WS] refresh wallet addresses: %v", err)
continue
}
storePolygonRecipientsFromWallets(w)
}
}()
wsURL := "wss://polygon-bor-rpc.publicnode.com"
query := ethereum.FilterQuery{
Addresses: []common.Address{
polygonUsdtContract,
polygonUsdcContract,
polygonUsdcEContract,
},
Topics: [][]common.Hash{},
}
runWsLogListener("[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
event := vLog.Topics[0].String()
if event != transferEventHash.String() {
return
}
amount := new(big.Int).SetBytes(vLog.Data)
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedPolygonRecipient(toAddr) {
return
}
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessEvmERC20Transfer(mdb.NetworkPolygon, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
})
}
func storePolygonRecipientsFromWallets(wallets []mdb.WalletAddress) int {
m := make(map[string]struct{})
for _, w := range wallets {
a := strings.TrimSpace(w.Address)
if !common.IsHexAddress(a) {
continue
}
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
}
polygonWatchedRecipients.Store(&polygonRecipientSnapshot{addrs: m})
return len(m)
}
func isWatchedPolygonRecipient(to common.Address) bool {
snap := polygonWatchedRecipients.Load()
if snap == nil || len(snap.addrs) == 0 {
return false
}
_, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok
}
+103 -26
View File
@@ -1,39 +1,116 @@
package task
import (
"sync"
"context"
"math/big"
"strings"
"sync/atomic"
"time"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
type ListenTrc20Job struct{}
const tronUsdtContractBase58 = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
var gListenTrc20JobLock sync.Mutex
// TronGrid 的 JSON-RPC 使用 20-byte hex 地址(去掉 Tron 主网 0x41 前缀)。
var tronUsdtTrc20Contract = common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return
}
if len(walletAddress) <= 0 {
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return
}
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup
for _, address := range walletAddress {
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
wg.Add(1)
go service.Trc20CallBack(address.Address, &wg)
}
wg.Wait()
log.Sugar.Debug("[ListenTrc20Job] Job completed")
type tronRecipientSnapshot struct {
addrs map[string]struct{}
}
var tronWatchedRecipients atomic.Pointer[tronRecipientSnapshot]
func StartTronTrc20WebSocketListener() {
log.Sugar.Infof("[TRON-WS] listening contract base58=%s hex=%s", tronUsdtContractBase58, tronUsdtTrc20Contract.Hex())
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[TRON-WS] Failed to get wallet addresses: %v", err)
return
}
storeTronRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Warnf("[TRON-WS] refresh wallet addresses: %v", err)
continue
}
storeTronRecipientsFromWallets(w)
}
}()
wsURL := "wss://api.trongrid.io/jsonrpc"
query := ethereum.FilterQuery{
Addresses: []common.Address{tronUsdtTrc20Contract},
Topics: [][]common.Hash{},
}
runWsLogListener("[TRON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
if vLog.Topics[0].String() != transferEventHash.String() {
return
}
toAddr := tronHexToBase58(common.HexToAddress(vLog.Topics[2].Hex()))
//if !isWatchedTronRecipient(toAddr) {
// return
//}
amount := new(big.Int).SetBytes(vLog.Data)
if amount.Sign() <= 0 {
return
}
log.Sugar.Infof("[TRON-WS] Detected TRC20 transfer to %s amount=%s tx=%s", toAddr, amount.String(), vLog.TxHash.Hex())
var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil {
log.Sugar.Warnf("[TRON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli()
} else {
blockTsMs = int64(header.Time) * 1000
}
service.TryProcessTronTRC20Transfer(toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
})
}
func storeTronRecipientsFromWallets(wallets []mdb.WalletAddress) int {
m := make(map[string]struct{})
for _, w := range wallets {
addr := strings.TrimSpace(w.Address)
if addr == "" {
continue
}
m[addr] = struct{}{}
}
tronWatchedRecipients.Store(&tronRecipientSnapshot{addrs: m})
return len(m)
}
func isWatchedTronRecipient(to string) bool {
snap := tronWatchedRecipients.Load()
if snap == nil || len(snap.addrs) == 0 {
return false
}
_, ok := snap.addrs[strings.TrimSpace(to)]
return ok
}
func tronHexToBase58(addr common.Address) string {
raw := append([]byte{tron.PrefixMainnet}, addr.Bytes()...)
return tron.EncodeCheck(raw)
}
+82 -17
View File
@@ -2,6 +2,7 @@ package telegram
import (
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -14,12 +15,14 @@ import (
)
const (
ReplayAddWallet = "请发给我 TRON(T 开头)、以太坊主网(0x 开头)或 Solana 收款地址"
ReplaySelectNetwork = "请选择要添加的钱包网络"
ReplayAddWallet = "请发送 %s 网络收款地址"
pendingWalletAddressTTL = 5 * time.Minute
)
type pendingWalletAddressState struct {
RequestedAt time.Time
Network string
}
var pendingWalletAddressUsers sync.Map
@@ -36,7 +39,7 @@ func OnTextMessageHandle(c tb.Context) error {
senderID = sender.ID
}
isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet
isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ")
isPendingFlow := isWalletAddressPending(senderID)
if !isReplyFlow && !isPendingFlow {
return nil
@@ -47,24 +50,25 @@ func OnTextMessageHandle(c tb.Context) error {
}
msgText := strings.TrimSpace(msg.Text)
var err error
switch {
case isValidTronAddress(msgText):
_, err = data.AddWalletAddress(msgText)
case isValidEthereumAddress(msgText):
_, err = data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, strings.ToLower(msgText))
case isValidSolanaAddress(msgText):
_, err = data.AddWalletAddressWithNetwork(mdb.NetworkSolana, msgText)
default:
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 TRON、以太坊或 Solana 地址", msgText))
state, _ := getPendingWalletAddressState(senderID)
if state.Network == "" {
_ = c.Send("请先选择网络,再发送地址。")
return nil
}
var err error
if !isValidAddressByNetwork(state.Network, msgText) {
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 %s 地址", msgText, strings.ToUpper(state.Network)))
return nil
}
storeAddress := normalizeWalletAddressByNetwork(state.Network, msgText)
_, err = data.AddWalletAddressWithNetwork(state.Network, storeAddress)
if err != nil {
return c.Send(err.Error())
}
pendingWalletAddressUsers.Delete(senderID)
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText))
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功%s", storeAddress, strings.ToUpper(state.Network)))
return WalletList(c)
}
@@ -96,11 +100,26 @@ func WalletList(c tb.Context) error {
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error {
if sender := c.Sender(); sender != nil {
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
chains, err := getEnabledSupportedNetworks()
if err != nil {
return c.Send("读取支持链失败:" + err.Error())
}
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
ForceReply: true,
if len(chains) == 0 {
return c.Send("当前没有可用链,请先在后台配置 supported-assets。")
}
rows := make([][]tb.InlineButton, 0, len(chains))
for _, network := range chains {
btn := tb.InlineButton{
Text: strings.ToUpper(network),
Unique: "SelectWalletNetwork",
Data: network,
}
bots.Handle(&btn, SelectWalletNetwork)
rows = append(rows, []tb.InlineButton{btn})
}
return c.EditOrSend(ReplaySelectNetwork, &tb.ReplyMarkup{
InlineKeyboard: rows,
})
})
btnList = append(btnList, []tb.InlineButton{addBtn})
@@ -110,6 +129,22 @@ func WalletList(c tb.Context) error {
})
}
func SelectWalletNetwork(c tb.Context) error {
network := strings.ToLower(strings.TrimSpace(c.Data()))
if network == "" {
return c.Send("请选择有效网络")
}
if sender := c.Sender(); sender != nil {
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{
RequestedAt: time.Now(),
Network: network,
})
}
return c.Send(fmt.Sprintf(ReplayAddWallet, strings.ToUpper(network)), &tb.ReplyMarkup{
ForceReply: true,
})
}
func WalletInfo(c tb.Context) error {
id := mathutil.MustUint(c.Data())
tokenInfo, err := data.GetWalletAddressById(id)
@@ -211,3 +246,33 @@ func isWalletAddressPending(userID int64) bool {
}
return true
}
func getPendingWalletAddressState(userID int64) (pendingWalletAddressState, bool) {
v, ok := pendingWalletAddressUsers.Load(userID)
if !ok {
return pendingWalletAddressState{}, false
}
state, ok := v.(pendingWalletAddressState)
return state, ok
}
func getEnabledSupportedNetworks() ([]string, error) {
assets, err := data.ListEnabledSupportedAssets()
if err != nil {
return nil, err
}
set := make(map[string]struct{})
for _, a := range assets {
n := strings.ToLower(strings.TrimSpace(a.Network))
if n == "" {
continue
}
set[n] = struct{}{}
}
networks := make([]string, 0, len(set))
for n := range set {
networks = append(networks, n)
}
sort.Strings(networks)
return networks, nil
}
+23
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"strings"
"github.com/assimon/luuu/model/mdb"
"github.com/btcsuite/btcutil/base58"
"github.com/gagliardetto/solana-go"
)
@@ -46,6 +47,28 @@ func isValidTronAddress(addr string) bool {
return string(checksum) == string(hash2[:4])
}
func isValidAddressByNetwork(network, addr string) bool {
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron:
return isValidTronAddress(addr)
case mdb.NetworkSolana:
return isValidSolanaAddress(addr)
default:
// 其余 EVM 链统一使用 0x 地址校验
return isValidEthereumAddress(addr)
}
}
func normalizeWalletAddressByNetwork(network, addr string) string {
addr = strings.TrimSpace(addr)
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron, mdb.NetworkSolana:
return addr
default:
return strings.ToLower(addr)
}
}
// isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。
func isValidSolanaAddress(addr string) bool {
addr = strings.TrimSpace(addr)
+4
View File
@@ -16,6 +16,8 @@ var Errno = map[int]string{
10011: "exceeded maximum sub-order limit",
10012: "cannot switch network on a sub-order",
10013: "order is not awaiting payment",
10014: "supported asset already exists",
10015: "supported asset not found",
}
var (
@@ -34,6 +36,8 @@ var (
SubOrderLimitExceeded = Err(10011)
CannotSwitchSubOrder = Err(10012)
OrderNotWaitPay = Err(10013)
SupportedAssetAlreadyExists = Err(10014)
SupportedAssetNotFound = Err(10015)
)
type RspError struct {