29 Commits

Author SHA1 Message Date
Ginta 06933451c5 actions 2026-04-28 16:28:01 +08:00
line-6000 d17d212c24 change tron scan logic 2026-04-28 15:28:00 +08:00
Ginta 51a2accd9f 🐛 fix link 2026-04-27 15:57:26 +08:00
Ginta 96ea7481bc Revert "Revert "new trc (#54)""
This reverts commit 74c3a3a08c.
2026-04-27 15:52:50 +08:00
Ginta de9c45a944 static 2026-04-27 15:52:12 +08:00
Ginta 9e885d8b2f order notify 2026-04-27 15:43:35 +08:00
Ginta 74c3a3a08c Revert "new trc (#54)"
This reverts commit 0a19854602.
2026-04-27 00:04:05 +08:00
Ginta 0a19854602 new trc (#54)
* new trct
2026-04-26 23:32:43 +08:00
line-6000 67263da0f1 Merge pull request #51 from GMWalletApp/dev 2026-04-25 18:49:41 +08:00
line-6000 95879e33d4 refactor(routes):
- keep install-mode root redirect to /install and add tests covering install root redirect and install page serving
- change root backend handler from Any("/") to POST("/") so GET / can consistently serve the SPA after install

Co-authored-by: Copilot <copilot@github.com>
2026-04-25 18:48:28 +08:00
line-6000 0c059e8a28 Merge pull request #50 from GMWalletApp/dev 2026-04-24 19:18:45 +08:00
line-6000 485059f60e - Removed manifest link from index.html.
- Updated script and module preload links in index.html for optimized loading.
- Modified service worker (sw.js) to reflect new asset revisions and improve caching strategy.
- Updated configuration documentation in BT_RUN.md, docker-RUN.md, and manual_RUN.md to clarify currency rate settings.
2026-04-24 19:10:11 +08:00
root 143bc84ac3 docs: sync docker examples to latest image 2026-04-23 07:25:56 +00:00
Ginta 06856e5caa release to group 2026-04-23 03:15:56 +08:00
Ginta b9bde0f95b release to group 2026-04-23 02:49:58 +08:00
line-6000 1b92ec1905 Merge pull request #47 from GMWalletApp/dev
Dev
2026-04-23 02:26:18 +08:00
line-6000 89be1fc967 Refactor project structure and update import paths to GMWalletApp/epusdt 2026-04-23 02:17:21 +08:00
line-6000 91936d580f refactor(rpc): streamline RPC node selection and remove hardcoded defaults 2026-04-23 02:08:18 +08:00
line-6000 09514a6946 fix(http): block /install page after setup is complete
- skip SPA fallback for `/install` and `/install/*` in main server
- keep install wizard accessible only during `RunInstallServer` phase
- prevent post-install access to install UI
2026-04-23 00:39:22 +08:00
Ginta b6cd4325b8 release action 2026-04-22 22:46:14 +08:00
Ginta 27d5e5bcfe add .env file into docker image 2026-04-22 22:00:51 +08:00
Ginta b625bac1e3 add .env file into docker image 2026-04-22 21:03:10 +08:00
Ginta bc5bbf72f7 add .env file into docker image 2026-04-22 20:46:11 +08:00
line-6000 3a3a830d93 fix(server): prevent SPA fallback from swallowing API routes
- add ShouldSkipSPAFallback() deny prefixes: /api, /admin/api, /payments, /pay
- apply StaticWithConfig Skipper in both main http server and install server
- add unit tests for fallback skip matching behavior
2026-04-22 20:27:34 +08:00
Ginta 59aa71209c action 2026-04-22 18:06:59 +08:00
Ginta cdf274599e build 2026-04-22 16:43:47 +08:00
line-6000 916394337b Merge pull request #37 from GMWalletApp/dev-runtime
Dev runtime
2026-04-22 04:20:43 +08:00
Ginta efc8f29af6 issue template 2026-04-21 13:42:07 +08:00
Astra3456 9eaf27298c Add Solana support to token list in README 2026-04-17 13:00:52 +08:00
285 changed files with 2587 additions and 1032 deletions
+111
View File
@@ -0,0 +1,111 @@
name: Bug 反馈
description: 有一个 bug 需要解决
title: "发现bug"
labels:
- bug
body:
- type: markdown
attributes:
value: |
# 提 ISSUE 须知
请先阅读文档:
- [epusdt 文档](https://epusdt.com/)
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
- type: checkboxes
id: checks
attributes:
label: 提交前检查
options:
- label: 我已阅读文档并检查常见问题
required: true
- label: 我已搜索现有 issue,确认不是重复问题
required: true
- label: 我已尽量提供可复现信息(代码、配置、日志)
required: true
- type: textarea
id: epusdt_version
attributes:
label: 1. epusdt 版本
description: 请提供版本号或 commit hash,避免只写“最新版”
placeholder: |
例如:
- v1.2.3
- commit: abcdef1
validations:
required: true
- type: textarea
id: runtime
attributes:
label: 2. 运行环境版本
description: 请提供语言/运行时及相关依赖版本
placeholder: |
例如:
- Go/Python/Node 版本:
- 数据库及版本:
- Docker 版本(如有):
validations:
required: true
- type: textarea
id: os_info
attributes:
label: 3. 操作系统信息
description: Windows / Linux / macOS 或其他系统,请写明版本
placeholder: |
例如:
- Ubuntu 22.04
- macOS 14.6
- Windows Server 2022
validations:
required: true
- type: textarea
id: repro_code
attributes:
label: 4. 可复现代码/配置
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
render: shell
placeholder: |
# 最小复现步骤
1. ...
2. ...
3. ...
# 关键请求/配置(脱敏后)
curl -X POST https://xxx/api/...
-H "Authorization: Bearer ***"
-d '{"amount":100,"order_id":"test-001"}'
validations:
required: true
- type: textarea
id: stacktrace
attributes:
label: 5. 错误日志 / 堆栈信息
description: 请粘贴完整报错信息、状态码、关键日志
render: shell
placeholder: |
例如:
- HTTP 状态码:
- 错误码:
- 错误堆栈:
- 关键日志片段:
validations:
required: true
- type: textarea
id: extra
attributes:
label: 补充信息
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
placeholder: 其他你认为有帮助的信息(可选)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 安全漏洞报告
url: https://github.com/ginta/epusdt/security/advisories/new
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
+26 -15
View File
@@ -5,28 +5,40 @@ on:
push:
branches:
- "main"
- "master"
tags:
- "v*"
env:
DOCKERHUB_NAMESPACE: gmwallet
IMAGE_NAME: epusdt
jobs:
push-docker-hub:
runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps:
- uses: actions/checkout@v6
- name: Compute Build Metadata
id: meta
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
- name: Login
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=alpine
type=raw,value=latest
type=ref,event=branch,suffix=-alpine
type=ref,event=tag
type=sha,prefix=sha-,suffix=-alpine
- name: Login To Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -38,9 +50,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ steps.meta.outputs.commit }}
DATE=${{ steps.meta.outputs.date }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
API_RATE_URL=${{ vars.API_RATE_URL }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
required: true
type: string
permissions:
contents: write
@@ -25,6 +31,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
- name: Ensure Tag Commit Is On Default Branch
env:
+6 -1
View File
@@ -5,7 +5,7 @@ ENV CGO_ENABLED=0
WORKDIR /app
RUN git clone https://github.com/GMwalletApp/epusdt.git .
COPY . /app
WORKDIR /app/src
RUN go mod download
@@ -14,10 +14,15 @@ RUN go build -o /app/epusdt .
FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata
ARG API_RATE_URL=""
WORKDIR /app
COPY --from=builder /app/src/static /app/static
COPY --from=builder /app/src/static /static
COPY --from=builder /app/src/.env.example /app/.env
RUN if [ -n "$API_RATE_URL" ]; then \
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
fi
COPY --from=builder /app/epusdt .
VOLUME /app/conf
+5 -4
View File
@@ -15,10 +15,10 @@
</p>
<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://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/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>
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
</p>
---
@@ -37,11 +37,12 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
|------|------|
| **TRC20** (Tron) | USDT、TRX|
| **ERC20** (Ethereum) | USDT、USDC、ETH |
| **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
---
@@ -122,7 +123,7 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
## 💬 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
+1 -1
View File
@@ -1,6 +1,6 @@
services:
epusdt:
image: gmwallet/epusdt:alpine
image: gmwallet/epusdt:latest
restart: always
build:
context: .
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
+2 -2
View File
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
use Illuminate\Support\Facades\Route;
+12 -15
View File
@@ -3,14 +3,14 @@ package bootstrap
import (
"sync"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/task"
"github.com/assimon/luuu/telegram"
appjwt "github.com/assimon/luuu/util/jwt"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/mq"
"github.com/GMWalletApp/epusdt/task"
"github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
)
@@ -22,7 +22,7 @@ func InitApp() {
log.Init()
dao.Init()
// Wire settings-table lookups into the config package so
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides.
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
@@ -36,20 +36,17 @@ func InitApp() {
}
}
}
// config.Init() computes RateApiUrl before SettingsGetString is
// installed, so refresh the cache once DB-backed settings are available.
config.RateApiUrl = config.GetRateApiUrl()
// Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent.
adminPwd, isNew, err := data.EnsureDefaultAdmin()
_, isNew, err := data.EnsureDefaultAdmin()
if err != nil {
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
}
if isNew {
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
color.Yellow.Println("║ Default admin account created. Change the password via admin console! ║")
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
color.Yellow.Printf("║ Username: admin ║\n")
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd)
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
}
if _, err := appjwt.EnsureSecret(); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/gookit/color"
)
+18 -8
View File
@@ -6,16 +6,17 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/install"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route"
"github.com/assimon/luuu/util/constant"
luluHttp "github.com/assimon/luuu/util/http"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/bootstrap"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/install"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/route"
"github.com/GMWalletApp/epusdt/util/constant"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra"
@@ -72,6 +73,15 @@ func HttpServerStart() {
}
}
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
Skipper: func(c echo.Context) bool {
path := c.Request().URL.Path
if path == "/install" || strings.HasPrefix(path, "/install/") {
// The install wizard is only served by install.RunInstallServer
// before bootstrap. Once main server starts, block /install.
return true
}
return luluHttp.ShouldSkipSPAFallback(path)
},
HTML5: true,
Index: "index.html",
Root: wwwRoot,
+1 -1
View File
@@ -1,7 +1,7 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package command
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra"
)
+105 -32
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
@@ -11,7 +12,7 @@ import (
"strings"
"time"
"github.com/assimon/luuu/util/http_client"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
)
@@ -34,9 +35,6 @@ var (
TgBotToken string
TgProxy string
TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
@@ -65,6 +63,9 @@ func Init() {
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
if err = ensureConfiguredStaticFiles(); err != nil {
panic(err)
}
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath)
@@ -77,9 +78,6 @@ func Init() {
TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
}
func mustMkdir(path string) {
@@ -88,6 +86,93 @@ func mustMkdir(path string) {
}
}
func ensureConfiguredStaticFiles() error {
if strings.TrimSpace(StaticFilePath) == "" {
return nil
}
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return err
}
srcDir := filepath.Join(filepath.Dir(exePath), "static")
srcInfo, err := os.Stat(srcDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !srcInfo.IsDir() {
return nil
}
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return err
}
dstAbs, err := filepath.Abs(StaticFilePath)
if err != nil {
return err
}
if srcAbs == dstAbs {
return nil
}
return copyMissingStaticFiles(srcAbs, dstAbs)
}
func copyMissingStaticFiles(srcDir, dstDir string) error {
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, rel)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if _, err = os.Stat(dstPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
return copyFile(path, dstPath)
})
}
func copyFile(srcPath, dstPath string) error {
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil
}
return err
}
if _, err = io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error":
@@ -277,13 +362,14 @@ func GetRateForCoin(coin string, base string) float64 {
if usdtRate > 0 {
return 1 / usdtRate
}
return 0
}
}
return getRateForCoinFromAPI(coin, base)
}
baseURL := RateApiUrl
if baseURL == "" {
baseURL = GetRateApiUrl()
}
func getRateForCoinFromAPI(coin string, base string) float64 {
baseURL := GetRateApiUrl()
if baseURL == "" {
log.Printf("rate api url is empty")
return 0.0
@@ -315,19 +401,18 @@ func GetRateForCoin(coin string, base string) float64 {
}
func GetUsdtRate() float64 {
// Prefer the DB-backed override (admin-configurable). Fall back to
// the legacy .env var, then the hardcoded 6.4 default.
// Only the admin setting can force the USDT/CNY rate. When the
// setting is unset, zero, or negative, fall back to the rate API.
if forced := settingsForcedUsdtRate(); forced > 0 {
return forced
}
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 {
return forcedUsdtRate
apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 {
return 1 / apiRate
}
if UsdtRate <= 0 {
return 6.4
}
return UsdtRate
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
return 0
}
func GetOrderExpirationTime() int {
@@ -398,15 +483,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
}
return time.Duration(seconds) * time.Second
}
func GetSolanaRpcUrl() string {
rpcUrl := viper.GetString("solana_rpc_url")
if rpcUrl == "" {
return "https://api.mainnet-beta.solana.com"
}
return rpcUrl
}
func GetEthereumWsUrl() string {
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
}
+219
View File
@@ -1,11 +1,52 @@
package config
import (
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
"github.com/spf13/viper"
)
func installSettingsGetter(t *testing.T, values map[string]string) {
t.Helper()
oldGetter := SettingsGetString
SettingsGetString = func(key string) string {
return values[key]
}
t.Cleanup(func() {
SettingsGetString = oldGetter
})
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
t.Helper()
@@ -135,3 +176,181 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Fatalf("config path = %s, want %s", got, flagPath)
}
}
func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "app-static")
dst := filepath.Join(root, "data-static")
if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
t.Fatalf("write source index: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
t.Fatalf("write source payment: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
t.Fatalf("write source logo: %v", err)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatalf("mkdir destination: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
t.Fatalf("write existing destination index: %v", err)
}
if err := copyMissingStaticFiles(src, dst); err != nil {
t.Fatalf("copy missing static files: %v", err)
}
index, err := os.ReadFile(filepath.Join(dst, "index.html"))
if err != nil {
t.Fatalf("read destination index: %v", err)
}
if string(index) != "custom-index" {
t.Fatalf("existing index was overwritten: %q", string(index))
}
payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
if err != nil {
t.Fatalf("read copied payment: %v", err)
}
if string(payment) != "source-payment" {
t.Fatalf("payment.js = %q, want source-payment", string(payment))
}
logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
if err != nil {
t.Fatalf("read copied nested asset: %v", err)
}
if string(logo) != "source-logo" {
t.Fatalf("logo.png = %q, want source-logo", string(logo))
}
}
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
apiCalled := false
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
apiCalled = true
return &http.Response{
StatusCode: http.StatusInternalServerError,
Status: http.StatusText(http.StatusInternalServerError),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "7.25",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
if got != 7.25 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
}
if apiCalled {
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
}
}
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/cny.json" {
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "-1",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
want := 1 / 0.14635
if math.Abs(got-want) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
}
rate := GetRateForCoin("usdt", "cny")
if math.Abs(rate-0.14635) > 1e-9 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
}
}
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetUsdtRate(); got != 0 {
t.Fatalf("GetUsdtRate() = %v, want 0", got)
}
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
}
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
callCount := 0
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
callCount++
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
if callCount != 1 {
t.Fatalf("rate api call count = %d, want 1", callCount)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package config
// create a cycle via `model/dao -> config`.
//
// If unset (e.g. during early startup or tests) the getters return the
// zero string / 0 and callers fall through to the .env / default path.
// zero string / 0 and callers apply their own fallback behavior.
// SettingsGetString is installed by bootstrap.Init with a closure that
// reads from the settings table. Runtime-only.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+48 -6
View File
@@ -3,9 +3,9 @@ package admin
import (
"errors"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
appjwt "github.com/assimon/luuu/util/jwt"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
@@ -34,6 +34,12 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"`
}
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login
// @Description Verify credentials and return a signed JWT token
@@ -77,6 +83,42 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
})
}
// GetInitialPassword returns the one-time initial admin password.
// @Summary Get initial admin password (one-time)
// @Description Returns the initial random admin password once, then invalidates it.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password [get]
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, InitialPasswordResponse{
Username: "admin",
Password: password,
})
}
// GetInitialPasswordHash returns the initial-password fingerprint so the
// frontend can warn until the password is changed.
// @Summary Get initial admin password hash
// @Description Returns the initial password hash and password-changed flag.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password-hash [get]
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
info, err := data.GetInitialAdminPasswordHashInfo()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, info)
}
// Logout is a no-op stub — tokens are stateless and the frontend
// discards them. Kept for API symmetry with the documented spec.
// @Summary Admin logout
@@ -111,9 +153,9 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found"))
}
// Warn the frontend when the operator hasn't changed the default
// password so the UI can show a prominent reminder.
isDefault := data.VerifyPassword(user.PasswordHash, "admin")
// Warn the frontend when the operator hasn't changed the seeded
// initial password.
isDefault := data.IsUsingInitialAdminPassword()
return c.SucJson(ctx, MeResponse{
AdminUser: *user,
PasswordIsDefault: isDefault,
+2 -2
View File
@@ -1,8 +1,8 @@
package admin
import (
"github.com/assimon/luuu/controller"
"github.com/assimon/luuu/middleware"
"github.com/GMWalletApp/epusdt/controller"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package admin
import (
"github.com/assimon/luuu/model/data"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
@@ -4,8 +4,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
@@ -6,10 +6,10 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/notify"
"github.com/assimon/luuu/telegram"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/task"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4"
)
+7 -7
View File
@@ -3,10 +3,10 @@ package admin
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/telegram"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
@@ -14,8 +14,8 @@ import (
// Supported groups and keys:
//
// - group=rate:
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api)
// rate.api_url (string) — external rate API URL
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
// rate.adjust_percent (float) — rate adjustment percentage
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
//
@@ -73,7 +73,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// @Description Batch insert/update settings. Returns per-key status.
// @Description Supported groups: brand, rate, system, epay.
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
// @Description rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
// @Description system group keys: system.order_expiration_time.
// @Tags Admin Settings
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package controller
import (
"errors"
"github.com/assimon/luuu/util/http"
"github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/validate"
"github.com/gookit/validate/locales/zhcn"
"github.com/gookit/validate/locales/zhtw"
+1 -1
View File
@@ -1,6 +1,6 @@
package comm
import "github.com/assimon/luuu/controller"
import "github.com/GMWalletApp/epusdt/controller"
var Ctrl = &BaseCommController{}
+6 -6
View File
@@ -4,12 +4,12 @@ import (
"encoding/json"
"net/http"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/assimon/luuu
module github.com/GMWalletApp/epusdt
go 1.25.0
+56 -28
View File
@@ -28,6 +28,7 @@ import (
"text/template"
"time"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/color"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
@@ -78,6 +79,60 @@ type installHandler struct {
done chan struct{}
}
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
return c.Redirect(http.StatusFound, "/install")
}
return next(c)
}
}
func resolveInstallWWWRoot() string {
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
return wwwRoot
}
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
h := &installHandler{
envFilePath: envFilePath,
done: make(chan struct{}),
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
// api routes for the install frontend
api := e.Group("/api")
api.GET("/install/defaults", h.GetDefaults)
api.POST("/install", h.Submit)
// Redirect browser visits on root to /install so first-run users land
// on the wizard directly. This must run before the static middleware,
// otherwise "/" is intercepted by the SPA index.html fallback.
e.Use(installRootRedirectMiddleware)
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: func(c echo.Context) bool {
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
},
HTML5: true,
Index: "index.html",
Root: wwwRoot,
}))
return e, h
}
// GetDefaults returns default values for the install form.
//
// @Summary Install — get default values
@@ -171,34 +226,7 @@ func RunInstallServer(listenAddr, envFilePath string) {
listenAddr = DefaultInstallAddr
}
h := &installHandler{
envFilePath: envFilePath,
done: make(chan struct{}),
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
// api routes for the install frontend
api := e.Group("/api")
api.GET("/install/defaults", h.GetDefaults)
api.POST("/install", h.Submit)
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Index: "index.html",
Root: wwwRoot,
}))
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
// Build a human-readable URL for the console hint.
installHost := listenAddr
+49
View File
@@ -95,6 +95,55 @@ func TestInstallAPIDefaults(t *testing.T) {
}
}
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/install" {
t.Fatalf("Location = %q, want /install", got)
}
}
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
const wantBody = "install-ui"
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/install", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
}
func TestInstallAPISubmit(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
+23 -6
View File
@@ -4,10 +4,10 @@ import (
"path/filepath"
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
appLog "github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/libtnb/sqlite"
"github.com/spf13/viper"
"go.uber.org/zap"
@@ -20,7 +20,6 @@ func SetupTestDatabases(t testing.TB) func() {
t.Helper()
viper.Reset()
viper.Set("forced_usdt_rate", 1.0)
viper.Set("app_uri", "https://example.com")
viper.Set("order_expiration_time", 10)
viper.Set("order_notice_max_retry", 2)
@@ -31,7 +30,6 @@ func SetupTestDatabases(t testing.TB) func() {
config.HTTPAccessLog = false
config.SQLDebug = false
config.LogLevel = "error"
config.UsdtRate = 0
appLog.Sugar = zap.NewNop().Sugar()
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
@@ -52,6 +50,16 @@ func SetupTestDatabases(t testing.TB) func() {
dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB
config.SettingsGetString = func(key string) string {
if dao.Mdb == nil {
return ""
}
var row mdb.Setting
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
return ""
}
return row.Value
}
// Seed all standard chains as enabled so IsChainEnabled checks pass.
for _, network := range []string{
@@ -74,12 +82,21 @@ func SetupTestDatabases(t testing.TB) func() {
Pid: "1001", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable,
})
if err := dao.Mdb.Create(&mdb.Setting{
Group: "rate",
Key: "rate.forced_usdt_rate",
Value: "1.0",
Type: "string",
}).Error; err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
return func() {
closeDB(t, runtimeDB)
closeDB(t, mainDB)
dao.Mdb = nil
dao.RuntimeDB = nil
config.SettingsGetString = nil
viper.Reset()
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os"
"path/filepath"
"github.com/assimon/luuu/command"
"github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
appjwt "github.com/assimon/luuu/util/jwt"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/json"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package dao
import (
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"sync"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color"
"gorm.io/gorm/clause"
)
+3 -3
View File
@@ -1,9 +1,9 @@
package dao
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"gorm.io/gorm"
"gorm.io/gorm/logger"
+216 -5
View File
@@ -2,16 +2,38 @@ package data
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const defaultAdminUsername = "admin"
const InitialAdminPasswordHashAlgorithm = "sha256"
var (
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
)
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
// so the frontend can detect whether the operator is still using the
// initial password.
type InitialAdminPasswordHashInfo struct {
Algorithm string `json:"algorithm" example:"sha256"`
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
PasswordChanged bool `json:"password_changed" example:"false"`
Available bool `json:"available" example:"true"`
}
// EnsureDefaultAdmin seeds an initial admin account when no admin user
// exists. The password is randomly generated and returned so the caller
@@ -35,7 +57,15 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
return password, true, dao.Mdb.Create(user).Error
if err := dao.Mdb.Create(user).Error; err != nil {
return "", false, err
}
if err := initAdminPasswordState(password); err != nil {
// Account was already created; surface both for visibility while
// preserving created=true and password for emergency fallback.
return password, true, err
}
return password, true, nil
}
func randomAdminPassword() string {
@@ -44,6 +74,141 @@ func randomAdminPassword() string {
return hex.EncodeToString(b)
}
// HashInitialAdminPassword fingerprints the initial plaintext password so
// the frontend can compare user input locally without exposing plaintext.
func HashInitialAdminPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return fmt.Sprintf("%x", sum[:])
}
func initAdminPasswordState(plain string) error {
hash := HashInitialAdminPassword(plain)
settings := []mdb.Setting{
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: plain, Type: mdb.SettingTypeString,
Description: "One-time readable initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
Value: hash, Type: mdb.SettingTypeString,
Description: "SHA-256 fingerprint for initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
},
}
for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil {
return err
}
}
// Keep in-process cache coherent for the current process.
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock()
return nil
}
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
}).Create(&row).Error
}
// ConsumeInitialAdminPassword returns the one-time initial admin password.
// After a successful read, the plaintext is deleted and cannot be fetched
// again.
func ConsumeInitialAdminPassword() (string, error) {
var password string
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
row := new(mdb.Setting)
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInitAdminPasswordAlreadyFetched
}
return upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
})
})
if err != nil {
return "", err
}
settingsCacheMu.Lock()
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
settingsCacheMu.Unlock()
return password, nil
}
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
// and the changed-state flag used by the admin frontend.
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: "",
PasswordChanged: true,
Available: false,
}, nil
}
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: hash,
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
Available: true,
}, nil
}
// IsUsingInitialAdminPassword reports whether the current admin password is
// still considered the seeded initial password.
func IsUsingInitialAdminPassword() bool {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return false
}
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
}
// HashPassword bcrypts a plaintext password.
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
@@ -80,9 +245,55 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
if err != nil {
return err
}
return dao.Mdb.Model(&mdb.AdminUser{}).
changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}).
Where("id = ?", id).
Update("password_hash", hash).Error
Update("password_hash", hash).Error; err != nil {
return err
}
// Old installs may not have initial-password metadata; skip in
// that case to keep password updates backward compatible.
var initHashRow mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
Limit(1).
Find(&initHashRow).Error; err != nil {
return err
}
initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" {
return nil
}
newHash := HashInitialAdminPassword(newPlain)
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
changedValue := "true"
if !changed {
changedValue = "false"
}
if err := upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: changedValue,
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
}); err != nil {
return err
}
changedCacheValue = changedValue
return nil
})
if err != nil {
return err
}
if changedCacheValue != "" {
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
settingsCacheMu.Unlock()
}
return nil
}
// TouchAdminUserLastLogin stamps last_login_at to now.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"gorm.io/gorm"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListChains returns all chain rows ordered by id.
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListNotificationChannels returns all rows, optionally filtered by type.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"gorm.io/gorm"
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// DailyStat is one bucket of day-level aggregation used by the
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
+14 -7
View File
@@ -4,8 +4,8 @@ import (
"math/rand"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
)
@@ -47,8 +47,8 @@ func DeleteRpcNodeByID(id uint64) error {
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
// Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to any enabled row when none are healthy yet (bootstrap case
// where the health check hasn't run). Returns nil if nothing is defined.
// Falls back to enabled rows with status=unknown when no health check has
// run yet. Explicitly down rows are not selected.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
@@ -62,15 +62,22 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
if len(rows) == 0 {
return nil, nil
}
healthy := rows[:0]
healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows {
if r.Status == mdb.RpcNodeStatusOk {
switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
}
}
candidates := healthy
if len(candidates) == 0 {
candidates = rows // bootstrap: no health check has run yet
candidates = bootstrap
}
if len(candidates) == 0 {
return nil, nil
}
return pickWeighted(candidates), nil
}
+101
View File
@@ -0,0 +1,101 @@
package data
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestSelectRpcNodeUsesHealthyRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://ok.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed ok rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://ok.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want ok row", got)
}
}
func TestSelectRpcNodeFallsBackToUnknownOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://unknown.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want unknown row", got)
}
}
func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
+6 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"sync"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause"
)
@@ -151,6 +151,10 @@ func DeleteSetting(key string) error {
// sensitiveSettingKeys lists keys that must never be returned to API callers.
var sensitiveSettingKeys = []string{
mdb.SettingKeyJwtSecret,
mdb.SettingKeyInitAdminPasswordPlain,
mdb.SettingKeyInitAdminPasswordHash,
mdb.SettingKeyInitAdminPasswordFetched,
mdb.SettingKeyInitAdminPasswordChanged,
}
// ListSettingsByGroup returns all rows for a given group (empty group = all),
+3 -3
View File
@@ -3,9 +3,9 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
)
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
+4
View File
@@ -23,6 +23,10 @@ const (
const (
SettingKeyJwtSecret = "system.jwt_secret"
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
SettingKeyOrderExpiration = "system.order_expiration_time"
SettingKeyBrandSiteName = "brand.site_name"
SettingKeyBrandLogoUrl = "brand.logo_url"
+9 -9
View File
@@ -8,15 +8,15 @@ import (
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
)
+84 -6
View File
@@ -2,16 +2,21 @@ package service
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
)
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
@@ -25,6 +30,26 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -72,6 +97,59 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
}
}
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/cny.json" {
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
Request: r,
}, nil
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
t.Fatalf("set rate.api_url: %v", err)
}
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
t.Fatalf("actual amount = %s, want 1.46", got)
}
}
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
t.Fatalf("clear rate.api_url: %v", err)
}
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
if err != constant.RateAmountErr {
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
}
}
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
+4 -4
View File
@@ -3,10 +3,10 @@ package service
import (
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
)
var ErrOrder = errors.New("不存在待支付订单或已过期")
+26 -8
View File
@@ -10,13 +10,13 @@ import (
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2"
"github.com/shopspring/decimal"
@@ -281,6 +281,21 @@ type solSignatureResult struct {
BlockTime *int64 `json:"blockTime"`
}
func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if node == nil || node.ID == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New()
@@ -297,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false
})
rpcUrl := config.GetSolanaRpcUrl()
rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R().
SetHeader("Content-Type", "application/json").
+63 -11
View File
@@ -2,13 +2,59 @@ package service
import (
"encoding/json"
"fmt"
"os"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/tidwall/gjson"
)
func setupSolanaRPCNode(t *testing.T, url string) func() {
t.Helper()
cleanup := testutil.SetupTestDatabases(t)
node := &mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: url,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
cleanup()
t.Fatalf("seed solana rpc_node: %v", err)
}
return cleanup
}
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if got, err := resolveSolanaRpcURL(); err == nil {
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
}
}
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
defer cleanup()
got, err := resolveSolanaRpcURL()
if err != nil {
t.Fatalf("resolveSolanaRpcURL(): %v", err)
}
if got != "https://solana.example.com" {
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
}
}
func TestSolClientHealthy(t *testing.T) {
requireSolanaIntegration(t)
bodyData, err := SolRetryClient("getHealth", nil)
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
@@ -33,6 +79,8 @@ func TestSolClientHealthy(t *testing.T) {
}
func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t)
// Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
@@ -57,6 +105,8 @@ func TestSolClientGetSignaturesForAddress(t *testing.T) {
}
func TestSolClientGetTransaction(t *testing.T) {
requireSolanaIntegration(t)
// Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -64,7 +114,6 @@ func TestSolClientGetTransaction(t *testing.T) {
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
fmt.Printf("%v\n", string(txData))
var result map[string]interface{}
err = json.Unmarshal(txData, &result)
@@ -80,6 +129,15 @@ func TestSolClientGetTransaction(t *testing.T) {
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
}
func requireSolanaIntegration(t *testing.T) {
t.Helper()
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
}
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
t.Cleanup(cleanup)
}
func TestFindATAAddress(t *testing.T) {
tests := []struct {
name string
@@ -219,9 +277,7 @@ func TestAdjustAmount(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
@@ -261,9 +317,7 @@ func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -306,9 +360,7 @@ func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
+140 -276
View File
@@ -1,328 +1,159 @@
package service
import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"github.com/assimon/luuu/config"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/notify"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
const tronNodeDefault = "https://api.trongrid.io"
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
// if none exists it falls back to the hard-coded default URL and the
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
func resolveTronNode() (string, string) {
func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err == nil && node != nil && node.ID > 0 {
return strings.TrimRight(node.Url, "/"), node.ApiKey
if err != nil {
return "", "", err
}
return tronNodeDefault, config.TRON_GRID_API_KEY
if node == nil || node.ID == 0 {
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
if rpcURL == "" {
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, node.ApiKey, nil
}
func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
func ResolveTronNode() (string, string, error) {
return resolveTronNode()
}
func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
}
}()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
// Native TRX is gated by a chain_tokens row with empty
// contract_address and symbol=TRX. Admin can disable this row to
// stop scanning native transfers without touching the chain toggle.
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
if err != nil {
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
return
}
if trxCfg == nil || trxCfg.ID == 0 {
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
return
}
trxDecimals := trxCfg.Decimals
if trxDecimals <= 0 {
trxDecimals = 6
}
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil {
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
return
}
if resp.StatusCode() != http.StatusOK {
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
addr := strings.TrimSpace(toAddr)
if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
return
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
return
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue
}
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue
}
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
}
if tron.EncodeCheck(toBytes) != address {
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
continue
}
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
continue
return
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
return
}
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return
}
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
return
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
ReceiveAddress: addr,
Token: "USDT",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
}
}()
// Build contract -> token map for the TRON network. If nothing is
// configured, skip — preserves the previous behavior of only watching
// admin-approved tokens.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
return
}
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
for i := range tokens {
c := strings.TrimSpace(tokens[i].ContractAddress)
if c == "" {
continue
}
contractTokens[c] = &tokens[i]
}
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
return
}
if resp.StatusCode() != http.StatusOK {
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
addr := strings.TrimSpace(toAddr)
if addr == "" || rawSun <= 0 {
return
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
return
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
contractAddr := transfer.Get("token_info.address").String()
cfg, ok := contractTokens[contractAddr]
if !ok {
continue
}
if transfer.Get("to").String() != address {
continue
}
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
if tokenDecimals <= 0 {
tokenDecimals = int64(cfg.Decimals)
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
continue
}
decimalQuant := decimal.NewFromInt(rawSun)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
continue
return
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
return
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
continue
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return
}
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
return
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: tokenSym,
ReceiveAddress: addr,
Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
func evmChainLogLabel(chainNetwork string) string {
@@ -340,9 +171,6 @@ func evmChainLogLabel(chainNetwork string) string {
}
}
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
@@ -350,38 +178,67 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
}()
net := evmChainLogLabel(chainNetwork)
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
if err != nil {
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
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:
return
}
if token == nil || token.ID == 0 {
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
return
}
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
if tokenSym == "" {
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
return
}
decimals := token.Decimals
if decimals <= 0 {
decimals = 6
}
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("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
divisor := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
if token.MinAmount > 0 && amount < token.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
chainTokens, err := data.ListChainTokens(chainNetwork)
if err != nil {
log.Sugar.Warnf("[%s-%s] load chain tokens: %v", net, tokenSym, err)
return
}
var tokenConfig *mdb.ChainToken
for _, t := range chainTokens {
if strings.EqualFold(t.Symbol, tokenSym) {
tokenConfig = &t
break
}
}
if tokenConfig == nil || !tokenConfig.Enabled {
log.Sugar.Warnf("[%s-%s] token not enabled or configured in chain_tokens", net, tokenSym)
return
}
pow := decimal.New(1, int32(tokenConfig.Decimals))
log.Sugar.Warnf("tokenConfig.Decimals %d pow %s", tokenConfig.Decimals, pow.String())
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
@@ -412,11 +269,6 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
@@ -441,6 +293,18 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
func sendPaymentNotification(order *mdb.Orders) {
if order == nil {
return
}
if strings.TrimSpace(order.TradeId) != "" {
latest, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
} else if latest != nil && latest.TradeId != "" {
order = latest
}
}
msg := fmt.Sprintf(
"🎉 <b>收款成功通知</b>\n\n"+
"💰 <b>金额信息</b>\n"+
@@ -463,7 +327,7 @@ func sendPaymentNotification(order *mdb.Orders) {
networkDisplay(order.Network),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
order.UpdatedAt.ToDateTimeString(),
)
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
)
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const channelType = "test-pay-success-time"
got := make(chan string, 1)
notify.RegisterSender(channelType, func(config, text string) error {
got <- text
return nil
})
if err := dao.Mdb.Create(&mdb.NotificationChannel{
Type: channelType,
Name: "test",
Config: "{}",
Events: `{"pay_success":true}`,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed notification channel: %v", err)
}
order := &mdb.Orders{
TradeId: "T202604270001",
OrderId: "ORD202604270001",
Amount: 100,
Currency: "cny",
ActualAmount: 14.28,
Token: "USDT",
Network: mdb.NetworkTron,
ReceiveAddress: "TTestAddress",
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
const createdAt = "2026-04-27 09:00:00"
const staleUpdatedAt = "2026-04-27 09:01:00"
const paidAt = "2026-04-27 10:20:30"
if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
t.Fatalf("set initial timestamps: %v", err)
}
var staleOrderModel mdb.Orders
if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
t.Fatalf("load stale order: %v", err)
}
if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
t.Fatalf("set paid timestamp: %v", err)
}
sendPaymentNotification(&staleOrderModel)
select {
case text := <-got:
if !strings.Contains(text, "支付时间:"+paidAt) {
t.Fatalf("notification payment time = %q, want %s", text, paidAt)
}
if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
t.Fatalf("notification used stale payment time: %q", text)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for notification")
}
}
+14 -27
View File
@@ -3,26 +3,19 @@ package service
import (
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
// hard-coded default when the rpc_nodes table has no TRON row.
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
// TRON row from rpc_nodes.
func TestResolveTronNode_NoRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
@@ -32,8 +25,6 @@ func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
// Insert an enabled TRON http node.
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
@@ -48,7 +39,10 @@ func TestResolveTronNode_WithRow(t *testing.T) {
t.Fatalf("seed rpc_node: %v", err)
}
gotURL, gotKey := resolveTronNode()
gotURL, gotKey, err := resolveTronNode()
if err != nil {
t.Fatalf("resolveTronNode(): %v", err)
}
if gotURL != "https://custom-tron.example.com" {
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
}
@@ -57,14 +51,11 @@ func TestResolveTronNode_WithRow(t *testing.T) {
}
}
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored and
// the fallback is used.
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com",
@@ -82,11 +73,7 @@ func TestResolveTronNode_DisabledRow(t *testing.T) {
t.Fatalf("disable rpc_node: %v", err)
}
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package mq
import (
"sync"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
)
var (
+8 -8
View File
@@ -8,14 +8,14 @@ import (
"strings"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
)
// resolveOrderApiKey returns the api_keys row that signed the order.
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/sign"
)
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"encoding/json"
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
)
// Sender delivers an already-rendered text message via a channel row.
+89 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
@@ -118,6 +118,14 @@ func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token st
return rec
}
// doGet sends an unauthenticated GET request.
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// assertOK asserts the response status is 200 and the status_code field is 200.
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
@@ -273,6 +281,84 @@ func TestAdminChangePassword(t *testing.T) {
}
}
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
// and hash-based "password changed" detection flow.
func TestAdminInitPasswordFlow(t *testing.T) {
e := setupTestEnv(t)
const initPassword = "init-pass-123456"
adminHash, err := data.HashPassword(initPassword)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: adminHash,
Status: mdb.AdminUserStatusEnable,
})
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash := assertOK(t, recHash)
hashData, _ := respHash["data"].(map[string]interface{})
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
}
if got, _ := hashData["password_changed"].(bool); got {
t.Fatalf("expected password_changed=false before change, got true")
}
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{})
if fetchData["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
}
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest {
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
}
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
}
token := adminLogin(t, e, testAdminUsername, initPassword)
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{})
if got, _ := meData1["password_is_default"].(bool); !got {
t.Fatalf("expected password_is_default=true before change, got %v", got)
}
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
"old_password": initPassword,
"new_password": "new-pass-789",
}, token)
assertOK(t, recChange)
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash2 := assertOK(t, recHash2)
hashData2, _ := respHash2["data"].(map[string]interface{})
if got, _ := hashData2["password_changed"].(bool); !got {
t.Fatalf("expected password_changed=true after change, got %v", got)
}
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe2 := assertOK(t, recMe2)
meData2, _ := respMe2["data"].(map[string]interface{})
if got, _ := meData2["password_is_default"].(bool); got {
t.Fatalf("expected password_is_default=false after change, got %v", got)
}
}
// ─── API Keys ────────────────────────────────────────────────────────────────
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
+10 -8
View File
@@ -9,20 +9,20 @@ import (
"net/http"
"strconv"
"github.com/assimon/luuu/controller/admin"
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/controller/admin"
"github.com/GMWalletApp/epusdt/controller/comm"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
// RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error {
e.POST("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
})
@@ -188,6 +188,8 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated
authed := adminV1.Group("", middleware.CheckAdminJWT())
+30 -6
View File
@@ -10,11 +10,12 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
"github.com/spf13/viper"
"gorm.io/gorm/clause"
@@ -33,7 +34,6 @@ func setupTestEnv(t *testing.T) *echo.Echo {
viper.Set("app_uri", "http://localhost:8080")
viper.Set("order_expiration_time", 10)
viper.Set("api_rate_url", "")
viper.Set("forced_usdt_rate", 7.0)
viper.Set("runtime_root_path", tmpDir)
viper.Set("log_save_path", tmpDir)
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
@@ -68,6 +68,15 @@ func setupTestEnv(t *testing.T) *echo.Echo {
// reset the settings cache so stale entries from a prior test don't leak
_ = data.ReloadSettings()
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
t.Cleanup(func() {
config.SettingsGetString = nil
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
// seed wallet addresses
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
@@ -158,6 +167,21 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
return rec
}
func TestRootPostRoute(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if body := rec.Body.String(); body != "hello epusdt, https://github.com/GMwalletApp/epusdt" {
t.Fatalf("unexpected body: %q", body)
}
}
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
func TestCreateOrderGmpayV1Solana(t *testing.T) {
e := setupTestEnv(t)
+7 -9
View File
@@ -1,7 +1,7 @@
package task
import (
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/robfig/cron/v3"
)
@@ -13,22 +13,19 @@ func Start() {
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
go StartTronBlockScannerListener()
c := cron.New()
// TRC20 polling
_, 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 polling
_, 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
}
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
// RPC node health checks
_, err = c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
@@ -36,6 +33,7 @@ func Start() {
return
}
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"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/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const bscDefaultWsURL = "wss://bsc.drpc.org"
type bscRecipientSnapshot struct {
addrs map[string]struct{}
}
@@ -68,7 +66,10 @@ func runBscListener(contracts []common.Address) {
}
}()
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL)
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
if !ok {
return
}
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
+18 -8
View File
@@ -6,9 +6,9 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum/common"
)
@@ -90,12 +90,22 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
}
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
// given network, falling back to the provided default (usually the
// public node URL) when no row is configured.
func resolveChainWsURL(network, fallback string) string {
// given network. If no enabled node is configured, the caller skips the
// current listener run so admin-side disabled/deleted rows are respected.
func resolveChainWsURL(network, logPrefix string) (string, bool) {
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 {
return node.Url
rpcURL := strings.TrimSpace(node.Url)
if rpcURL != "" {
return rpcURL, true
}
return fallback
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
return "", false
}
if err != nil {
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
} else {
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
}
return "", false
}
+67
View File
@@ -0,0 +1,67 @@
package task
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
}
}
func TestResolveChainWsURLWithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
node := &mdb.RpcNode{
Network: mdb.NetworkEthereum,
Url: " wss://ethereum.example.com ",
Type: mdb.RpcNodeTypeWs,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
if !ok {
t.Fatalf("resolveChainWsURL() ok=false, want true")
}
if got != "wss://ethereum.example.com" {
t.Fatalf("resolveChainWsURL() = %q, want wss://ethereum.example.com", got)
}
}
func TestResolveChainWsURLDisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
node := &mdb.RpcNode{
Network: mdb.NetworkEthereum,
Url: "wss://disabled.example.com",
Type: mdb.RpcNodeTypeWs,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
t.Fatalf("disable rpc_node: %v", err)
}
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
}
}
+8 -23
View File
@@ -7,11 +7,10 @@ import (
"sync/atomic"
"time"
"github.com/assimon/luuu/config"
"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/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -22,22 +21,6 @@ import (
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
// legacy .env value (if set) before the shared default — keeps existing
// deployments working until the admin adds an rpc_nodes row.
func resolveEthereumWsURL() string {
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 {
return node.Url
}
if u := config.GetEthereumWsUrl(); u != "" {
return u
}
return ethereumDefaultWsURL
}
type ethRecipientSnapshot struct {
addrs map[string]struct{}
}
@@ -88,7 +71,10 @@ func runEthereumListener(contracts []common.Address) {
}
}()
wsURL := resolveEthereumWsURL()
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
if !ok {
return
}
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
@@ -145,4 +131,3 @@ func isWatchedEthRecipient(to common.Address) bool {
_, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types"
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"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/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
type plasmaRecipientSnapshot struct {
addrs map[string]struct{}
}
@@ -67,7 +65,10 @@ func runPlasmaListener(contracts []common.Address) {
}
}()
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL)
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
if !ok {
return
}
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"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/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
type polygonRecipientSnapshot struct {
addrs map[string]struct{}
}
@@ -67,7 +65,10 @@ func runPolygonListener(contracts []common.Address) {
}
}()
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL)
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
if !ok {
return
}
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
+4 -4
View File
@@ -3,10 +3,10 @@ package task
import (
"sync"
"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/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
)
type ListenSolJob struct{}
+378
View File
@@ -0,0 +1,378 @@
package task
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
)
const (
// USDT 合约地址 (TRC20 主网)
USDTContractHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" // Base58: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
// transfer(address,uint256) 方法签名前4字节
TransferMethodID = "a9059cbb"
PollInterval = 3 * time.Second
RequestTimeout = 10 * time.Second
)
func HexToTronAddress(hexAddr string) (string, error) {
hexAddr = strings.TrimPrefix(hexAddr, "0x")
hexAddr = strings.TrimPrefix(hexAddr, "0X")
raw, err := hex.DecodeString(hexAddr)
if err != nil {
return "", err
}
// 确保有 0x41 前缀(TRON 主网地址前缀)
if len(raw) == 20 {
raw = append([]byte{0x41}, raw...)
}
if len(raw) != 21 {
return "", fmt.Errorf("地址长度非法: %d bytes", len(raw))
}
return tron.EncodeCheck(raw), nil
}
type BlockHeader struct {
RawData struct {
Number int64 `json:"number"`
Timestamp int64 `json:"timestamp"`
WitnessAddress string `json:"witness_address"`
ParentHash string `json:"parentHash"`
Version int `json:"version"`
} `json:"raw_data"`
}
type TriggerSmartContractValue struct {
OwnerAddress string `json:"owner_address"`
ContractAddress string `json:"contract_address"`
Data string `json:"data"`
CallValue int64 `json:"call_value"`
}
type ContractParam struct {
TypeURL string `json:"type_url"`
Value json.RawMessage `json:"value"`
}
type Transaction struct {
TxID string `json:"txID"`
RawData struct {
Contract []struct {
Type string `json:"type"`
Parameter ContractParam `json:"parameter"`
} `json:"contract"`
Timestamp int64 `json:"timestamp"`
FeeLimit int64 `json:"fee_limit"`
} `json:"raw_data"`
Ret []struct {
ContractRet string `json:"contractRet"`
} `json:"ret"`
}
type Block struct {
BlockID string `json:"blockID"`
BlockHeader BlockHeader `json:"block_header"`
Transactions []Transaction `json:"transactions"`
}
type USDTTransfer struct {
TxID string
From string
To string
Raw *big.Int // 原始数值(6 decimals
Status string
}
type TRXTransfer struct {
TxID string
From string
To string
RawSun int64 // 单位: SUN
Status string
}
type TransferContractValue struct {
OwnerAddress string `json:"owner_address"`
ToAddress string `json:"to_address"`
Amount int64 `json:"amount"` // 单位: SUN
}
var httpClient = &http.Client{Timeout: RequestTimeout}
func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
data, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", apiKey)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
}
return b, nil
}
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
if err != nil {
return nil, err
}
var block Block
return &block, json.Unmarshal(b, &block)
}
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
if err != nil {
return nil, err
}
var block Block
return &block, json.Unmarshal(b, &block)
}
func parseUSDTTransfer(tx Transaction) *USDTTransfer {
if len(tx.RawData.Contract) == 0 {
return nil
}
c := tx.RawData.Contract[0]
if c.Type != "TriggerSmartContract" {
return nil
}
var val TriggerSmartContractValue
if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
return nil
}
// 检查是否是 USDT 合约
contractHex := strings.ToLower(strings.TrimPrefix(val.ContractAddress, "0x"))
if contractHex != USDTContractHex {
return nil
}
// 解析 data 字段
// 格式: [4字节方法ID][32字节 to 地址][32字节 amount]
data := strings.TrimPrefix(strings.ToLower(val.Data), "0x")
if len(data) < 8+64+64 {
return nil
}
if data[:8] != TransferMethodID {
return nil
}
// to 地址:后 40 个十六进制字符(20 字节)
toHex := data[8+24 : 8+64] // 跳过前12字节填充,取后20字节
toAddr, err := HexToTronAddress(toHex)
if err != nil {
return nil
}
// amount:后 32 字节大整数
amountHex := data[8+64 : 8+64+64]
amountBytes, err := hex.DecodeString(amountHex)
if err != nil {
return nil
}
amountBig := new(big.Int).SetBytes(amountBytes)
// from 地址
fromAddr, err := HexToTronAddress(val.OwnerAddress)
if err != nil {
fromAddr = val.OwnerAddress
}
// 交易状态
status := "SUCCESS"
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
status = tx.Ret[0].ContractRet
}
return &USDTTransfer{
TxID: tx.TxID,
From: fromAddr,
To: toAddr,
Raw: amountBig,
Status: status,
}
}
func parseTRXTransfer(tx Transaction) *TRXTransfer {
if len(tx.RawData.Contract) == 0 {
return nil
}
c := tx.RawData.Contract[0]
if c.Type != "TransferContract" {
return nil
}
var val TransferContractValue
if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
return nil
}
if val.Amount <= 0 {
return nil
}
fromAddr, err := HexToTronAddress(val.OwnerAddress)
if err != nil {
fromAddr = val.OwnerAddress
}
toAddr, err := HexToTronAddress(val.ToAddress)
if err != nil {
toAddr = val.ToAddress
}
status := "SUCCESS"
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
status = tx.Ret[0].ContractRet
}
return &TRXTransfer{
TxID: tx.TxID,
From: fromAddr,
To: toAddr,
RawSun: val.Amount,
Status: status,
}
}
func processBlock(block *Block) {
blockTsMs := block.BlockHeader.RawData.Timestamp
for _, tx := range block.Transactions {
if t := parseUSDTTransfer(tx); t != nil {
if t.Status != "SUCCESS" {
continue
}
service.TryProcessTronTRC20Transfer(t.To, t.Raw, t.TxID, blockTsMs)
continue
}
if t := parseTRXTransfer(tx); t != nil {
if t.Status != "SUCCESS" {
continue
}
service.TryProcessTronTRXTransfer(t.To, t.RawSun, t.TxID, blockTsMs)
}
}
}
type Scanner struct {
baseURL string
apiKey string
lastBlock int64
// 统计
totalBlocks int64
totalUSDTTxs int64
totalTRXTxs int64
}
func NewScanner() *Scanner {
return &Scanner{}
}
func (s *Scanner) Init() error {
baseURL, apiKey, err := service.ResolveTronNode()
if err != nil {
return fmt.Errorf("resolve tron node: %w", err)
}
s.baseURL = baseURL
s.apiKey = apiKey
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
block, err := GetNowBlock(s.baseURL, s.apiKey)
if err != nil {
return fmt.Errorf("获取初始块失败: %w", err)
}
s.lastBlock = block.BlockHeader.RawData.Number
log.Sugar.Infof("[TRON-BLOCK] start block=%d", s.lastBlock)
return nil
}
func (s *Scanner) Run() {
log.Sugar.Info("[TRON-BLOCK] start scanning (USDT TRC20 + TRX)")
ticker := time.NewTicker(PollInterval)
defer ticker.Stop()
statTicker := time.NewTicker(60 * time.Second)
defer statTicker.Stop()
for {
select {
case <-statTicker.C:
log.Sugar.Infof("[TRON-BLOCK] stats blocks=%d usdt=%d trx=%d", s.totalBlocks, s.totalUSDTTxs, s.totalTRXTxs)
case <-ticker.C:
s.poll()
}
}
}
func (s *Scanner) poll() {
latest, err := GetNowBlock(s.baseURL, s.apiKey)
if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
return
}
latestNum := latest.BlockHeader.RawData.Number
if latestNum <= s.lastBlock {
return
}
for num := s.lastBlock + 1; num <= latestNum; num++ {
var block *Block
if num == latestNum {
block = latest
} else {
block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
continue
}
time.Sleep(200 * time.Millisecond)
}
processBlock(block)
s.lastBlock = num
s.totalBlocks++
for _, tx := range block.Transactions {
if parseUSDTTransfer(tx) != nil {
s.totalUSDTTxs++
} else if parseTRXTransfer(tx) != nil {
s.totalTRXTxs++
}
}
}
}
func StartTronBlockScannerListener() {
scanner := NewScanner()
if err := scanner.Init(); err != nil {
log.Sugar.Errorf("[TRON-BLOCK] init: %v", err)
return
}
scanner.Run()
}
-43
View File
@@ -1,43 +0,0 @@
package task
import (
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
)
type ListenTrc20Job struct{}
var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
if !data.IsChainEnabled(mdb.NetworkTron) {
log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
return
}
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")
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
)
const rpcProbeTimeout = 5 * time.Second
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// --------------- ParseAddress ---------------
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/goutil/mathutil"
"github.com/gookit/goutil/strutil"
tb "gopkg.in/telebot.v3"
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/log"
tb "gopkg.in/telebot.v3"
"gopkg.in/telebot.v3/middleware"
)
+5 -5
View File
@@ -3,11 +3,11 @@ package telegram
import (
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"strings"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/btcsuite/btcutil/base58"
"github.com/gagliardetto/solana-go"
)
+2 -2
View File
@@ -1,8 +1,8 @@
package http
import (
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/page"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/page"
"github.com/labstack/echo/v4"
"net/http"
)
+19
View File
@@ -6,6 +6,25 @@ import (
"strings"
)
var spaFallbackDenyPrefixes = []string{
"/api",
"/admin/api",
"/payments",
"/pay",
}
// ShouldSkipSPAFallback reports whether a request path should bypass
// server-side SPA fallback to index.html and continue normal backend routing.
func ShouldSkipSPAFallback(requestPath string) bool {
for _, prefix := range spaFallbackDenyPrefixes {
if requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
return true
}
}
return false
}
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
// It strips any leading slash and blocks path traversal outside wwwRoot.
// The second return value indicates whether the caller should try os.Stat
+29
View File
@@ -66,3 +66,32 @@ func TestResolveSPAFilePath(t *testing.T) {
})
}
}
func TestShouldSkipSPAFallback(t *testing.T) {
tests := []struct {
name string
path string
want bool
}{
{name: "api root", path: "/api", want: true},
{name: "api sub path", path: "/api/install/defaults", want: true},
{name: "admin api root", path: "/admin/api", want: true},
{name: "admin api sub path", path: "/admin/api/v1/orders", want: true},
{name: "payments root", path: "/payments", want: true},
{name: "payments sub path", path: "/payments/gmpay/v1/order/create-transaction", want: true},
{name: "pay root", path: "/pay", want: true},
{name: "pay sub path", path: "/pay/checkout-counter/abc", want: true},
{name: "not exact prefix", path: "/apiary", want: false},
{name: "normal spa route", path: "/install", want: false},
{name: "normal asset route", path: "/assets/index.js", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldSkipSPAFallback(tt.path)
if got != tt.want {
t.Fatalf("ShouldSkipSPAFallback(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
+4 -1
View File
@@ -6,9 +6,12 @@ import (
"github.com/go-resty/resty/v2"
)
// ClientFactory is overridden in tests to stub outbound HTTP calls.
var ClientFactory = resty.New
// GetHttpClient 获取请求客户端
func GetHttpClient(proxys ...string) *resty.Client {
client := resty.New()
client := ClientFactory()
// 如果有代理
if len(proxys) > 0 {
proxy := proxys[0]
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"errors"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/golang-jwt/jwt/v4"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package log
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/natefinch/lumberjack"
"github.com/spf13/viper"
"go.uber.org/zap"
+1 -1
View File
@@ -2,7 +2,7 @@ package sign
import (
"errors"
"github.com/assimon/luuu/util/json"
"github.com/GMWalletApp/epusdt/util/json"
"github.com/gookit/goutil/strutil"
"reflect"
"sort"
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-CxmfHb-a.js";var t=e;export{t as component};

Some files were not shown because too many files have changed in this diff Show More