mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e88d7a40c | |||
| 18dcb96822 | |||
| 4750d63db3 | |||
| 7af7306729 | |||
| ff16db907a | |||
| 96c262a7dd | |||
| 8e96cc0058 | |||
| 9c003fbead | |||
| aff1055743 | |||
| f6a8fb47df | |||
| d46bc7793e | |||
| 9d01443f3e | |||
| 70f8ed4ea0 | |||
| 8cd816c35e | |||
| 2f6cd34610 | |||
| 1e2282ba2e | |||
| 786c5e8175 | |||
| 5e68bf8309 |
@@ -1,73 +1,169 @@
|
|||||||
## Epusdt (Easy Payment Usdt)
|
# Epusdt — Easy Payment USDT
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="wiki/img/usdtlogo.png">
|
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||||
</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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
## 项目简介
|
<p align="center">
|
||||||
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
|
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||||
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql`和`redis`
|
<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>
|
||||||
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
|
<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>
|
||||||
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
|
</p>
|
||||||
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
|
|
||||||
|
|
||||||
## 项目特点
|
<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>
|
||||||
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
|
<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>
|
||||||
- 支持`http api`,其他系统亦可接入
|
|
||||||
- `Telegram`机器人接入,便捷使用和支付消息快速通知
|
---
|
||||||
|
|
||||||
|
## 🌍 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
|
Epusdt
|
||||||
├── plugins ---> (已集成的插件库,例如dujiaoka)
|
├── plugins/ → 已集成的系统插件(独角数卡等)
|
||||||
├── src ---> (项目核心目录)
|
├── src/ → 项目核心代码
|
||||||
├── sdk ---> (接入SDK)
|
├── sdk/ → 接入 SDK
|
||||||
├── sql ---> (安装sql文件或更新sql文件)
|
├── sql/ → 数据库安装 / 升级脚本
|
||||||
└── wiki ---> (知识库)
|
└── 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 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||||
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
|
|
||||||
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
|
|
||||||
|
|
||||||
## 设计实现
|
|
||||||
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
|
|
||||||
监听钱包地址`usdt`代币入账事件,通过`金额差异`和`时效性`来判定交易归属信息,
|
|
||||||
可参考下方`流程图`
|
|
||||||
```
|
```
|
||||||
简单的原理:
|
工作流程:
|
||||||
1.客户需要支付20.05usdt
|
1. 客户发起支付,需支付 20.05 USDT
|
||||||
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
|
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
||||||
3.发起支付的时候,我们可以判定钱包address_1的20.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟。
|
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
||||||
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
|
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||||
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功!
|
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||||
```
|
```
|
||||||
### 流程图:
|
|
||||||

|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 社区与支持
|
||||||
|
|
||||||
|
**遇到问题?** 请优先在 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 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,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)
|
||||||
|
}
|
||||||
@@ -22,5 +22,39 @@ func MdbTableInit() {
|
|||||||
// panic(err)
|
// panic(err)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ const (
|
|||||||
NetworkTron = "tron"
|
NetworkTron = "tron"
|
||||||
NetworkSolana = "solana"
|
NetworkSolana = "solana"
|
||||||
NetworkEthereum = "ethereum"
|
NetworkEthereum = "ethereum"
|
||||||
|
NetworkBsc = "bsc"
|
||||||
|
NetworkPolygon = "polygon"
|
||||||
|
NetworkPlasma = "plasma"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WalletAddress struct {
|
type WalletAddress struct {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
type NetworkTokenSupport struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Tokens []string `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupportedAssetsResponse struct {
|
||||||
|
Supports []NetworkTokenSupport `json:"supports"`
|
||||||
|
}
|
||||||
@@ -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() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
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")
|
var usdt, usdc common.Address
|
||||||
usdc := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
var polygonUsdcE common.Address
|
||||||
var tokenSym string
|
switch chainNetwork {
|
||||||
switch contract {
|
case mdb.NetworkEthereum:
|
||||||
case usdt:
|
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||||
tokenSym = "USDT"
|
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||||
case usdc:
|
case mdb.NetworkBsc:
|
||||||
tokenSym = "USDC"
|
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:
|
default:
|
||||||
log.Sugar.Warnf("[ETH-WS] skip unsupported contract %s", contract.Hex())
|
|
||||||
return
|
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())
|
walletAddr := strings.ToLower(toAddr.Hex())
|
||||||
if rawValue == nil || rawValue.Sign() <= 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||||
if amount <= 0 {
|
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
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
if tradeID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkEthereum {
|
if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
|
||||||
log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s network=%q", tokenSym, walletAddr, tradeID, order.Network)
|
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: walletAddr,
|
ReceiveAddress: walletAddr,
|
||||||
Token: tokenSym,
|
Token: tokenSym,
|
||||||
Network: mdb.NetworkEthereum,
|
Network: chainNetwork,
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: txHash,
|
BlockTransactionId: txHash,
|
||||||
@@ -325,15 +363,15 @@ func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Addr
|
|||||||
err = OrderProcessing(req)
|
err = OrderProcessing(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
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
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPaymentNotification(order *mdb.Orders) {
|
func sendPaymentNotification(order *mdb.Orders) {
|
||||||
@@ -372,6 +410,12 @@ func networkDisplay(n string) string {
|
|||||||
return "Solana"
|
return "Solana"
|
||||||
case mdb.NetworkEthereum:
|
case mdb.NetworkEthereum:
|
||||||
return "Ethereum"
|
return "Ethereum"
|
||||||
|
case mdb.NetworkBsc:
|
||||||
|
return "BSC"
|
||||||
|
case mdb.NetworkPolygon:
|
||||||
|
return "Polygon"
|
||||||
|
case mdb.NetworkPlasma:
|
||||||
|
return "Plasma"
|
||||||
default:
|
default:
|
||||||
if n == "" {
|
if n == "" {
|
||||||
return "Tron"
|
return "Tron"
|
||||||
|
|||||||
+55
-17
@@ -64,6 +64,12 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
// gmpay v1 routes
|
// gmpay v1 routes
|
||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
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
|
// wallet management routes
|
||||||
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
|
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
|
||||||
@@ -75,25 +81,51 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
// epay v1 routes
|
// epay v1 routes
|
||||||
epayV1 := paymentRoute.Group("/epay/v1")
|
epayV1 := paymentRoute.Group("/epay/v1")
|
||||||
epayV1.GET("/order/create-transaction/submit.php", func(ctx echo.Context) error {
|
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
|
||||||
money := ctx.QueryParam("money")
|
params := make(map[string]interface{})
|
||||||
name := ctx.QueryParam("name")
|
copyParams := func(values map[string][]string) {
|
||||||
notifyURL := ctx.QueryParam("notify_url")
|
for k, v := range values {
|
||||||
outTradeNo := ctx.QueryParam("out_trade_no")
|
if len(v) == 0 {
|
||||||
returnURL := ctx.QueryParam("return_url")
|
continue
|
||||||
signstr := ctx.QueryParam("sign")
|
}
|
||||||
// signType := ctx.QueryParam("sign_type")
|
params[k] = v[0]
|
||||||
|
}
|
||||||
m := map[string]interface{}{
|
|
||||||
"money": money,
|
|
||||||
"name": name,
|
|
||||||
"notify_url": notifyURL,
|
|
||||||
"out_trade_no": outTradeNo,
|
|
||||||
"pid": config.GetEpayPid(), // 注意:验签时需要包含 pid 参数
|
|
||||||
"return_url": returnURL,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return constant.SignatureErr
|
return constant.SignatureErr
|
||||||
}
|
}
|
||||||
@@ -101,6 +133,12 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
return constant.SignatureErr
|
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)
|
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||||
|
|||||||
+202
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -28,6 +29,7 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
|||||||
viper.Reset()
|
viper.Reset()
|
||||||
viper.Set("db_type", "sqlite")
|
viper.Set("db_type", "sqlite")
|
||||||
viper.Set("api_auth_token", testAPIToken)
|
viper.Set("api_auth_token", testAPIToken)
|
||||||
|
viper.Set("epay_pid", 1)
|
||||||
viper.Set("app_uri", "http://localhost:8080")
|
viper.Set("app_uri", "http://localhost:8080")
|
||||||
viper.Set("order_expiration_time", 10)
|
viper.Set("order_expiration_time", 10)
|
||||||
viper.Set("api_rate_url", "")
|
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)
|
// 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
|
// seed wallet addresses
|
||||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
|
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})
|
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()
|
e := echo.New()
|
||||||
RegisterRoute(e)
|
RegisterRoute(e)
|
||||||
@@ -78,6 +99,28 @@ func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.Re
|
|||||||
return rec
|
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.
|
// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network.
|
||||||
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
|
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
@@ -207,6 +250,115 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac
|
|||||||
return resp
|
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.
|
// TestWalletAddAndList tests adding wallets via API and listing them.
|
||||||
func TestWalletAddAndList(t *testing.T) {
|
func TestWalletAddAndList(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
@@ -401,3 +553,52 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
|
|||||||
t.Errorf("expected SolTestAddress001, got %v", data["receive_address"])
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
func Start() {
|
func Start() {
|
||||||
log.Sugar.Info("[task] Starting task scheduler...")
|
log.Sugar.Info("[task] Starting task scheduler...")
|
||||||
go StartEthereumWebSocketListener()
|
go StartEthereumWebSocketListener()
|
||||||
|
go StartBscWebSocketListener()
|
||||||
|
//go StartPolygonWebSocketListener()
|
||||||
|
//go StartPlasmaWebSocketListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
// trc20钱包监听
|
// trc20钱包监听
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener("[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
|
||||||
|
}
|
||||||
+7
-42
@@ -2,7 +2,6 @@ package task
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -38,7 +37,7 @@ var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
|
|||||||
func StartEthereumWebSocketListener() {
|
func StartEthereumWebSocketListener() {
|
||||||
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Fatalf("Failed to get wallet addresses: %v", err)
|
log.Sugar.Errorf("Failed to get wallet addresses: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
StoreEthRecipientsFromWallets(wallets)
|
StoreEthRecipientsFromWallets(wallets)
|
||||||
@@ -55,16 +54,6 @@ func StartEthereumWebSocketListener() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
wsURL := "wss://ethereum.publicnode.com"
|
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{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: []common.Address{
|
Addresses: []common.Address{
|
||||||
usdtContract,
|
usdtContract,
|
||||||
@@ -73,45 +62,22 @@ func StartEthereumWebSocketListener() {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅日志(核心)
|
runEvmWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
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 {
|
if len(vLog.Topics) < 3 {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event := vLog.Topics[0].String()
|
event := vLog.Topics[0].String()
|
||||||
if event != transferEventHash.String() {
|
if event != transferEventHash.String() {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// value from data
|
|
||||||
amount := new(big.Int).SetBytes(vLog.Data)
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
|
||||||
|
|
||||||
if event != transferEventHash.String() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
|
||||||
if !isWatchedEthRecipient(toAddr) {
|
if !isWatchedEthRecipient(toAddr) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
@@ -123,9 +89,8 @@ func StartEthereumWebSocketListener() {
|
|||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
service.TryProcessEthereumERC20Transfer(vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
service.TryProcessEvmERC20Transfer(mdb.NetworkEthereum, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
|||||||
@@ -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 runEvmWsLogListener(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
|
||||||
|
}
|
||||||
@@ -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{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener("[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
|
||||||
|
}
|
||||||
@@ -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-ws.publicnode.com"
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: []common.Address{
|
||||||
|
polygonUsdtContract,
|
||||||
|
polygonUsdcContract,
|
||||||
|
polygonUsdcEContract,
|
||||||
|
},
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener("[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
|
||||||
|
}
|
||||||
+82
-17
@@ -2,6 +2,7 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,12 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReplayAddWallet = "请发给我 TRON(T 开头)、以太坊主网(0x 开头)或 Solana 收款地址"
|
ReplaySelectNetwork = "请选择要添加的钱包网络"
|
||||||
|
ReplayAddWallet = "请发送 %s 网络收款地址"
|
||||||
pendingWalletAddressTTL = 5 * time.Minute
|
pendingWalletAddressTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type pendingWalletAddressState struct {
|
type pendingWalletAddressState struct {
|
||||||
RequestedAt time.Time
|
RequestedAt time.Time
|
||||||
|
Network string
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingWalletAddressUsers sync.Map
|
var pendingWalletAddressUsers sync.Map
|
||||||
@@ -36,7 +39,7 @@ func OnTextMessageHandle(c tb.Context) error {
|
|||||||
senderID = sender.ID
|
senderID = sender.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet
|
isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ")
|
||||||
isPendingFlow := isWalletAddressPending(senderID)
|
isPendingFlow := isWalletAddressPending(senderID)
|
||||||
if !isReplyFlow && !isPendingFlow {
|
if !isReplyFlow && !isPendingFlow {
|
||||||
return nil
|
return nil
|
||||||
@@ -47,24 +50,25 @@ func OnTextMessageHandle(c tb.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgText := strings.TrimSpace(msg.Text)
|
msgText := strings.TrimSpace(msg.Text)
|
||||||
var err error
|
state, _ := getPendingWalletAddressState(senderID)
|
||||||
switch {
|
if state.Network == "" {
|
||||||
case isValidTronAddress(msgText):
|
_ = c.Send("请先选择网络,再发送地址。")
|
||||||
_, 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))
|
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return c.Send(err.Error())
|
return c.Send(err.Error())
|
||||||
}
|
}
|
||||||
pendingWalletAddressUsers.Delete(senderID)
|
pendingWalletAddressUsers.Delete(senderID)
|
||||||
|
|
||||||
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText))
|
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功(%s)", storeAddress, strings.ToUpper(state.Network)))
|
||||||
return WalletList(c)
|
return WalletList(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +100,26 @@ func WalletList(c tb.Context) error {
|
|||||||
|
|
||||||
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
|
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
|
||||||
bots.Handle(&addBtn, func(c tb.Context) error {
|
bots.Handle(&addBtn, func(c tb.Context) error {
|
||||||
if sender := c.Sender(); sender != nil {
|
chains, err := getEnabledSupportedNetworks()
|
||||||
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
|
if err != nil {
|
||||||
|
return c.Send("读取支持链失败:" + err.Error())
|
||||||
}
|
}
|
||||||
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
|
if len(chains) == 0 {
|
||||||
ForceReply: true,
|
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})
|
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 {
|
func WalletInfo(c tb.Context) error {
|
||||||
id := mathutil.MustUint(c.Data())
|
id := mathutil.MustUint(c.Data())
|
||||||
tokenInfo, err := data.GetWalletAddressById(id)
|
tokenInfo, err := data.GetWalletAddressById(id)
|
||||||
@@ -211,3 +246,33 @@ func isWalletAddressPending(userID int64) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
"github.com/gagliardetto/solana-go"
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
@@ -46,6 +47,28 @@ func isValidTronAddress(addr string) bool {
|
|||||||
return string(checksum) == string(hash2[:4])
|
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 字节公钥)。
|
// isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。
|
||||||
func isValidSolanaAddress(addr string) bool {
|
func isValidSolanaAddress(addr string) bool {
|
||||||
addr = strings.TrimSpace(addr)
|
addr = strings.TrimSpace(addr)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ var Errno = map[int]string{
|
|||||||
10011: "exceeded maximum sub-order limit",
|
10011: "exceeded maximum sub-order limit",
|
||||||
10012: "cannot switch network on a sub-order",
|
10012: "cannot switch network on a sub-order",
|
||||||
10013: "order is not awaiting payment",
|
10013: "order is not awaiting payment",
|
||||||
|
10014: "supported asset already exists",
|
||||||
|
10015: "supported asset not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,6 +36,8 @@ var (
|
|||||||
SubOrderLimitExceeded = Err(10011)
|
SubOrderLimitExceeded = Err(10011)
|
||||||
CannotSwitchSubOrder = Err(10012)
|
CannotSwitchSubOrder = Err(10012)
|
||||||
OrderNotWaitPay = Err(10013)
|
OrderNotWaitPay = Err(10013)
|
||||||
|
SupportedAssetAlreadyExists = Err(10014)
|
||||||
|
SupportedAssetNotFound = Err(10015)
|
||||||
)
|
)
|
||||||
|
|
||||||
type RspError struct {
|
type RspError struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user