mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
24 Commits
4a69b7c863
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| cd44c7c818 | |||
| c69b0d6c0c | |||
| 13c81ee560 | |||
| c38f728e10 | |||
| b70995f8e9 | |||
| fca75a99ea | |||
| 8bf2adad89 | |||
| 0d97237c71 | |||
| c65b07cfe7 | |||
| 7fcb7b38fe | |||
| 2a00a0332b | |||
| 8a48611625 | |||
| dec1b97b9e | |||
| 449d22a41b | |||
| 4b4701f5b9 | |||
| f4ef71b359 | |||
| c19a7e5f2c | |||
| 09642d4c2b | |||
| 9f9b258d59 | |||
| 85b81e661c | |||
| 049a15cb95 | |||
| 2a36a10e7f | |||
| 864eb5969c | |||
| b0093c48c7 |
@@ -1,11 +1,7 @@
|
||||
name: docker-alpine-epusdt
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "master"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
@@ -34,7 +30,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -53,5 +48,6 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||
BUILD_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
+2
-1
@@ -8,8 +8,9 @@ WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app/src
|
||||
ARG BUILD_VERSION=0.0.0-dev
|
||||
RUN go mod download
|
||||
RUN go build -o /app/epusdt .
|
||||
RUN go build -trimpath -ldflags="-s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion=${BUILD_VERSION}" -o /app/epusdt .
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
| 领域 | 已支持系统 |
|
||||
|------|-----------|
|
||||
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
||||
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
||||
|
||||
@@ -15,7 +15,7 @@ builds:
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
|
||||
- -s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion={{ .Tag }} -X github.com/GMWalletApp/epusdt/config.BuildCommit={{ .Commit }} -X github.com/GMWalletApp/epusdt/config.BuildDate={{ .Date }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
|
||||
+20
-23
@@ -8,7 +8,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,12 +16,6 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// parseFloat is the minimal helper used by the settings bridge; kept
|
||||
// here so settings_bridge.go stays import-free.
|
||||
func parseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
var (
|
||||
HTTPAccessLog bool
|
||||
SQLDebug bool
|
||||
@@ -353,21 +346,25 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
if coin == base {
|
||||
return 1
|
||||
}
|
||||
if coin == "usdt" {
|
||||
switch base {
|
||||
case "usd":
|
||||
return 1
|
||||
case "cny":
|
||||
usdtRate := GetUsdtRate()
|
||||
if usdtRate > 0 {
|
||||
return 1 / usdtRate
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
|
||||
return forcedRate
|
||||
}
|
||||
if coin == "usdt" && base == "usd" {
|
||||
return 1
|
||||
}
|
||||
return getRateForCoinFromAPI(coin, base)
|
||||
}
|
||||
|
||||
func getForcedRateForCoin(coin string, base string) float64 {
|
||||
raw := strings.TrimSpace(settingsForcedRateList())
|
||||
if raw != "" {
|
||||
if rate := gjson.Get(raw, base+"."+coin).Float(); rate > 0 {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||
baseURL := GetRateApiUrl()
|
||||
if baseURL == "" {
|
||||
@@ -401,17 +398,17 @@ func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||
}
|
||||
|
||||
func GetUsdtRate() float64 {
|
||||
// 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
|
||||
// rate.forced_rate_list stores token amount per one base currency unit.
|
||||
// GetUsdtRate returns the inverse display value: CNY per USDT.
|
||||
if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
|
||||
return 1 / forcedRate
|
||||
}
|
||||
|
||||
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||
if apiRate > 0 {
|
||||
return 1 / apiRate
|
||||
}
|
||||
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
|
||||
log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||
func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
@@ -249,16 +249,19 @@ func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "7.25",
|
||||
"rate.forced_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
if got != 7.25 {
|
||||
if math.Abs(got-7.25) > 1e-9 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||
}
|
||||
if rate := GetRateForCoin("trx", "usd"); rate != 123.45 {
|
||||
t.Fatalf("GetRateForCoin(trx, usd) = %v, want 123.45", rate)
|
||||
}
|
||||
if apiCalled {
|
||||
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
|
||||
t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +284,7 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "-1",
|
||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
@@ -297,6 +300,35 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRateForCoinUsesAPIWhenForcedPairMissing(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_rate_list": `{"usd":{"trx":123.45}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -313,7 +345,7 @@ func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
@@ -343,7 +375,7 @@ func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
|
||||
@@ -18,17 +18,9 @@ func settingsRateApiUrl() string {
|
||||
return SettingsGetString("rate.api_url")
|
||||
}
|
||||
|
||||
func settingsForcedUsdtRate() float64 {
|
||||
func settingsForcedRateList() string {
|
||||
if SettingsGetString == nil {
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
raw := SettingsGetString("rate.forced_usdt_rate")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := parseFloat(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
return SettingsGetString("rate.forced_rate_list")
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package admin
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||
req := new(CreateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -150,11 +150,11 @@ func isUniqueViolation(err error) bool {
|
||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
@@ -187,11 +187,11 @@ func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(ChangeApiKeyStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -215,7 +215,7 @@ func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
secret := randomHex(32)
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||
@@ -239,7 +239,7 @@ func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -262,14 +262,14 @@ func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"call_count": row.CallCount,
|
||||
@@ -291,14 +291,14 @@ func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -53,7 +54,7 @@ type InitialPasswordResponse struct {
|
||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
req := new(LoginRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -63,10 +64,10 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
return c.FailJson(ctx, errors.New("invalid username or password"))
|
||||
return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
|
||||
}
|
||||
if user.Status != mdb.AdminUserStatusEnable {
|
||||
return c.FailJson(ctx, errors.New("user disabled"))
|
||||
return c.FailJson(ctx, constant.AdminUserDisabledErr)
|
||||
}
|
||||
token, err := appjwt.Sign(user.ID, user.Username)
|
||||
if err != nil {
|
||||
@@ -94,6 +95,9 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||
password, err := data.ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
||||
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
||||
}
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, InitialPasswordResponse{
|
||||
@@ -144,14 +148,14 @@ func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("user not found"))
|
||||
return c.FailJson(ctx, constant.AdminUserNotFoundErr)
|
||||
}
|
||||
// Warn the frontend when the operator hasn't changed the seeded
|
||||
// initial password.
|
||||
@@ -176,11 +180,11 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||
}
|
||||
req := new(ChangePasswordRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -190,7 +194,7 @@ func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||
return c.FailJson(ctx, errors.New("old password incorrect"))
|
||||
return c.FailJson(ctx, constant.AdminOldPasswordErr)
|
||||
}
|
||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -49,7 +50,7 @@ func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
||||
network := ctx.Param("network")
|
||||
req := new(UpdateChainRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Enabled != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -65,7 +66,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||
req := new(CreateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -107,11 +108,11 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.ContractAddress != nil {
|
||||
@@ -147,11 +148,11 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(ChangeChainTokenStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -172,7 +173,7 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
|
||||
@@ -49,16 +49,18 @@ type RotateSecretResponse struct {
|
||||
|
||||
// BatchImportResult is one row in the batch import response.
|
||||
type BatchImportResult struct {
|
||||
Address string `json:"address" example:"TTestTronAddress001"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
Address string `json:"address" example:"TTestTronAddress001"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
ErrorCode int `json:"error_code,omitempty" example:"10001"`
|
||||
Error string `json:"error,omitempty" example:"wallet address already exists"`
|
||||
}
|
||||
|
||||
// SettingsUpsertResult is one row in the settings upsert response.
|
||||
type SettingsUpsertResult struct {
|
||||
Key string `json:"key" example:"rate.api_url"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
Key string `json:"key" example:"rate.api_url"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
ErrorCode int `json:"error_code,omitempty" example:"10043"`
|
||||
Error string `json:"error,omitempty" example:"invalid setting item"`
|
||||
}
|
||||
|
||||
// Overview returns the top-card numbers for the dashboard landing page.
|
||||
|
||||
@@ -2,7 +2,6 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||
req := new(CreateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
@@ -120,7 +120,7 @@ func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error
|
||||
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
@@ -130,7 +130,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
||||
|
||||
req := new(UpdateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
@@ -175,7 +175,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
@@ -185,7 +185,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
||||
|
||||
req := new(ChangeChannelStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -209,7 +209,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
@@ -230,12 +230,12 @@ func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error
|
||||
// + chat_id; webhook/email kept loose until those senders exist.
|
||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config required")
|
||||
return constant.NotificationConfigErr
|
||||
}
|
||||
if channelType == mdb.NotificationTypeTelegram {
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||
return err
|
||||
return constant.NotificationConfigErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -260,10 +260,10 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
case "1", "true", "on", "yes":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("invalid boolean string")
|
||||
return false, constant.NotificationEventsErr
|
||||
}
|
||||
default:
|
||||
return false, errors.New("invalid event value type")
|
||||
return false, constant.NotificationEventsErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
for _, v := range t {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("events array must contain strings")
|
||||
return nil, constant.NotificationEventsErr
|
||||
}
|
||||
s = trimKey(s)
|
||||
if s == "" {
|
||||
@@ -309,6 +309,6 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, errors.New("events must be object or string array")
|
||||
return nil, constant.NotificationEventsErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,14 +12,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// MarkPaidRequest is the payload for manually marking an order as paid.
|
||||
type MarkPaidRequest struct {
|
||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||
}
|
||||
|
||||
// OrderListResponse wraps the paginated order list.
|
||||
type OrderListResponse struct {
|
||||
List []mdb.Orders `json:"list"`
|
||||
@@ -92,7 +88,7 @@ func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
return c.SucJson(ctx, order)
|
||||
}
|
||||
@@ -114,17 +110,17 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
||||
}
|
||||
ok, err := data.CloseOrderManually(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
||||
return c.FailJson(ctx, constant.OrderStatusConflict)
|
||||
}
|
||||
// Release the transaction lock so the amount slot becomes reusable.
|
||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
@@ -141,41 +137,29 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
||||
// @Param request body request.ManualPaymentRequest true "Block transaction ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
req := new(MarkPaidRequest)
|
||||
adminUserID := currentAdminUserID(ctx)
|
||||
req := new(request.ManualPaymentRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
}
|
||||
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Currency: order.Currency,
|
||||
Token: order.Token,
|
||||
Network: order.Network,
|
||||
Amount: order.ActualAmount,
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: req.BlockTransactionId,
|
||||
})
|
||||
resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] mark-paid failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, resp.BlockTransactionId)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
@@ -193,13 +177,38 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
adminUserID := currentAdminUserID(ctx)
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] resend-callback load failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
err = constant.OrderNotExists
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.Status != mdb.StatusPaySuccess {
|
||||
err = constant.OrderCallbackNotApplicable
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if strings.TrimSpace(order.NotifyUrl) == "" {
|
||||
err = constant.OrderNotifyURLEmptyErr
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
ok, err := data.ReopenOrderCallback(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] resend-callback reopen failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
||||
err = constant.OrderResendCallbackErr
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
log.Sugar.Infof("[admin-order] resend-callback queued admin_user_id=%d trade_id=%s", adminUserID, tradeID)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
@@ -380,7 +389,7 @@ func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||
type CreateRpcNodeRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
||||
Weight int `json:"weight" example:"1"`
|
||||
ApiKey string `json:"api_key" example:""`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||
Purpose string `json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||
}
|
||||
|
||||
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||
@@ -28,6 +31,7 @@ type UpdateRpcNodeRequest struct {
|
||||
Weight *int `json:"weight" example:"1"`
|
||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
Purpose *string `json:"purpose" enums:"general,manual_verify,both" example:"manual_verify"`
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
@@ -64,7 +68,7 @@ func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
req := new(CreateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -77,13 +81,23 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
purpose, err := rpcNodePurposeFromRequest(req.Purpose)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
nodeURL := strings.TrimSpace(req.Url)
|
||||
nodeType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := validateRpcNodeURLForType(nodeURL, nodeType); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row := &mdb.RpcNode{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Url: strings.TrimSpace(req.Url),
|
||||
Type: strings.ToLower(strings.TrimSpace(req.Type)),
|
||||
Url: nodeURL,
|
||||
Type: nodeType,
|
||||
Weight: weight,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: enabled,
|
||||
Purpose: purpose,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
LastLatencyMs: -1,
|
||||
}
|
||||
@@ -93,7 +107,7 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateRpcNode patches url/weight/api_key/enabled.
|
||||
// UpdateRpcNode patches url/weight/api_key/enabled/purpose.
|
||||
// @Summary Update RPC node
|
||||
// @Description Patch RPC node fields
|
||||
// @Tags Admin RPC Nodes
|
||||
@@ -108,15 +122,26 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Url != nil {
|
||||
fields["url"] = *req.Url
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||
}
|
||||
nodeURL := strings.TrimSpace(*req.Url)
|
||||
if err := validateRpcNodeURLForType(nodeURL, row.Type); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields["url"] = nodeURL
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
@@ -127,6 +152,13 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.Purpose != nil {
|
||||
purpose, err := rpcNodePurposeFromRequest(*req.Purpose)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields["purpose"] = purpose
|
||||
}
|
||||
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
@@ -146,7 +178,7 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -170,14 +202,14 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("node not found"))
|
||||
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||
}
|
||||
status, latency := task.ProbeNode(row.Url)
|
||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||
@@ -188,3 +220,40 @@ func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||
"last_latency_ms": latency,
|
||||
})
|
||||
}
|
||||
|
||||
func rpcNodePurposeFromRequest(raw string) (string, error) {
|
||||
purpose := strings.ToLower(strings.TrimSpace(raw))
|
||||
if purpose == "" {
|
||||
return mdb.RpcNodePurposeGeneral, nil
|
||||
}
|
||||
switch purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeManualVerify, mdb.RpcNodePurposeBoth:
|
||||
return purpose, nil
|
||||
default:
|
||||
return "", constant.RpcNodePurposeErr
|
||||
}
|
||||
}
|
||||
|
||||
func validateRpcNodeURLForType(rawURL string, nodeType string) error {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
nodeType = strings.ToLower(strings.TrimSpace(nodeType))
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return constant.RpcNodeURLErr
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch nodeType {
|
||||
case mdb.RpcNodeTypeHttp:
|
||||
if scheme == "http" || scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
return constant.RpcNodeHTTPURLErr
|
||||
case mdb.RpcNodeTypeWs:
|
||||
if scheme == "ws" || scheme == "wss" {
|
||||
return nil
|
||||
}
|
||||
return constant.RpcNodeWebSocketURLErr
|
||||
default:
|
||||
return constant.RpcNodeTypeErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/security"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -16,8 +19,8 @@ import (
|
||||
// Supported groups and keys:
|
||||
//
|
||||
// - group=rate:
|
||||
// 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.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase
|
||||
// rate.api_url (string) — external rate API URL used when no positive forced rate exists
|
||||
// rate.adjust_percent (float) — rate adjustment percentage
|
||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||
//
|
||||
@@ -52,10 +55,10 @@ import (
|
||||
// system.order_expiration_time (int) — order expiry in minutes
|
||||
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
||||
type SettingUpsertItem struct {
|
||||
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value string `json:"value" example:"tron"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value interface{} `json:"value"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
}
|
||||
|
||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||
@@ -88,11 +91,11 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
// independently so a malformed row in the middle doesn't drop earlier
|
||||
// ones. Errors are returned per-key so the UI can surface them.
|
||||
// @Summary Upsert settings
|
||||
// @Description Batch insert/update settings. Returns per-key status.
|
||||
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
||||
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
||||
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
|
||||
// @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 rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
||||
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2).
|
||||
// @Tags Admin Settings
|
||||
@@ -106,33 +109,48 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||
req := new(SettingsUpsertRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Key string `json:"key"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Key string `json:"key"`
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Items))
|
||||
errorResult := func(key string, err error) result {
|
||||
code, msg := constant.ResolveErrno(err)
|
||||
return result{Key: key, OK: false, ErrorCode: code, Error: msg}
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
|
||||
out = append(out, errorResult(item.Key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
if err := validateSettingItem(item.Group, key, item.Value); err != nil {
|
||||
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||
value, err := normalizeSettingValue(item.Value)
|
||||
if err != nil {
|
||||
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
value, err = normalizeAndValidateSettingItem(item.Group, key, value)
|
||||
if err != nil {
|
||||
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
if key == mdb.SettingKeyAmountPrecision {
|
||||
item.Group = mdb.SettingGroupSystem
|
||||
item.Type = mdb.SettingTypeInt
|
||||
}
|
||||
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
|
||||
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||
if key == mdb.SettingKeyRateForcedRateList {
|
||||
item.Group = mdb.SettingGroupRate
|
||||
item.Type = mdb.SettingTypeJSON
|
||||
}
|
||||
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
||||
out = append(out, errorResult(key, err))
|
||||
continue
|
||||
}
|
||||
out = append(out, result{Key: key, OK: true})
|
||||
@@ -159,21 +177,98 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
func validateSettingItem(group, key, value string) error {
|
||||
func normalizeSettingValue(value interface{}) (string, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case string:
|
||||
return v, nil
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("value must be JSON serializable")
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
|
||||
switch key {
|
||||
case mdb.SettingKeyAmountPrecision:
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||
return fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||
}
|
||||
precision, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be an integer", key)
|
||||
return value, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
|
||||
return fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
|
||||
return value, fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
|
||||
}
|
||||
case mdb.SettingKeyRateForcedRateList:
|
||||
normalized, err := normalizeForcedRateListSetting(group, key, value)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
return normalized, nil
|
||||
case mdb.SettingKeyRateApiUrl:
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||
}
|
||||
if err := security.ValidatePublicHTTPURL(value); err != nil {
|
||||
return value, fmt.Errorf("%s invalid: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeForcedRateListSetting(group, key, value string) (string, error) {
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var rates map[string]map[string]float64
|
||||
if err := json.Unmarshal([]byte(value), &rates); err != nil {
|
||||
return value, fmt.Errorf("%s must be valid JSON object", key)
|
||||
}
|
||||
|
||||
normalized := make(map[string]map[string]float64, len(rates))
|
||||
for base, coins := range rates {
|
||||
normalizedBase := strings.ToLower(strings.TrimSpace(base))
|
||||
if normalizedBase == "" {
|
||||
return value, fmt.Errorf("%s base currency must not be empty", key)
|
||||
}
|
||||
if coins == nil {
|
||||
return value, fmt.Errorf("%s.%s must be an object", key, base)
|
||||
}
|
||||
normalizedCoins, ok := normalized[normalizedBase]
|
||||
if !ok {
|
||||
normalizedCoins = make(map[string]float64, len(coins))
|
||||
normalized[normalizedBase] = normalizedCoins
|
||||
}
|
||||
for coin, rate := range coins {
|
||||
normalizedCoin := strings.ToLower(strings.TrimSpace(coin))
|
||||
if normalizedCoin == "" {
|
||||
return value, fmt.Errorf("%s coin must not be empty", key)
|
||||
}
|
||||
if rate < 0 {
|
||||
return value, fmt.Errorf("%s.%s.%s must be >= 0", key, base, coin)
|
||||
}
|
||||
if _, exists := normalizedCoins[normalizedCoin]; exists {
|
||||
return value, fmt.Errorf("%s.%s.%s is duplicated after normalization", key, normalizedBase, normalizedCoin)
|
||||
}
|
||||
normalizedCoins[normalizedCoin] = rate
|
||||
}
|
||||
}
|
||||
|
||||
normalizedValue, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return value, fmt.Errorf("%s must be JSON serializable", key)
|
||||
}
|
||||
return string(normalizedValue), nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes one row. The next read of that key will fall
|
||||
@@ -197,7 +292,3 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Public helper for the rate/usdt overrides — used by config package to
|
||||
// read settings-backed values without importing the controller package.
|
||||
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||
req := new(AdminAddWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -140,14 +140,14 @@ func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
wallet, err := data.GetWalletAddressById(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if wallet.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("wallet not found"))
|
||||
return c.FailJson(ctx, constant.WalletNotFoundErr)
|
||||
}
|
||||
counts, _ := data.CountOrdersByAddress()
|
||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||
@@ -168,11 +168,11 @@ func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(AdminUpdateWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Remark != nil {
|
||||
@@ -202,11 +202,11 @@ func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(AdminChangeWalletStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -230,7 +230,7 @@ func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
@@ -242,7 +242,7 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
// them all as observation wallets. Per-row failures are collected and
|
||||
// returned so the UI can surface which addresses already existed.
|
||||
// @Summary Batch import wallets
|
||||
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
||||
// @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
@@ -254,21 +254,23 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||
req := new(AdminBatchImportRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Address string `json:"address"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Address string `json:"address"`
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Addresses))
|
||||
for _, addr := range req.Addresses {
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||
if err != nil {
|
||||
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
||||
code, msg := constant.ResolveErrno(err)
|
||||
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
|
||||
continue
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/gookit/validate"
|
||||
"github.com/gookit/validate/locales/zhcn"
|
||||
@@ -46,6 +46,6 @@ func (c *BaseController) ValidateStruct(ctx echo.Context, i interface{}, scene .
|
||||
if v.Validate() {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New(v.Errors.One())
|
||||
return constant.ParamsMarshalErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param payment_type formData string false "Payment type"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
|
||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
@@ -68,7 +68,7 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
// @Produce json
|
||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
||||
// @Router /pay/switch-network [post]
|
||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
req := new(request.SwitchNetworkRequest)
|
||||
@@ -85,7 +85,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.SystemErr)
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
||||
@@ -121,7 +121,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
// @Param sign formData string true "MD5 signature"
|
||||
// @Param sign_type formData string false "Signature type (MD5)"
|
||||
// @Success 302 "Redirect to checkout counter"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckoutCounter 收银台
|
||||
// @Summary Checkout counter page
|
||||
// @Description Render the payment checkout counter JSON response for a given trade
|
||||
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired).
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.CheckoutCounterResponse
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||
if err != nil {
|
||||
if err == service.ErrOrder {
|
||||
// Unknown trade id: render the page with empty payload
|
||||
// (client side shows a friendly "order not found" screen).
|
||||
emptyResp := response.CheckoutCounterResponse{}
|
||||
return c.SucJson(ctx, emptyResp)
|
||||
}
|
||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, resp)
|
||||
@@ -34,12 +31,12 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||
|
||||
// CheckStatus 支付状态检测
|
||||
// @Summary Check payment status
|
||||
// @Description Check the payment status of an order by trade ID
|
||||
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired.
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||
// @Router /pay/check-status/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
@@ -53,3 +50,32 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// SubmitTxHash 手动提交交易 hash 补单
|
||||
// @Summary Submit transaction hash
|
||||
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body request.ManualPaymentRequest true "Transaction hash payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.ManualPaymentResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10009 invalid params, 10013 not waiting payment, 10038 verification failed, 10039 unsupported provider, 10007 hash already processed"
|
||||
// @Router /pay/submit-tx-hash/{trade_id} [post]
|
||||
func (c *BaseCommController) SubmitTxHash(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
req := new(request.ManualPaymentRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
resp, err := service.SubmitCashierManualPayment(tradeId, req.BlockTransactionId)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
@@ -53,9 +54,14 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
||||
continue
|
||||
}
|
||||
sort.Strings(symbols)
|
||||
displayName := strings.TrimSpace(ch.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = network
|
||||
}
|
||||
supports = append(supports, response.NetworkTokenSupport{
|
||||
Network: network,
|
||||
Tokens: symbols,
|
||||
Network: network,
|
||||
DisplayName: displayName,
|
||||
Tokens: symbols,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,7 +113,8 @@ func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
||||
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||
},
|
||||
OkPay: okpay,
|
||||
OkPay: okpay,
|
||||
Version: config.GetAppVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ func SetupTestDatabases(t testing.TB) func() {
|
||||
})
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: "rate",
|
||||
Key: "rate.forced_usdt_rate",
|
||||
Value: "1.0",
|
||||
Type: "string",
|
||||
Key: "rate.forced_rate_list",
|
||||
Value: `{"cny":{"usdt":1}}`,
|
||||
Type: "json",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||
t.Fatalf("seed rate.forced_rate_list: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
|
||||
+23
-10
@@ -27,16 +27,9 @@ const (
|
||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
|
||||
if raw == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
|
||||
}
|
||||
token := strings.TrimSpace(raw[len("Bearer "):])
|
||||
if token == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claims, err := appjwt.Parse(token)
|
||||
if err != nil {
|
||||
@@ -48,3 +41,23 @@ func CheckAdminJWT() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func adminTokenFromAuthorization(header string) (string, error) {
|
||||
raw := strings.TrimSpace(header)
|
||||
if raw == "" {
|
||||
return "", echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
token := raw
|
||||
if strings.HasPrefix(raw, bearerPrefix) {
|
||||
token = strings.TrimSpace(raw[len(bearerPrefix):])
|
||||
} else if raw == strings.TrimSpace(bearerPrefix) {
|
||||
token = ""
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestAdminTokenFromAuthorization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "bearer token",
|
||||
header: "Bearer jwt-token",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "bare token",
|
||||
header: "jwt-token",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "trimmed bare token",
|
||||
header: " jwt-token ",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "missing header",
|
||||
header: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty bearer token",
|
||||
header: "Bearer ",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := adminTokenFromAuthorization(tt.header)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("token = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAdminJWTAcceptsBearerAndBareTokens(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, "test-jwt-secret", mdb.SettingTypeString); err != nil {
|
||||
t.Fatalf("seed jwt secret: %v", err)
|
||||
}
|
||||
token, err := appjwt.Sign(42, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{
|
||||
name: "bearer token",
|
||||
header: "Bearer " + token,
|
||||
},
|
||||
{
|
||||
name: "bare token",
|
||||
header: token,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e := echo.New()
|
||||
e.Use(CheckAdminJWT())
|
||||
e.GET("/", func(ctx echo.Context) error {
|
||||
if got := ctx.Get(AdminUserIDKey); got != uint64(42) {
|
||||
t.Fatalf("%s = %v, want 42", AdminUserIDKey, got)
|
||||
}
|
||||
if got := ctx.Get(AdminUsernameKey); got != "admin" {
|
||||
t.Fatalf("%s = %v, want admin", AdminUsernameKey, got)
|
||||
}
|
||||
return ctx.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(echo.HeaderAuthorization, tt.header)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ func MdbTableInit() {
|
||||
}
|
||||
|
||||
seedChains()
|
||||
backfillRpcNodePurpose()
|
||||
seedRpcNodes()
|
||||
seedChainTokens()
|
||||
seedDefaultSettings()
|
||||
@@ -106,14 +107,7 @@ func seedChainTokens() {
|
||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||
func seedRpcNodes() {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
defaults := defaultRpcNodes()
|
||||
for _, d := range defaults {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
@@ -131,6 +125,29 @@ func seedRpcNodes() {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRpcNodes() []mdb.RpcNode {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func backfillRpcNodePurpose() {
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("purpose = '' OR purpose IS NULL").
|
||||
Update("purpose", mdb.RpcNodePurposeGeneral).Error; err != nil {
|
||||
color.Red.Printf("[store_db] backfill rpc_nodes purpose err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultSettings inserts built-in default settings.
|
||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||
func seedDefaultSettings() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
||||
want := map[string]string{
|
||||
mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum",
|
||||
mdb.NetworkBsc: "https://rpc.epusdt.com/binance",
|
||||
mdb.NetworkPolygon: "https://rpc.epusdt.com/polygon",
|
||||
}
|
||||
got := make(map[string]mdb.RpcNode)
|
||||
for _, node := range defaultRpcNodes() {
|
||||
if node.Purpose != mdb.RpcNodePurposeManualVerify {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[node.Network]; ok {
|
||||
got[node.Network] = node
|
||||
}
|
||||
}
|
||||
|
||||
for network, url := range want {
|
||||
node, ok := got[network]
|
||||
if !ok {
|
||||
t.Fatalf("missing manual_verify seed rpc node for %s", network)
|
||||
}
|
||||
if node.Url != url {
|
||||
t.Fatalf("%s manual_verify seed url = %q, want %q", network, node.Url, url)
|
||||
}
|
||||
if node.Type != mdb.RpcNodeTypeHttp {
|
||||
t.Fatalf("%s manual_verify seed type = %q, want %q", network, node.Type, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
if !node.Enabled {
|
||||
t.Fatalf("%s manual_verify seed enabled = false, want true", network)
|
||||
}
|
||||
if node.Status != mdb.RpcNodeStatusUnknown {
|
||||
t.Fatalf("%s manual_verify seed status = %q, want %q", network, node.Status, mdb.RpcNodeStatusUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ type InitialAdminPasswordHashInfo struct {
|
||||
// can print it to the console. Idempotent — subsequent calls return
|
||||
// ("", false, nil).
|
||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return "", false, err
|
||||
@@ -68,6 +71,12 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func purgeDeletedInitialAdminPasswordPlain() error {
|
||||
return dao.Mdb.Unscoped().
|
||||
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Delete(&mdb.Setting{}).Error
|
||||
}
|
||||
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
@@ -121,9 +130,11 @@ func initAdminPasswordState(plain string) error {
|
||||
}
|
||||
|
||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||
updates := clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"})
|
||||
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
||||
DoUpdates: updates,
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
@@ -154,7 +165,7 @@ func ConsumeInitialAdminPassword() (string, error) {
|
||||
return ErrInitAdminPasswordUnavailable
|
||||
}
|
||||
password = row.Value
|
||||
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
row := mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: "false",
|
||||
Type: mdb.SettingTypeBool,
|
||||
}
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
t.Fatalf("seed setting: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Where("`key` = ?", row.Key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
t.Fatalf("delete setting: %v", err)
|
||||
}
|
||||
|
||||
row.Value = "true"
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
t.Fatalf("restore setting: %v", err)
|
||||
}
|
||||
|
||||
var restored mdb.Setting
|
||||
if err := dao.Mdb.Where("`key` = ?", row.Key).Take(&restored).Error; err != nil {
|
||||
t.Fatalf("load restored setting: %v", err)
|
||||
}
|
||||
if restored.Value != "true" {
|
||||
t.Fatalf("restored value = %q, want true", restored.Value)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const password = "init-pass-plain"
|
||||
if err := initAdminPasswordState(password); err != nil {
|
||||
t.Fatalf("seed initial password state: %v", err)
|
||||
}
|
||||
|
||||
got, err := ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("consume initial password: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("password = %q, want %q", got, password)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dao.Mdb.Unscoped().
|
||||
Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count plaintext setting: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("plaintext setting rows after consume = %d, want 0", count)
|
||||
}
|
||||
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
|
||||
t.Fatal("expected fetched flag to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := HashPassword("existing-password")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||
Value: "legacy-soft-deleted-plain",
|
||||
Type: mdb.SettingTypeString,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed plaintext setting: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
t.Fatalf("soft delete plaintext setting: %v", err)
|
||||
}
|
||||
|
||||
password, created, err := EnsureDefaultAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("ensure default admin: %v", err)
|
||||
}
|
||||
if created || password != "" {
|
||||
t.Fatalf("created=%v password=%q, want existing admin unchanged", created, password)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dao.Mdb.Unscoped().
|
||||
Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count plaintext setting: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,56 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetOrderByBlockTransactionIDs fetches the first order whose stored tx id
|
||||
// matches any equivalent spelling supplied by the caller.
|
||||
func GetOrderByBlockTransactionIDs(blockIDs []string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
seen := make(map[string]struct{}, len(blockIDs))
|
||||
candidates := make([]string, 0, len(blockIDs))
|
||||
for _, blockID := range blockIDs {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
candidates = append(candidates, blockID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return order, nil
|
||||
}
|
||||
err := dao.Mdb.Model(order).Where("block_transaction_id IN ?", candidates).Limit(1).Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetOrderByBlockTransactionIDsCaseInsensitive fetches the first order whose
|
||||
// stored tx id matches any candidate after ASCII case folding. This is used
|
||||
// only for hex-based chain tx ids; case-sensitive signatures must keep using
|
||||
// GetOrderByBlockTransactionIDs.
|
||||
func GetOrderByBlockTransactionIDsCaseInsensitive(blockIDs []string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
seen := make(map[string]struct{}, len(blockIDs))
|
||||
candidates := make([]string, 0, len(blockIDs))
|
||||
for _, blockID := range blockIDs {
|
||||
blockID = strings.ToLower(strings.TrimSpace(blockID))
|
||||
if blockID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
candidates = append(candidates, blockID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return order, nil
|
||||
}
|
||||
err := dao.Mdb.Model(order).Where("LOWER(block_transaction_id) IN ?", candidates).Limit(1).Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
@@ -28,25 +29,40 @@ type AddressDailyStat struct {
|
||||
ActualAmount float64 `json:"actual_amount"`
|
||||
}
|
||||
|
||||
func statsBucketExpr(column string, hourly bool) (string, error) {
|
||||
dialect := "sqlite"
|
||||
if dao.Mdb != nil && dao.Mdb.Dialector != nil {
|
||||
dialect = dao.Mdb.Dialector.Name()
|
||||
}
|
||||
return statsBucketExprForDialect(dialect, column, hourly)
|
||||
}
|
||||
|
||||
func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) {
|
||||
switch dialect {
|
||||
case "sqlite", "":
|
||||
// modernc SQLite stores time.Time as Go's time.String() by default.
|
||||
// Prefix buckets work for that format and SQLite datetime text.
|
||||
if hourly {
|
||||
return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil
|
||||
}
|
||||
return fmt.Sprintf("substr(%s, 1, 10)", column), nil
|
||||
case "postgres":
|
||||
if hourly {
|
||||
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil
|
||||
}
|
||||
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect)
|
||||
}
|
||||
}
|
||||
|
||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||
// zero-filling any days in the range that have no orders.
|
||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||
// what the user means by "流水". Pending orders count toward OrderCount
|
||||
// to keep成交率 computable.
|
||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`DATE(created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
rows, err := queryOrderStats(start, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -56,25 +72,34 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||
// zero-filling any hours that have no orders.
|
||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
rows, err := queryOrderStats(start, end, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
bucket, err := statsBucketExpr("created_at", hourly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(fmt.Sprintf(`%s AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket),
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group(bucket).
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
@@ -122,15 +147,7 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing days.
|
||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
rows, err := queryAssetByAddress(start, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -140,21 +157,33 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing hours.
|
||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
rows, err := queryAssetByAddress(start, end, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
bucket, err := statsBucketExpr("created_at", hourly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(fmt.Sprintf(`%s AS day,
|
||||
receive_address AS address,
|
||||
SUM(actual_amount) AS actual_amount`, bucket)).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group(bucket).
|
||||
Group("receive_address").
|
||||
Order("day ASC, address ASC").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||
// All addresses that appear in rows are represented for every day in range.
|
||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
|
||||
@@ -42,6 +42,56 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsBucketExprForDialect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect string
|
||||
hourly bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "sqlite daily",
|
||||
dialect: "sqlite",
|
||||
want: "substr(created_at, 1, 10)",
|
||||
},
|
||||
{
|
||||
name: "sqlite hourly",
|
||||
dialect: "sqlite",
|
||||
hourly: true,
|
||||
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
|
||||
},
|
||||
{
|
||||
name: "postgres daily",
|
||||
dialect: "postgres",
|
||||
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
|
||||
},
|
||||
{
|
||||
name: "postgres hourly",
|
||||
dialect: "postgres",
|
||||
hourly: true,
|
||||
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
|
||||
if err != nil {
|
||||
t.Fatalf("statsBucketExprForDialect error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
|
||||
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
|
||||
t.Fatal("expected unsupported mysql dialect error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcFailoverThreshold = 3
|
||||
RpcFailoverCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
var gRpcFailover = struct {
|
||||
sync.Mutex
|
||||
failures map[uint64]int
|
||||
cooldownUntil map[uint64]time.Time
|
||||
}{
|
||||
failures: make(map[uint64]int),
|
||||
cooldownUntil: make(map[uint64]time.Time),
|
||||
}
|
||||
|
||||
func NormalizeRpcNodePurpose(purpose string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(purpose)) {
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
return mdb.RpcNodePurposeManualVerify
|
||||
case mdb.RpcNodePurposeBoth:
|
||||
return mdb.RpcNodePurposeBoth
|
||||
default:
|
||||
return mdb.RpcNodePurposeGeneral
|
||||
}
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
@@ -20,6 +50,84 @@ func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListRpcNodesForHealth returns nodes that should be probed by the periodic
|
||||
// health job. Manual verification nodes are intentionally excluded because
|
||||
// they may be paid/high-limit endpoints that should only be used on demand.
|
||||
func ListRpcNodesForHealth() ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListGeneralRpcCandidates returns enabled non-manual nodes for ordinary
|
||||
// scanners/listeners. Down nodes are skipped; ok nodes are ordered before
|
||||
// unknown/bootstrap nodes.
|
||||
func ListGeneralRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||
Where("enabled = ?", true).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buckets := make([][]mdb.RpcNode, 2)
|
||||
for _, row := range rows {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], row)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], row)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, bucket := range buckets {
|
||||
sortRpcNodes(bucket)
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SelectGeneralRpcNode(network, nodeType string, excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
rows, err := ListGeneralRpcCandidates(network, nodeType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
excluded := make(map[uint64]struct{}, len(excludeIDs))
|
||||
for _, id := range excludeIDs {
|
||||
if id > 0 {
|
||||
excluded[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
if _, ok := excluded[rows[i].ID]; ok {
|
||||
continue
|
||||
}
|
||||
if IsRpcNodeCoolingDown(rows[i].ID) {
|
||||
continue
|
||||
}
|
||||
return &rows[i], nil
|
||||
}
|
||||
if len(excluded) == 0 {
|
||||
for i := range rows {
|
||||
return &rows[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetRpcNodeByID fetches one row.
|
||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
row := new(mdb.RpcNode)
|
||||
@@ -29,6 +137,7 @@ func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
|
||||
// CreateRpcNode inserts a row.
|
||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
@@ -37,6 +146,9 @@ func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if purpose, ok := fields["purpose"].(string); ok {
|
||||
fields["purpose"] = NormalizeRpcNodePurpose(purpose)
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
@@ -48,13 +160,15 @@ 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 enabled rows with status=unknown when no health check has
|
||||
// run yet. Explicitly down rows are not selected.
|
||||
// run yet. Explicitly down rows are not selected. Manual verification nodes
|
||||
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
|
||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("type = ?", strings.ToLower(nodeType)).
|
||||
Where("enabled = ?", true).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -82,6 +196,144 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
return pickWeighted(candidates), nil
|
||||
}
|
||||
|
||||
// ListManualPaymentRpcCandidates returns enabled RPC nodes for manual payment
|
||||
// verification. Ordinary RPCs are tried first to avoid paid endpoint usage;
|
||||
// manual_verify nodes are used only as a fallback.
|
||||
func ListManualPaymentRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||
Where("enabled = ?", true).
|
||||
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
buckets := make([][]mdb.RpcNode, 4)
|
||||
for _, row := range rows {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
switch row.Purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], row)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], row)
|
||||
}
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[2] = append(buckets[2], row)
|
||||
} else {
|
||||
buckets[3] = append(buckets[3], row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, bucket := range buckets {
|
||||
sortRpcNodes(bucket)
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortRpcNodes(rows []mdb.RpcNode) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Weight != rows[j].Weight {
|
||||
return rows[i].Weight > rows[j].Weight
|
||||
}
|
||||
return rows[i].ID < rows[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func RecordRpcNodeSuccess(nodeID uint64) {
|
||||
if nodeID == 0 {
|
||||
return
|
||||
}
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
}
|
||||
|
||||
func RecordRpcNodeFailure(nodeID uint64) (int, bool) {
|
||||
if nodeID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
now := time.Now()
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
if until, ok := gRpcFailover.cooldownUntil[nodeID]; ok {
|
||||
if now.Before(until) {
|
||||
return gRpcFailover.failures[nodeID], true
|
||||
}
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
}
|
||||
|
||||
gRpcFailover.failures[nodeID]++
|
||||
failures := gRpcFailover.failures[nodeID]
|
||||
if failures >= RpcFailoverThreshold {
|
||||
gRpcFailover.failures[nodeID] = 0
|
||||
gRpcFailover.cooldownUntil[nodeID] = now.Add(RpcFailoverCooldown)
|
||||
return failures, true
|
||||
}
|
||||
return failures, false
|
||||
}
|
||||
|
||||
func IsRpcNodeCoolingDown(nodeID uint64) bool {
|
||||
if nodeID == 0 {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
until, ok := gRpcFailover.cooldownUntil[nodeID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if now.Before(until) {
|
||||
return true
|
||||
}
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
return false
|
||||
}
|
||||
|
||||
func RpcNodeLogLabel(node mdb.RpcNode) string {
|
||||
return fmt.Sprintf("id=%d network=%s type=%s endpoint=%s", node.ID, node.Network, node.Type, safeRpcEndpoint(node.Url))
|
||||
}
|
||||
|
||||
func safeRpcEndpoint(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil && parsed.Host != "" {
|
||||
if parsed.Scheme != "" {
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
return parsed.Host
|
||||
}
|
||||
cut := strings.IndexAny(raw, "/?")
|
||||
if cut >= 0 {
|
||||
return raw[:cut]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func ResetRpcFailoverForTest() {
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
gRpcFailover.failures = make(map[uint64]int)
|
||||
gRpcFailover.cooldownUntil = make(map[uint64]time.Time)
|
||||
}
|
||||
|
||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||
// to truly disable).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
@@ -99,3 +100,306 @@ func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeExcludesManualVerifyRows(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://manual.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://general.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed general rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://general.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want general row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodePurposeFilterAppliesToAnyNodeType(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const nodeType = "grpc"
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "grpc://manual.example.com",
|
||||
Type: nodeType,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "grpc://general.example.com",
|
||||
Type: nodeType,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed general rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, nodeType)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "grpc://general.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want general grpc row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeManualVerifyOnlyReturnsNil(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://manual-only.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListManualPaymentRpcCandidatesGeneralBeforeManualVerify(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusDown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||
}
|
||||
var urls []string
|
||||
for _, row := range got {
|
||||
urls = append(urls, row.Url)
|
||||
}
|
||||
want := []string{
|
||||
"https://general-ok.example.com",
|
||||
"https://general-unknown.example.com",
|
||||
"https://manual-ok.example.com",
|
||||
"https://manual-unknown.example.com",
|
||||
}
|
||||
if len(urls) != len(want) {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if urls[i] != want[i] {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListManualPaymentRpcCandidatesAllowsManualVerifyFallbackOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusDown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Url != "https://manual-ok.example.com" {
|
||||
t.Fatalf("candidate rows = %#v, want only manual ok fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeSkipsCoolingNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
first, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if first == nil || first.Url != "https://primary.example.com" {
|
||||
t.Fatalf("first node = %#v, want primary", first)
|
||||
}
|
||||
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(first.ID)
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://backup.example.com" {
|
||||
t.Fatalf("node after cooldown = %#v, want backup", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeFallsBackWhenAllNodesCooling(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
for i := range rows {
|
||||
for n := 0; n < RpcFailoverThreshold; n++ {
|
||||
RecordRpcNodeFailure(rows[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://primary.example.com" {
|
||||
t.Fatalf("SelectGeneralRpcNode() = %#v, want primary when every node is cooling", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeReturnsNilWhenExcludedAlternatesCooling(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(rows[1].ID)
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode() = %#v, want nil when only alternate is cooling", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRpcNodeSuccessClearsCooldown(t *testing.T) {
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
const nodeID uint64 = 42
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(nodeID)
|
||||
}
|
||||
if !IsRpcNodeCoolingDown(nodeID) {
|
||||
t.Fatal("node should be cooling after threshold")
|
||||
}
|
||||
RecordRpcNodeSuccess(nodeID)
|
||||
if IsRpcNodeCoolingDown(nodeID) {
|
||||
t.Fatal("node should not be cooling after success")
|
||||
}
|
||||
failures, cooling := RecordRpcNodeFailure(nodeID)
|
||||
if cooling || failures != 1 {
|
||||
t.Fatalf("failure state after success = failures=%d cooling=%v, want failures=1 cooling=false", failures, cooling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRpcNodeLogLabelHidesPathAndQuery(t *testing.T) {
|
||||
label := RpcNodeLogLabel(mdb.RpcNode{
|
||||
BaseModel: mdb.BaseModel{ID: 7},
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeWs,
|
||||
Url: "wss://rpc.example.com/v3/secret-key?token=secret",
|
||||
})
|
||||
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||
t.Fatalf("RpcNodeLogLabel() leaked sensitive URL parts: %s", label)
|
||||
}
|
||||
if !strings.Contains(label, "wss://rpc.example.com") {
|
||||
t.Fatalf("RpcNodeLogLabel() = %s, want host", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRpcNodesForHealthExcludesManualVerify(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://general.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
got, err := ListRpcNodesForHealth()
|
||||
if err != nil {
|
||||
t.Fatalf("ListRpcNodesForHealth(): %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Url != "https://general.example.com" {
|
||||
t.Fatalf("ListRpcNodesForHealth() = %#v, want only general row", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +144,11 @@ func SetSetting(group, key, value, valueType string) error {
|
||||
valueType = mdb.SettingTypeString
|
||||
}
|
||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||
updates := clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"})
|
||||
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
||||
DoUpdates: updates,
|
||||
}).Create(&row).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
// The main orders table remains the source of truth for merchant-facing state,
|
||||
// while this table keeps provider-specific identifiers and callback payloads.
|
||||
type ProviderOrder struct {
|
||||
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"T2026041612345678"`
|
||||
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
// Provider values should match the bound Orders.PayProvider value.
|
||||
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
|
||||
// ProviderOrderID is the downstream provider's own order identifier for the
|
||||
|
||||
+17
-10
@@ -13,19 +13,26 @@ const (
|
||||
RpcNodeStatusDown = "down"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Clients select a node
|
||||
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
||||
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
||||
const (
|
||||
RpcNodePurposeGeneral = "general"
|
||||
RpcNodePurposeManualVerify = "manual_verify"
|
||||
RpcNodePurposeBoth = "both"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
|
||||
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
|
||||
type RpcNode struct {
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||
Purpose string `gorm:"column:purpose;size:32;default:general;index:rpc_nodes_purpose_index" json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||
SettingKeyBrandBackgroundColor = "brand.background_color"
|
||||
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
|
||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||
SettingKeyRateForcedRateList = "rate.forced_rate_list"
|
||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||
SettingKeyRateApiUrl = "rate.api_url"
|
||||
@@ -64,10 +64,10 @@ const (
|
||||
|
||||
type Setting struct {
|
||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_rate_list"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"{\"cny\":{\"usdt\":0.14635}}"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"json"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制汇率列表"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const (
|
||||
NetworkTron = "tron"
|
||||
NetworkSolana = "solana"
|
||||
NetworkEthereum = "ethereum"
|
||||
NetworkBsc = "bsc"
|
||||
NetworkBsc = "binance"
|
||||
NetworkPolygon = "polygon"
|
||||
NetworkPlasma = "plasma"
|
||||
)
|
||||
|
||||
@@ -39,9 +39,20 @@ type OrderProcessingRequest struct {
|
||||
BlockTransactionId string
|
||||
}
|
||||
|
||||
// ManualPaymentRequest 手动提交交易 hash 补单
|
||||
type ManualPaymentRequest struct {
|
||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||
}
|
||||
|
||||
func (r ManualPaymentRequest) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"BlockTransactionId": "交易哈希",
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchNetworkRequest 切换支付网络
|
||||
type SwitchNetworkRequest struct {
|
||||
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
|
||||
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
Token string `json:"token" validate:"required" example:"USDT"`
|
||||
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ package response
|
||||
// Used for swagger documentation only — the actual response is built
|
||||
// by util/http.Resp.
|
||||
type ApiResponse struct {
|
||||
StatusCode int `json:"status_code" example:"200"`
|
||||
Message string `json:"message" example:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
RequestID string `json:"request_id" example:"req-123456"`
|
||||
// Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
|
||||
StatusCode int `json:"status_code" example:"200"`
|
||||
// Default server-side errno text. Frontend should prefer status_code for display translation.
|
||||
Message string `json:"message" example:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
// Request id echoed from X-Request-ID when present.
|
||||
RequestID string `json:"request_id" example:"req-123456"`
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ package response
|
||||
|
||||
// CreateTransactionResponse 创建订单成功返回
|
||||
type CreateTransactionResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
||||
}
|
||||
|
||||
// OrderNotifyResponse 订单异步回调结构体
|
||||
type OrderNotifyResponse struct {
|
||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
@@ -30,13 +30,13 @@ type OrderNotifyResponse struct {
|
||||
|
||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||
type OrderNotifyResponseEpay struct {
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package response
|
||||
|
||||
type CheckoutCounterResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
@@ -10,13 +10,19 @@ type CheckoutCounterResponse struct {
|
||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
|
||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
|
||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||
IsSelected bool `json:"is_selected" example:"false"`
|
||||
}
|
||||
|
||||
type CheckStatusResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"1"`
|
||||
}
|
||||
|
||||
type ManualPaymentResponse struct {
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
Status int `json:"status" enums:"1,2,3" example:"2"` // 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123def456..."` // 已验证并入账的交易哈希
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package response
|
||||
|
||||
type NetworkTokenSupport struct {
|
||||
Network string `json:"network" example:"tron"`
|
||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||
Network string `json:"network" example:"tron"`
|
||||
DisplayName string `json:"display_name" example:"TRON"`
|
||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||
}
|
||||
|
||||
type EpayPublicConfig struct {
|
||||
@@ -36,4 +37,5 @@ type PublicConfigResponse struct {
|
||||
Site SitePublicConfig `json:"site"`
|
||||
Epay EpayPublicConfig `json:"epay"`
|
||||
OkPay OkPayPublicConfig `json:"okpay"`
|
||||
Version string `json:"version" example:"v1.0.1"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const manualVerifyRequestTimeout = 15 * time.Second
|
||||
|
||||
var erc20TransferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||
|
||||
var (
|
||||
manualOrderPaymentValidatorMu sync.Mutex
|
||||
manualOrderPaymentValidator manualOrderPaymentValidatorFunc = validateManualOrderPaymentDefault
|
||||
)
|
||||
|
||||
type manualOrderPaymentValidatorFunc func(*mdb.Orders, string) (string, error)
|
||||
|
||||
// ValidateManualOrderPayment verifies that the supplied chain transaction
|
||||
// really settles the order before an admin manually marks it paid. It returns
|
||||
// the canonical transaction id that should be persisted for duplicate checks.
|
||||
func ValidateManualOrderPayment(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||
manualOrderPaymentValidatorMu.Lock()
|
||||
validator := manualOrderPaymentValidator
|
||||
manualOrderPaymentValidatorMu.Unlock()
|
||||
return validator(order, blockTransactionID)
|
||||
}
|
||||
|
||||
// SetManualOrderPaymentValidatorForTest swaps the chain verifier in tests so
|
||||
// route/controller tests don't depend on public RPC availability.
|
||||
func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string, error)) func() {
|
||||
manualOrderPaymentValidatorMu.Lock()
|
||||
old := manualOrderPaymentValidator
|
||||
if fn == nil {
|
||||
manualOrderPaymentValidator = validateManualOrderPaymentDefault
|
||||
} else {
|
||||
manualOrderPaymentValidator = func(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||
return fn(order, blockTransactionID)
|
||||
}
|
||||
}
|
||||
manualOrderPaymentValidatorMu.Unlock()
|
||||
return func() {
|
||||
manualOrderPaymentValidatorMu.Lock()
|
||||
manualOrderPaymentValidator = old
|
||||
manualOrderPaymentValidatorMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||
if order == nil || order.ID == 0 {
|
||||
return "", fmt.Errorf("order not found")
|
||||
}
|
||||
txID := strings.TrimSpace(blockTransactionID)
|
||||
if txID == "" {
|
||||
return "", fmt.Errorf("block_transaction_id is required")
|
||||
}
|
||||
|
||||
var canonicalTxID string
|
||||
var err error
|
||||
switch strings.ToLower(strings.TrimSpace(order.Network)) {
|
||||
case mdb.NetworkTron:
|
||||
canonicalTxID, err = validateManualTronPayment(order, txID)
|
||||
case mdb.NetworkSolana:
|
||||
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
canonicalTxID, err = validateManualEvmPayment(order, txID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported manual payment verification network: %s", order.Network)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = ensureManualBlockTransactionUnused(order, canonicalTxID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonicalTxID, nil
|
||||
}
|
||||
|
||||
func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string) error {
|
||||
candidates := equivalentManualBlockTransactionIDs(order.Network, canonicalTxID)
|
||||
var existing *mdb.Orders
|
||||
var err error
|
||||
if manualBlockTransactionIDIsHex(order.Network) {
|
||||
existing, err = data.GetOrderByBlockTransactionIDsCaseInsensitive(candidates)
|
||||
} else {
|
||||
existing, err = data.GetOrderByBlockTransactionIDs(candidates)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ID > 0 && existing.ID != order.ID {
|
||||
return constant.OrderBlockAlreadyProcess
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualBlockTransactionIDIsHex(network string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
canonicalTxID = strings.TrimSpace(canonicalTxID)
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, 6)
|
||||
add := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
|
||||
add(canonicalTxID)
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
|
||||
body = strings.ToLower(body)
|
||||
add("0x" + body)
|
||||
add("0x" + strings.ToUpper(body))
|
||||
add("0X" + body)
|
||||
add("0X" + strings.ToUpper(body))
|
||||
add(body)
|
||||
add(strings.ToUpper(body))
|
||||
case mdb.NetworkTron:
|
||||
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
|
||||
body = strings.ToLower(body)
|
||||
add(body)
|
||||
add(strings.ToUpper(body))
|
||||
add("0x" + body)
|
||||
add("0x" + strings.ToUpper(body))
|
||||
add("0X" + body)
|
||||
add("0X" + strings.ToUpper(body))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateManualEvmPayment(order *mdb.Orders, txID string) (string, error) {
|
||||
txHash, canonicalTxID, err := canonicalEvmHash(txID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := data.GetEnabledChainTokenBySymbol(order.Network, order.Token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
|
||||
return "", fmt.Errorf("enabled token contract not configured for %s/%s", order.Network, order.Token)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
clients, err := dialManualEvmClients(ctx, order.Network)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer closeManualEvmClients(clients)
|
||||
|
||||
if err = validateManualEvmPaymentAcrossClients(ctx, clients, order, txHash, token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonicalTxID, nil
|
||||
}
|
||||
|
||||
type evmChainReader interface {
|
||||
TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error)
|
||||
HeaderByNumber(context.Context, *big.Int) (*types.Header, error)
|
||||
}
|
||||
|
||||
type manualEvmClient struct {
|
||||
label string
|
||||
reader evmChainReader
|
||||
close func()
|
||||
}
|
||||
|
||||
func closeManualEvmClients(clients []manualEvmClient) {
|
||||
for _, item := range clients {
|
||||
if item.close != nil {
|
||||
item.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualEvmPaymentAcrossClients(ctx context.Context, clients []manualEvmClient, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
|
||||
var verifyErrors []string
|
||||
for _, item := range clients {
|
||||
if err := validateManualEvmPaymentWithClient(ctx, item.reader, order, txHash, token); err != nil {
|
||||
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", item.label, err))
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(verifyErrors) > 0 {
|
||||
return fmt.Errorf("manual EVM verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||
}
|
||||
return fmt.Errorf("no enabled %s WS/HTTP RPC node configured", order.Network)
|
||||
}
|
||||
|
||||
func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainReader, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
|
||||
receipt, err := client.TransactionReceipt(ctx, txHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch transaction receipt: %w", err)
|
||||
}
|
||||
if receipt.Status != types.ReceiptStatusSuccessful {
|
||||
return fmt.Errorf("transaction is not successful")
|
||||
}
|
||||
if receipt.BlockNumber == nil {
|
||||
return fmt.Errorf("transaction receipt missing block number")
|
||||
}
|
||||
txHeader, err := client.HeaderByNumber(ctx, receipt.BlockNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch transaction block header: %w", err)
|
||||
}
|
||||
if err = ensureEvmTransactionNotBeforeOrder(txHeader.Time, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ensureEvmConfirmations(ctx, client, order.Network, receipt.BlockNumber); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contract, err := normalizeEvmAddress(token.ContractAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid token contract address: %w", err)
|
||||
}
|
||||
to, err := normalizeEvmAddress(order.ReceiveAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order receive address: %w", err)
|
||||
}
|
||||
amountMismatch := false
|
||||
for _, item := range receipt.Logs {
|
||||
if item == nil || !strings.EqualFold(item.Address.Hex(), contract.Hex()) {
|
||||
continue
|
||||
}
|
||||
if len(item.Topics) < 3 || item.Topics[0] != erc20TransferEventHash {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(common.BytesToAddress(item.Topics[2].Bytes()).Hex(), to.Hex()) {
|
||||
continue
|
||||
}
|
||||
rawAmount := new(big.Int).SetBytes(item.Data)
|
||||
if amountMatchesRaw(order.ActualAmount, rawAmount, token.Decimals) {
|
||||
return nil
|
||||
}
|
||||
amountMismatch = true
|
||||
}
|
||||
|
||||
if amountMismatch {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return fmt.Errorf("matching token transfer to order address not found")
|
||||
}
|
||||
|
||||
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
|
||||
var clients []manualEvmClient
|
||||
var connectErrors []string
|
||||
|
||||
nodes, err := listManualEvmRpcCandidates(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if strings.TrimSpace(node.Url) == "" {
|
||||
continue
|
||||
}
|
||||
client, err := dialManualEvmClient(ctx, node)
|
||||
if err == nil {
|
||||
clients = append(clients, manualEvmClient{
|
||||
label: manualRpcNodeLabel(node),
|
||||
reader: client,
|
||||
close: client.Close,
|
||||
})
|
||||
continue
|
||||
}
|
||||
connectErrors = append(connectErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
}
|
||||
if len(clients) > 0 {
|
||||
return clients, nil
|
||||
}
|
||||
if len(connectErrors) > 0 {
|
||||
return nil, fmt.Errorf("connect %s RPC failed: %s", network, strings.Join(connectErrors, "; "))
|
||||
}
|
||||
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
|
||||
}
|
||||
|
||||
func listManualEvmRpcCandidates(network string) ([]mdb.RpcNode, error) {
|
||||
buckets := make([][]mdb.RpcNode, 4)
|
||||
for _, nodeType := range []string{mdb.RpcNodeTypeHttp, mdb.RpcNodeTypeWs} {
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(network, nodeType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
node.Purpose = data.NormalizeRpcNodePurpose(node.Purpose)
|
||||
switch node.Purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
|
||||
if node.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], node)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], node)
|
||||
}
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
if node.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[2] = append(buckets[2], node)
|
||||
} else {
|
||||
buckets[3] = append(buckets[3], node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0)
|
||||
for _, bucket := range buckets {
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dialManualEvmClient(ctx context.Context, node mdb.RpcNode) (*ethclient.Client, error) {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
return ethclient.DialContext(ctx, rpcURL)
|
||||
}
|
||||
|
||||
func manualRpcNodeLabel(node mdb.RpcNode) string {
|
||||
purpose := data.NormalizeRpcNodePurpose(node.Purpose)
|
||||
return fmt.Sprintf("%s purpose=%s", data.RpcNodeLogLabel(node), purpose)
|
||||
}
|
||||
|
||||
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
|
||||
if order == nil {
|
||||
return fmt.Errorf("order not found")
|
||||
}
|
||||
if int64(blockTime)*1000 < order.CreatedAt.TimestampMilli() {
|
||||
return fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureEvmConfirmations(ctx context.Context, client evmChainReader, network string, txBlock *big.Int) error {
|
||||
chain, err := data.GetChainByNetwork(network)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minConfirmations := 1
|
||||
if chain != nil && chain.MinConfirmations > 0 {
|
||||
minConfirmations = chain.MinConfirmations
|
||||
}
|
||||
header, err := client.HeaderByNumber(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch latest block: %w", err)
|
||||
}
|
||||
confirmations := new(big.Int).Sub(header.Number, txBlock)
|
||||
confirmations.Add(confirmations, big.NewInt(1))
|
||||
if confirmations.Sign() < 0 || confirmations.Cmp(big.NewInt(int64(minConfirmations))) < 0 {
|
||||
return fmt.Errorf("transaction confirmations %s below required %d", confirmations.String(), minConfirmations)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tronContractParam struct {
|
||||
TypeURL string `json:"type_url"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
type manualTronTransaction struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData struct {
|
||||
Contract []struct {
|
||||
Type string `json:"type"`
|
||||
Parameter tronContractParam `json:"parameter"`
|
||||
} `json:"contract"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
} `json:"raw_data"`
|
||||
Ret []struct {
|
||||
ContractRet string `json:"contractRet"`
|
||||
} `json:"ret"`
|
||||
}
|
||||
|
||||
type manualTronTxInfo struct {
|
||||
ID string `json:"id"`
|
||||
BlockNumber int64 `json:"blockNumber"`
|
||||
BlockTimeStamp int64 `json:"blockTimeStamp"`
|
||||
ContractResult []string `json:"contractResult"`
|
||||
Log []manualTronEventLog `json:"log"`
|
||||
Receipt struct {
|
||||
Result string `json:"result"`
|
||||
} `json:"receipt"`
|
||||
}
|
||||
|
||||
type manualTronEventLog struct {
|
||||
Address string `json:"address"`
|
||||
Topics []string `json:"topics"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type manualTronBlock struct {
|
||||
BlockHeader struct {
|
||||
RawData struct {
|
||||
Number int64 `json:"number"`
|
||||
} `json:"raw_data"`
|
||||
} `json:"block_header"`
|
||||
}
|
||||
|
||||
type manualTronTransferContractValue struct {
|
||||
OwnerAddress string `json:"owner_address"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
|
||||
normalizedTxID, err := normalizeTronTxID(txID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
var verifyErrors []string
|
||||
for _, node := range nodes {
|
||||
if strings.TrimSpace(node.Url) == "" {
|
||||
continue
|
||||
}
|
||||
if err = validateManualTronPaymentWithNode(order, normalizedTxID, node); err != nil {
|
||||
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
continue
|
||||
}
|
||||
return normalizedTxID, nil
|
||||
}
|
||||
if len(verifyErrors) > 0 {
|
||||
return "", fmt.Errorf("manual TRON verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||
}
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
func validateManualTronPaymentWithNode(order *mdb.Orders, normalizedTxID string, node mdb.RpcNode) error {
|
||||
baseURL := strings.TrimSpace(node.Url)
|
||||
var tx manualTronTransaction
|
||||
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
|
||||
return fmt.Errorf("fetch tron transaction: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) == "" {
|
||||
return fmt.Errorf("transaction not found")
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
|
||||
return fmt.Errorf("transaction id mismatch")
|
||||
}
|
||||
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||
return fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
|
||||
}
|
||||
|
||||
var info manualTronTxInfo
|
||||
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
|
||||
return fmt.Errorf("fetch tron transaction info: %w", err)
|
||||
}
|
||||
if info.BlockNumber <= 0 {
|
||||
return fmt.Errorf("transaction is not confirmed")
|
||||
}
|
||||
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
|
||||
return fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
|
||||
}
|
||||
if err := ensureTronConfirmations(baseURL, node.ApiKey, order.Network, info.BlockNumber); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.BlockTimeStamp <= 0 {
|
||||
return fmt.Errorf("transaction block timestamp missing")
|
||||
}
|
||||
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
|
||||
return fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
|
||||
if err := validateManualTronNativeTransfer(order, &tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
|
||||
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TransferContract" {
|
||||
return fmt.Errorf("transaction is not a TRX transfer")
|
||||
}
|
||||
var val manualTronTransferContractValue
|
||||
if err := json.Unmarshal(tx.RawData.Contract[0].Parameter.Value, &val); err != nil {
|
||||
return err
|
||||
}
|
||||
toAddr, err := tronHexToAddress(val.ToAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TRX recipient address: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(toAddr, order.ReceiveAddress) {
|
||||
return fmt.Errorf("transaction recipient mismatch")
|
||||
}
|
||||
if !amountMatchesRaw(order.ActualAmount, big.NewInt(val.Amount), 6) {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManualTronTRC20Transfer(order *mdb.Orders, tx *manualTronTransaction, info *manualTronTxInfo) error {
|
||||
token, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, order.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
|
||||
return fmt.Errorf("enabled token contract not configured for tron/%s", order.Token)
|
||||
}
|
||||
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TriggerSmartContract" {
|
||||
return fmt.Errorf("transaction is not a TRC20 transfer")
|
||||
}
|
||||
contractHex, err := tronAddressToHex(token.ContractAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configured TRC20 contract: %w", err)
|
||||
}
|
||||
return validateManualTronTRC20TransferEvent(order, info, contractHex, token.Decimals)
|
||||
}
|
||||
|
||||
func validateManualTronTRC20TransferEvent(order *mdb.Orders, info *manualTronTxInfo, contractHex string, decimals int) error {
|
||||
if info == nil || len(info.Log) == 0 {
|
||||
return fmt.Errorf("matching TRC20 transfer event to order address not found")
|
||||
}
|
||||
toHex, err := tronAddressToHex(order.ReceiveAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order receive address: %w", err)
|
||||
}
|
||||
transferTopic := strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x")
|
||||
amountMismatch := false
|
||||
for _, event := range info.Log {
|
||||
eventContractHex, err := normalizeTronAddressHex(event.Address)
|
||||
if err != nil || eventContractHex != contractHex {
|
||||
continue
|
||||
}
|
||||
if len(event.Topics) < 3 || normalizeEventTopic(event.Topics[0]) != transferTopic {
|
||||
continue
|
||||
}
|
||||
eventToHex, err := tronTopicAddressToHex(event.Topics[2])
|
||||
if err != nil || eventToHex != toHex {
|
||||
continue
|
||||
}
|
||||
rawAmount, err := tronEventDataAmount(event.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TRC20 transfer event amount: %w", err)
|
||||
}
|
||||
if amountMatchesRaw(order.ActualAmount, rawAmount, decimals) {
|
||||
return nil
|
||||
}
|
||||
amountMismatch = true
|
||||
}
|
||||
if amountMismatch {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return fmt.Errorf("matching TRC20 transfer event to order address not found")
|
||||
}
|
||||
|
||||
func ensureTronConfirmations(baseURL, apiKey, network string, txBlock int64) error {
|
||||
var latest manualTronBlock
|
||||
if err := tronPostJSON(baseURL, apiKey, "/wallet/getnowblock", map[string]interface{}{}, &latest); err != nil {
|
||||
return fmt.Errorf("fetch latest tron block: %w", err)
|
||||
}
|
||||
chain, err := data.GetChainByNetwork(network)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minConfirmations := 1
|
||||
if chain != nil && chain.MinConfirmations > 0 {
|
||||
minConfirmations = chain.MinConfirmations
|
||||
}
|
||||
confirmations := latest.BlockHeader.RawData.Number - txBlock + 1
|
||||
if confirmations < int64(minConfirmations) {
|
||||
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{}) error {
|
||||
payload, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(baseURL, "/")+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
client := &http.Client{Timeout: manualVerifyRequestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
|
||||
sig = strings.TrimSpace(sig)
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
var verifyErrors []string
|
||||
for _, node := range nodes {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
continue
|
||||
}
|
||||
if err = validateManualSolanaPaymentWithRPC(order, sig, node); err != nil {
|
||||
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
continue
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
if len(verifyErrors) > 0 {
|
||||
return "", fmt.Errorf("manual Solana verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||
}
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
func validateManualSolanaPaymentWithRPC(order *mdb.Orders, sig string, node mdb.RpcNode) error {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
txData, err := solGetTransactionWithURL(rpcURL, sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch solana transaction: %w", err)
|
||||
}
|
||||
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
|
||||
return fmt.Errorf("transaction not found")
|
||||
}
|
||||
if err = ensureSolanaConfirmationsWithRPC(order.Network, sig, rpcURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||
var nativeSolToken *mdb.ChainToken
|
||||
for i := range tokens {
|
||||
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||
mint := strings.TrimSpace(tokens[i].ContractAddress)
|
||||
if sym == "SOL" && mint == "" {
|
||||
nativeSolToken = &tokens[i]
|
||||
continue
|
||||
}
|
||||
if mint != "" {
|
||||
mintTokens[mint] = &tokens[i]
|
||||
}
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
amountMismatch := false
|
||||
for _, instruction := range instructions {
|
||||
transferInfo, parseErr := ParseTransferInfoFromInstruction(instruction, txData)
|
||||
if parseErr != nil || transferInfo == nil {
|
||||
continue
|
||||
}
|
||||
if !isTransferToAddress(transferInfo, order.ReceiveAddress) {
|
||||
continue
|
||||
}
|
||||
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
|
||||
if !strings.EqualFold(token, order.Token) {
|
||||
continue
|
||||
}
|
||||
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if amountMatchesFloat(order.ActualAmount, amount) {
|
||||
return nil
|
||||
}
|
||||
amountMismatch = true
|
||||
}
|
||||
if amountMismatch {
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return fmt.Errorf("matching solana transfer to order address not found")
|
||||
}
|
||||
|
||||
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
|
||||
if order == nil {
|
||||
return fmt.Errorf("order not found")
|
||||
}
|
||||
if blockTime <= 0 {
|
||||
return fmt.Errorf("transaction block time missing")
|
||||
}
|
||||
if blockTime*1000 < order.CreatedAt.TimestampMilli() {
|
||||
return fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureSolanaConfirmations(network, sig string) error {
|
||||
rpcURL, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureSolanaConfirmationsWithRPC(network, sig, rpcURL)
|
||||
}
|
||||
|
||||
func ensureSolanaConfirmationsWithRPC(network, sig string, rpcURL string) error {
|
||||
chain, err := data.GetChainByNetwork(network)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minConfirmations := 1
|
||||
if chain != nil && chain.MinConfirmations > 0 {
|
||||
minConfirmations = chain.MinConfirmations
|
||||
}
|
||||
if minConfirmations <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := solRetryClientWithURL(rpcURL, "getSignatureStatuses", []interface{}{
|
||||
[]string{sig},
|
||||
map[string]interface{}{"searchTransactionHistory": true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch solana signature status: %w", err)
|
||||
}
|
||||
status := gjson.GetBytes(body, "result.value.0")
|
||||
if !status.Exists() || status.Type == gjson.Null {
|
||||
return fmt.Errorf("transaction status not found")
|
||||
}
|
||||
if errValue := status.Get("err"); errValue.Exists() && errValue.Type != gjson.Null {
|
||||
return fmt.Errorf("transaction is not successful: %s", errValue.Raw)
|
||||
}
|
||||
if status.Get("confirmationStatus").String() == "finalized" {
|
||||
return nil
|
||||
}
|
||||
confirmations := status.Get("confirmations").Int()
|
||||
if confirmations < int64(minConfirmations) {
|
||||
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func amountMatchesRaw(expected float64, rawAmount *big.Int, decimals int) bool {
|
||||
if rawAmount == nil || rawAmount.Sign() <= 0 {
|
||||
return false
|
||||
}
|
||||
if decimals < 0 {
|
||||
decimals = 0
|
||||
}
|
||||
actual := decimal.NewFromBigInt(rawAmount, -int32(decimals))
|
||||
return roundedAmount(expected).Equal(actual.Round(int32(data.GetAmountPrecision())))
|
||||
}
|
||||
|
||||
func amountMatchesFloat(expected, actual float64) bool {
|
||||
return roundedAmount(expected).Equal(roundedAmount(actual))
|
||||
}
|
||||
|
||||
func roundedAmount(amount float64) decimal.Decimal {
|
||||
precision := data.GetAmountPrecision()
|
||||
return decimal.NewFromFloat(math.MustParsePrecFloat64(amount, precision)).Round(int32(precision))
|
||||
}
|
||||
|
||||
func tronHexToAddress(hexAddr string) (string, error) {
|
||||
hexAddr, err := normalizeTronAddressHex(hexAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := hex.DecodeString(hexAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tron.EncodeCheck(raw), nil
|
||||
}
|
||||
|
||||
func tronAddressToHex(addr string) (string, error) {
|
||||
raw, err := tron.DecodeCheck(strings.TrimSpace(addr))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) != 21 || raw[0] != tron.PrefixMainnet {
|
||||
return "", fmt.Errorf("invalid tron address")
|
||||
}
|
||||
return strings.ToLower(hex.EncodeToString(raw)), nil
|
||||
}
|
||||
|
||||
func normalizeTronAddressHex(hexAddr string) (string, error) {
|
||||
hexAddr = normalizeTronHexAddress(hexAddr)
|
||||
raw, err := hex.DecodeString(hexAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(raw) {
|
||||
case 20:
|
||||
raw = append([]byte{tron.PrefixMainnet}, raw...)
|
||||
case 21:
|
||||
if raw[0] != tron.PrefixMainnet {
|
||||
return "", fmt.Errorf("invalid tron address prefix")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("invalid address length: %d", len(raw))
|
||||
}
|
||||
return strings.ToLower(hex.EncodeToString(raw)), nil
|
||||
}
|
||||
|
||||
func normalizeTronHexAddress(hexAddr string) string {
|
||||
hexAddr = strings.TrimSpace(hexAddr)
|
||||
hexAddr = strings.TrimPrefix(hexAddr, "0x")
|
||||
hexAddr = strings.TrimPrefix(hexAddr, "0X")
|
||||
return strings.ToLower(hexAddr)
|
||||
}
|
||||
|
||||
func normalizeEventTopic(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "0x")
|
||||
value = strings.TrimPrefix(value, "0X")
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func tronTopicAddressToHex(topic string) (string, error) {
|
||||
topic = normalizeEventTopic(topic)
|
||||
if len(topic) != 64 {
|
||||
return "", fmt.Errorf("invalid TRON event address topic length")
|
||||
}
|
||||
if _, err := hex.DecodeString(topic); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeTronAddressHex(topic[24:])
|
||||
}
|
||||
|
||||
func tronEventDataAmount(data string) (*big.Int, error) {
|
||||
data = normalizeEventTopic(data)
|
||||
if data == "" {
|
||||
return nil, fmt.Errorf("empty event data")
|
||||
}
|
||||
if len(data)%2 != 0 {
|
||||
return nil, fmt.Errorf("odd-length event data")
|
||||
}
|
||||
raw, err := hex.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return new(big.Int).SetBytes(raw), nil
|
||||
}
|
||||
|
||||
func isEvmHash(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "0x")
|
||||
value = strings.TrimPrefix(value, "0X")
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func canonicalEvmHash(value string) (common.Hash, string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "0x")
|
||||
value = strings.TrimPrefix(value, "0X")
|
||||
if len(value) != 64 {
|
||||
return common.Hash{}, "", fmt.Errorf("invalid EVM transaction hash")
|
||||
}
|
||||
if _, err := hex.DecodeString(value); err != nil {
|
||||
return common.Hash{}, "", err
|
||||
}
|
||||
hash := common.HexToHash("0x" + strings.ToLower(value))
|
||||
return hash, hash.Hex(), nil
|
||||
}
|
||||
|
||||
func normalizeTronTxID(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "0x")
|
||||
value = strings.TrimPrefix(value, "0X")
|
||||
if len(value) != 64 {
|
||||
return "", fmt.Errorf("invalid TRON transaction id length")
|
||||
}
|
||||
if _, err := hex.DecodeString(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(value), nil
|
||||
}
|
||||
|
||||
func normalizeEvmAddress(value string) (common.Address, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "0x")
|
||||
value = strings.TrimPrefix(value, "0X")
|
||||
if len(value) != 40 {
|
||||
return common.Address{}, fmt.Errorf("invalid address length")
|
||||
}
|
||||
if _, err := hex.DecodeString(value); err != nil {
|
||||
return common.Address{}, err
|
||||
}
|
||||
return common.HexToAddress("0x" + value), nil
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
)
|
||||
|
||||
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
||||
hash := strings.Repeat("a", 64)
|
||||
if !isEvmHash(hash) {
|
||||
t.Fatal("expected bare EVM hash to be valid")
|
||||
}
|
||||
if !isEvmHash("0x" + hash) {
|
||||
t.Fatal("expected 0x-prefixed EVM hash to be valid")
|
||||
}
|
||||
if isEvmHash("0x" + strings.Repeat("a", 63)) {
|
||||
t.Fatal("expected short EVM hash to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeEvmAddressAcceptsOptional0x(t *testing.T) {
|
||||
addr := "1111111111111111111111111111111111111111"
|
||||
want := common.HexToAddress("0x" + addr)
|
||||
got, err := normalizeEvmAddress(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize bare address: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("bare address = %s, want %s", got.Hex(), want.Hex())
|
||||
}
|
||||
got, err = normalizeEvmAddress("0X" + strings.ToUpper(addr))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize prefixed address: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prefixed address = %s, want %s", got.Hex(), want.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeTronAddressHexAcceptsOptionalPrefix(t *testing.T) {
|
||||
body := "a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
want := "41" + body
|
||||
|
||||
for _, input := range []string{body, "0x" + body, want, "0X" + strings.ToUpper(want)} {
|
||||
got, err := normalizeTronAddressHex(input)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeTronAddressHex(%q): %v", input, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("normalizeTronAddressHex(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
||||
txID := strings.Repeat("a", 64)
|
||||
got, err := normalizeTronTxID(txID)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize bare txid: %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("bare txid = %q, want %q", got, txID)
|
||||
}
|
||||
got, err = normalizeTronTxID("0X" + strings.ToUpper(txID))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize prefixed txid: %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("prefixed txid = %q, want %q", got, txID)
|
||||
}
|
||||
if _, err = normalizeTronTxID("0x" + strings.Repeat("a", 63)); err == nil {
|
||||
t.Fatal("expected short txid to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: "http://127.0.0.1:1",
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create rpc node: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
clients, err := dialManualEvmClients(ctx, mdb.NetworkEthereum)
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClients(): %v", err)
|
||||
}
|
||||
defer closeManualEvmClients(clients)
|
||||
if len(clients) != 1 {
|
||||
t.Fatalf("client count = %d, want 1", len(clients))
|
||||
}
|
||||
if !strings.Contains(clients[0].label, mdb.RpcNodeTypeHttp) {
|
||||
t.Fatalf("client label = %q, want HTTP node", clients[0].label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmCandidatesPreferGeneralAcrossNodeTypes(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeHttp, Url: "http://manual.example.com", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeWs, Url: "ws://general.example.com", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := listManualEvmRpcCandidates(mdb.NetworkEthereum)
|
||||
if err != nil {
|
||||
t.Fatalf("listManualEvmRpcCandidates(): %v", err)
|
||||
}
|
||||
urls := make([]string, 0, len(got))
|
||||
for _, node := range got {
|
||||
urls = append(urls, node.Url)
|
||||
}
|
||||
want := []string{"ws://general.example.com", "http://manual.example.com"}
|
||||
if len(urls) != len(want) {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if urls[i] != want[i] {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRpcNodeLabelHidesSensitiveURLParts(t *testing.T) {
|
||||
label := manualRpcNodeLabel(mdb.RpcNode{
|
||||
BaseModel: mdb.BaseModel{ID: 9},
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: "https://rpc.example.com/v3/secret-key?token=secret",
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||
t.Fatalf("manualRpcNodeLabel() leaked sensitive URL parts: %s", label)
|
||||
}
|
||||
if !strings.Contains(label, "endpoint=https://rpc.example.com") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want sanitized endpoint", label)
|
||||
}
|
||||
if !strings.Contains(label, "purpose=manual_verify") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want purpose", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPHeader(t *testing.T) {
|
||||
var headerCalls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&headerCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&headerCalls); got == 0 {
|
||||
t.Fatal("manual verify EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPForGeneralNode(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&calls); got == 0 {
|
||||
t.Fatal("general EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronPostJSONDoesNotForwardUserIPWithoutHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := tronPostJSON(server.URL, "", "/wallet/getnowblock", map[string]interface{}{}, &out); err != nil {
|
||||
t.Fatalf("tronPostJSON(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||
txTime := uint64(time.Now().Add(-time.Hour).Unix())
|
||||
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err == nil {
|
||||
t.Fatal("expected transaction before order to be rejected")
|
||||
}
|
||||
txTime = uint64(time.Now().Add(time.Minute).Unix())
|
||||
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err != nil {
|
||||
t.Fatalf("expected transaction after order to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyCanonicalEvmHash(t *testing.T) {
|
||||
hash := strings.Repeat("a", 64)
|
||||
_, canonical, err := canonicalEvmHash("0X" + strings.ToUpper(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalEvmHash(): %v", err)
|
||||
}
|
||||
if canonical != "0x"+hash {
|
||||
t.Fatalf("canonical hash = %q, want %q", canonical, "0x"+hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEquivalentBlockIDsCatchLegacyVariants(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
hash := strings.Repeat("b", 64)
|
||||
mixedHash := "0x" + strings.Repeat("bB", 32)
|
||||
if err := dao.Mdb.Create(&mdb.Orders{
|
||||
TradeId: "paid-legacy-hash",
|
||||
OrderId: "paid-legacy-hash",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
Network: mdb.NetworkEthereum,
|
||||
BlockTransactionId: mixedHash,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create existing order: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{ID: 999}, Network: mdb.NetworkEthereum}
|
||||
if err := ensureManualBlockTransactionUnused(order, "0x"+hash); err == nil {
|
||||
t.Fatal("expected legacy hash variant to be treated as already processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmRequestFailureFallsBackToNextClient(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
to := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
rawAmount := big.NewInt(1230000)
|
||||
receipt := &types.Receipt{
|
||||
Status: types.ReceiptStatusSuccessful,
|
||||
BlockNumber: big.NewInt(10),
|
||||
Logs: []*types.Log{{
|
||||
Address: contract,
|
||||
Topics: []common.Hash{
|
||||
erc20TransferEventHash,
|
||||
common.Hash{},
|
||||
common.BytesToHash(to.Bytes()),
|
||||
},
|
||||
Data: common.LeftPadBytes(rawAmount.Bytes(), 32),
|
||||
}},
|
||||
}
|
||||
order := &mdb.Orders{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Token: "USDT",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: to.Hex(),
|
||||
BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
}
|
||||
token := &mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: contract.Hex(), Decimals: 6}
|
||||
|
||||
err := validateManualEvmPaymentAcrossClients(context.Background(), []manualEvmClient{
|
||||
{label: "ws bad", reader: &fakeEvmReader{receiptErr: errors.New("ws receipt failed")}},
|
||||
{label: "http ok", reader: &fakeEvmReader{
|
||||
receipt: receipt,
|
||||
headers: map[string]*types.Header{
|
||||
"10": {Number: big.NewInt(10), Time: uint64(time.Now().Unix())},
|
||||
"latest": {Number: big.NewInt(12), Time: uint64(time.Now().Unix())},
|
||||
},
|
||||
}},
|
||||
}, order, common.HexToHash("0x"+strings.Repeat("c", 64)), token)
|
||||
if err != nil {
|
||||
t.Fatalf("validateManualEvmPaymentAcrossClients(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
contractHex := "411111111111111111111111111111111111111111"
|
||||
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
contractAddress, err := tronHexToAddress(contractHex)
|
||||
if err != nil {
|
||||
t.Fatalf("contract address: %v", err)
|
||||
}
|
||||
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient address: %v", err)
|
||||
}
|
||||
if err = dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkTron,
|
||||
Symbol: "USDT",
|
||||
ContractAddress: contractAddress,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
|
||||
rawAmount := big.NewInt(1230000)
|
||||
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
||||
info := &manualTronTxInfo{Log: []manualTronEventLog{{
|
||||
Address: strings.TrimPrefix(contractHex, "41"),
|
||||
Topics: []string{
|
||||
"0x" + strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||
strings.Repeat("0", 64),
|
||||
"0x" + strings.Repeat("0", 24) + strings.TrimPrefix(recipientHex, "41"),
|
||||
},
|
||||
Data: "0x" + fmt.Sprintf("%064x", rawAmount),
|
||||
}}}
|
||||
order := &mdb.Orders{ReceiveAddress: recipientAddress, Token: "USDT", ActualAmount: 1.23}
|
||||
|
||||
if err = validateManualTronTRC20Transfer(order, &tx, info); err != nil {
|
||||
t.Fatalf("validateManualTronTRC20Transfer(): %v", err)
|
||||
}
|
||||
|
||||
info.Log[0].Data = "0x" + fmt.Sprintf("%064x", big.NewInt(1240000))
|
||||
if err = validateManualTronTRC20Transfer(order, &tx, info); err == nil {
|
||||
t.Fatal("expected event amount mismatch to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
txID := strings.Repeat("a", 64)
|
||||
contractHex := "411111111111111111111111111111111111111111"
|
||||
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
contractAddress, err := tronHexToAddress(contractHex)
|
||||
if err != nil {
|
||||
t.Fatalf("contract address: %v", err)
|
||||
}
|
||||
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient address: %v", err)
|
||||
}
|
||||
|
||||
rawAmount := big.NewInt(1230000)
|
||||
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
|
||||
requestCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
}
|
||||
requestCalls++
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode tron request: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/wallet/getnowblock" && req["value"] != txID {
|
||||
t.Errorf("request tx id = %q, want %q", req["value"], txID)
|
||||
http.Error(w, "bad tx id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wallet/gettransactionbyid":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"txID": txID,
|
||||
"raw_data": map[string]interface{}{
|
||||
"contract": []map[string]interface{}{{
|
||||
"type": "TriggerSmartContract",
|
||||
"parameter": map[string]interface{}{
|
||||
"value": map[string]interface{}{},
|
||||
},
|
||||
}},
|
||||
},
|
||||
"ret": []map[string]string{{"contractRet": "SUCCESS"}},
|
||||
})
|
||||
case "/wallet/gettransactioninfobyid":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": txID,
|
||||
"blockNumber": 100,
|
||||
"blockTimeStamp": blockTimeMs,
|
||||
"receipt": map[string]string{"result": "SUCCESS"},
|
||||
"log": []map[string]interface{}{{
|
||||
"address": strings.TrimPrefix(contractHex, "41"),
|
||||
"topics": []string{
|
||||
strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||
strings.Repeat("0", 64),
|
||||
"0X" + strings.ToUpper(strings.Repeat("0", 24)+strings.TrimPrefix(recipientHex, "41")),
|
||||
},
|
||||
"data": fmt.Sprintf("%064x", rawAmount),
|
||||
}},
|
||||
})
|
||||
case "/wallet/getnowblock":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"block_header": map[string]interface{}{
|
||||
"raw_data": map[string]interface{}{"number": 110},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err = dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tron rpc node: %v", err)
|
||||
}
|
||||
if err = dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkTron,
|
||||
Symbol: "USDT",
|
||||
ContractAddress: contractAddress,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
order := &mdb.Orders{
|
||||
TradeId: "manual-tron-http-flow",
|
||||
OrderId: "manual-tron-http-flow",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: recipientAddress,
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err = dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
got, err := ValidateManualOrderPayment(order, "0X"+strings.ToUpper(txID))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateManualOrderPayment(): %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("canonical tx id = %q, want %q", got, txID)
|
||||
}
|
||||
if requestCalls != 3 {
|
||||
t.Fatalf("manual verify request calls = %d, want 3", requestCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||
if err := ensureSolanaTransferNotBeforeOrder(0, order); err == nil {
|
||||
t.Fatal("expected missing block time to fail")
|
||||
}
|
||||
if err := ensureSolanaTransferNotBeforeOrder(time.Now().Add(time.Minute).Unix(), order); err != nil {
|
||||
t.Fatalf("expected future block time to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEvmReader struct {
|
||||
receipt *types.Receipt
|
||||
receiptErr error
|
||||
headers map[string]*types.Header
|
||||
headerErr error
|
||||
}
|
||||
|
||||
func (f *fakeEvmReader) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) {
|
||||
if f.receiptErr != nil {
|
||||
return nil, f.receiptErr
|
||||
}
|
||||
return f.receipt, nil
|
||||
}
|
||||
|
||||
func (f *fakeEvmReader) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
|
||||
if f.headerErr != nil {
|
||||
return nil, f.headerErr
|
||||
}
|
||||
key := "latest"
|
||||
if number != nil {
|
||||
key = number.String()
|
||||
}
|
||||
header := f.headers[key]
|
||||
if header == nil {
|
||||
return nil, fmt.Errorf("missing header %s", key)
|
||||
}
|
||||
return header, nil
|
||||
}
|
||||
|
||||
func manualTronTransactionFromCallData(t *testing.T, contractHex, recipientHex string, amount *big.Int) manualTronTransaction {
|
||||
t.Helper()
|
||||
body := strings.TrimPrefix(recipientHex, "41")
|
||||
raw := fmt.Sprintf(`{
|
||||
"raw_data": {
|
||||
"contract": [{
|
||||
"type": "TriggerSmartContract",
|
||||
"parameter": {
|
||||
"value": {
|
||||
"contract_address": %q,
|
||||
"data": %q
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}`, contractHex, "a9059cbb"+strings.Repeat("0", 24)+body+fmt.Sprintf("%064x", amount))
|
||||
var tx manualTronTransaction
|
||||
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
|
||||
t.Fatalf("unmarshal tron tx: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
@@ -268,10 +268,14 @@ func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: payload.OrderID,
|
||||
})
|
||||
processed := err == nil
|
||||
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
||||
return err
|
||||
}
|
||||
|
||||
if processed {
|
||||
sendPaymentNotification(order)
|
||||
}
|
||||
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/notify"
|
||||
)
|
||||
|
||||
func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
channelType = "test-okpay-pay-success"
|
||||
shopID = "okpay-shop-notify"
|
||||
shopToken = "okpay-shop-token"
|
||||
tradeID = "okpay-notify-success-001"
|
||||
orderID = "merchant-okpay-notify-success-001"
|
||||
providerOrderID = "okpay-provider-success-001"
|
||||
rawFormData = "raw-okpay-form"
|
||||
)
|
||||
|
||||
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 okpay pay success",
|
||||
Config: "{}",
|
||||
Events: `{"pay_success":true}`,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed notification channel: %v", err)
|
||||
}
|
||||
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
|
||||
t.Fatalf("seed okpay shop id: %v", err)
|
||||
}
|
||||
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
|
||||
t.Fatalf("seed okpay shop token: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: tradeID,
|
||||
OrderId: orderID,
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 0.15,
|
||||
ReceiveAddress: "OKPAY",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
PayProvider: mdb.PaymentProviderOkPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.ProviderOrder{
|
||||
TradeId: tradeID,
|
||||
Provider: mdb.PaymentProviderOkPay,
|
||||
ProviderOrderID: providerOrderID,
|
||||
Amount: 0.15,
|
||||
Coin: "USDT",
|
||||
Status: mdb.ProviderOrderStatusPending,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed provider order: %v", err)
|
||||
}
|
||||
|
||||
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, "0.15000000", "USDT")
|
||||
if err := HandleOkPayNotify(form, rawFormData); err != nil {
|
||||
t.Fatalf("handle okpay notify: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case text := <-got:
|
||||
if !strings.Contains(text, tradeID) {
|
||||
t.Fatalf("notification text = %q, want trade id %s", text, tradeID)
|
||||
}
|
||||
if !strings.Contains(text, orderID) {
|
||||
t.Fatalf("notification text = %q, want order id %s", text, orderID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for payment notification")
|
||||
}
|
||||
|
||||
if err := HandleOkPayNotify(form, rawFormData); err != nil {
|
||||
t.Fatalf("handle duplicate okpay notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case text := <-got:
|
||||
t.Fatalf("duplicate notification sent: %q", text)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
|
||||
form := map[string]string{
|
||||
"code": "200",
|
||||
"id": shopID,
|
||||
"status": "success",
|
||||
"data[order_id]": providerOrderID,
|
||||
"data[unique_id]": tradeID,
|
||||
"data[pay_user_id]": "123456789",
|
||||
"data[amount]": amount,
|
||||
"data[coin]": coin,
|
||||
"data[status]": "1",
|
||||
"data[type]": "deposit",
|
||||
}
|
||||
form["sign"] = okPayNotifySign(form, shopToken)
|
||||
return form
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/GMWalletApp/epusdt/util/security"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -54,12 +55,17 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
||||
|
||||
// CreateTransaction creates a new payment order.
|
||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
||||
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
||||
return nil, constant.NotifyURLErr
|
||||
}
|
||||
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
amountPrecision := data.GetAmountPrecision()
|
||||
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||
@@ -116,7 +122,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
Token: token,
|
||||
Network: network,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
NotifyUrl: notifyURL,
|
||||
RedirectUrl: req.RedirectUrl,
|
||||
Name: req.Name,
|
||||
PaymentType: req.PaymentType,
|
||||
@@ -316,9 +322,11 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
||||
|
||||
// GenerateCode creates a unique trade id.
|
||||
func GenerateCode() string {
|
||||
date := time.Now().Format("20060102")
|
||||
r := rand.Intn(1000)
|
||||
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
|
||||
buf := make([]byte, 18)
|
||||
if _, err := cryptorand.Read(buf); err != nil {
|
||||
panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// GetOrderInfoByTradeId returns a validated order.
|
||||
@@ -333,6 +341,92 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// SubmitManualPayment verifies a submitted transaction hash and marks the
|
||||
// matching on-chain order paid using the same path as automatic chain scans.
|
||||
func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||
tradeId = strings.TrimSpace(tradeId)
|
||||
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||
|
||||
order, err := GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||
}
|
||||
|
||||
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
|
||||
// already stored on any order before touching RPC so repeated/public probes do
|
||||
// not spend RPC quota. Admin mark-paid intentionally keeps using
|
||||
// SubmitManualPayment and the existing verification path.
|
||||
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||
tradeId = strings.TrimSpace(tradeId)
|
||||
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||
|
||||
order, err := GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = validateManualPaymentOrder(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||
}
|
||||
|
||||
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||
if err := validateManualPaymentOrder(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
|
||||
if err != nil {
|
||||
var rspErr *constant.RspError
|
||||
if errors.As(err, &rspErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, constant.ManualPaymentVerifyErr
|
||||
}
|
||||
if err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Currency: order.Currency,
|
||||
Token: order.Token,
|
||||
Network: order.Network,
|
||||
Amount: order.ActualAmount,
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: verifiedBlockTransactionID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedOrder, err := GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.ManualPaymentResponse{
|
||||
TradeId: updatedOrder.TradeId,
|
||||
Status: updatedOrder.Status,
|
||||
BlockTransactionId: updatedOrder.BlockTransactionId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateManualPaymentOrder(order *mdb.Orders) error {
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return constant.OrderNotWaitPay
|
||||
}
|
||||
if !isOnChainOrder(order.PayProvider) {
|
||||
return constant.ManualPaymentProviderErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOnChainOrder(payProvider string) bool {
|
||||
payProvider = strings.TrimSpace(payProvider)
|
||||
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||
}
|
||||
|
||||
const MaxSubOrders = 2
|
||||
|
||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
||||
@@ -588,7 +682,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
||||
if err != nil {
|
||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||
return nil, err
|
||||
return nil, constant.PaymentProviderCreateErr
|
||||
}
|
||||
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -26,7 +27,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
||||
Token: "USDT",
|
||||
Network: "tron",
|
||||
Amount: amount,
|
||||
NotifyUrl: "https://merchant.example/callback",
|
||||
NotifyUrl: "https://93.184.216.34/callback",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +37,31 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
tradeID := GenerateCode()
|
||||
|
||||
if len(tradeID) != 24 {
|
||||
t.Fatalf("trade id length = %d, want 24", len(tradeID))
|
||||
}
|
||||
if strings.Contains(tradeID, "=") {
|
||||
t.Fatalf("trade id = %q, contains padding '='", tradeID)
|
||||
}
|
||||
for _, ch := range tradeID {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("trade id = %q, contains non URL-safe character %q", tradeID, ch)
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("decode trade id %q: %v", tradeID, err)
|
||||
}
|
||||
if len(decoded) != 18 {
|
||||
t.Fatalf("decoded trade id length = %d, want 18", len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
t.Helper()
|
||||
|
||||
@@ -50,6 +76,15 @@ func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
|
||||
req := newCreateTransactionRequest("order_private_notify_url", 1)
|
||||
req.NotifyUrl = "http://127.0.0.1/notify"
|
||||
|
||||
if _, err := CreateTransaction(req, nil); err != constant.NotifyURLErr {
|
||||
t.Fatalf("CreateTransaction error = %v, want %v", err, constant.NotifyURLErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
@@ -167,8 +202,8 @@ func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T
|
||||
}, 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.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
|
||||
t.Fatalf("set rate.forced_rate_list: %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)
|
||||
@@ -190,8 +225,8 @@ func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositi
|
||||
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.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
|
||||
t.Fatalf("set rate.forced_rate_list: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||
t.Fatalf("clear rate.api_url: %v", err)
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
)
|
||||
|
||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
||||
// ErrOrder is returned when checkout initialization cannot find the trade id.
|
||||
var ErrOrder = constant.OrderNotExists
|
||||
|
||||
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
|
||||
// GetCheckoutCounterByTradeId returns checkout initialization data for an existing order.
|
||||
// It does not decide the payment state; callers should use CheckStatus for that.
|
||||
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
||||
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
|
||||
if orderInfo.ID <= 0 {
|
||||
return nil, ErrOrder
|
||||
}
|
||||
|
||||
|
||||
+122
-19
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -26,6 +27,14 @@ import (
|
||||
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
||||
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||
|
||||
var processSolanaOrder = OrderProcessing
|
||||
|
||||
var (
|
||||
solRPCRetryCount = 5
|
||||
solRPCRetryWait = 2 * time.Second
|
||||
solRPCRetryMaxWait = 10 * time.Second
|
||||
)
|
||||
|
||||
type TransferInfo struct {
|
||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||
@@ -150,6 +159,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
// Process each transaction signature
|
||||
for sigIdx, txSig := range result {
|
||||
sig := txSig.Signature
|
||||
retrySignature := false
|
||||
|
||||
// Skip failed transactions
|
||||
if txSig.Err != nil {
|
||||
@@ -213,6 +223,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
||||
retrySignature = true
|
||||
continue
|
||||
}
|
||||
if tradeID == "" {
|
||||
@@ -226,6 +237,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
retrySignature = true
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
||||
@@ -252,13 +264,14 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||
address, tradeID, sig, token, amount)
|
||||
err = OrderProcessing(req)
|
||||
err = processSolanaOrder(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
retrySignature = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -268,6 +281,11 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
||||
}
|
||||
|
||||
if retrySignature {
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s not marked processed because retryable processing failed", address, sig)
|
||||
continue
|
||||
}
|
||||
|
||||
// 标记已处理
|
||||
gProcessedSignatures.Store(sig, time.Now().Unix())
|
||||
}
|
||||
@@ -282,26 +300,74 @@ type solSignatureResult struct {
|
||||
}
|
||||
|
||||
func resolveSolanaRpcURL() (string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
node, err := resolveSolanaRpcNode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
return rpcURL, nil
|
||||
}
|
||||
|
||||
func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
node, err := data.SelectGeneralRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
return nil, 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 nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, nil
|
||||
node.Url = rpcURL
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
tried := make([]uint64, 0, 3)
|
||||
var lastErr error
|
||||
for attempts := 0; attempts < 3; attempts++ {
|
||||
node, err := resolveSolanaRpcNode(tried...)
|
||||
if err != nil {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(tried) > 0 {
|
||||
log.Sugar.Warnf("[SOL] trying alternate RPC node method=%s node=%s", method, data.RpcNodeLogLabel(*node))
|
||||
}
|
||||
|
||||
body, err := solRetryClientWithURL(node.Url, method, params)
|
||||
if err == nil {
|
||||
data.RecordRpcNodeSuccess(node.ID)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||
nodeLabel := data.RpcNodeLogLabel(*node)
|
||||
if !cooling {
|
||||
log.Sugar.Warnf("[SOL] RPC node failed method=%s node=%s failures=%d/%d", method, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||
return nil, err
|
||||
}
|
||||
log.Sugar.Warnf("[SOL] RPC node reached fail threshold method=%s node=%s, trying another node", method, nodeLabel)
|
||||
tried = append(tried, node.ID)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func solRetryClientWithURL(rpcUrl string, method string, params []interface{}) ([]byte, error) {
|
||||
return solRetryClientWithURLHeaders(rpcUrl, method, params, nil)
|
||||
}
|
||||
|
||||
func solRetryClientWithURLHeaders(rpcUrl string, method string, params []interface{}, headers http.Header) ([]byte, error) {
|
||||
client := resty.New()
|
||||
client.SetRetryCount(5)
|
||||
client.SetRetryWaitTime(2 * time.Second)
|
||||
client.SetRetryMaxWaitTime(10 * time.Second)
|
||||
client.SetRetryCount(solRPCRetryCount)
|
||||
client.SetRetryWaitTime(solRPCRetryWait)
|
||||
client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
|
||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
@@ -312,25 +378,35 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
return false
|
||||
})
|
||||
|
||||
rpcUrl, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
req := client.R().SetHeader("Content-Type", "application/json")
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.SetHeader(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
resp, err := req.SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
Post(rpcUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode() >= http.StatusBadRequest {
|
||||
return nil, fmt.Errorf("solana rpc HTTP %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
respBody := resp.Body()
|
||||
if errValue := gjson.GetBytes(respBody, "error"); errValue.Exists() && errValue.Type != gjson.Null {
|
||||
code := errValue.Get("code").String()
|
||||
if code == "" {
|
||||
code = "unknown"
|
||||
}
|
||||
return nil, fmt.Errorf("solana rpc error code=%s", code)
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
@@ -390,6 +466,33 @@ func SolGetTransaction(sig string) ([]byte, error) {
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
func solGetTransactionWithURL(rpcURL string, sig string) ([]byte, error) {
|
||||
return solGetTransactionWithURLHeaders(rpcURL, sig, nil)
|
||||
}
|
||||
|
||||
func solGetTransactionWithURLHeaders(rpcURL string, sig string, headers http.Header) ([]byte, error) {
|
||||
txData, err := solRetryClientWithURLHeaders(rpcURL, "getTransaction", []interface{}{
|
||||
sig,
|
||||
map[string]interface{}{
|
||||
"encoding": "jsonParsed",
|
||||
"commitment": "confirmed",
|
||||
"maxSupportedTransactionVersion": 0, // suport
|
||||
},
|
||||
}, headers)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("SolRetryClient failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errField := gjson.GetBytes(txData, "result.meta.err")
|
||||
if errField.Exists() && errField.Type != gjson.Null {
|
||||
log.Sugar.Warnf("Transaction failed: %v", errField.String())
|
||||
return nil, fmt.Errorf("transaction failed: %s", errField.String())
|
||||
}
|
||||
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||
// Native SOL transfer - check destination directly
|
||||
|
||||
@@ -2,12 +2,19 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -21,6 +28,7 @@ func setupSolanaRPCNode(t *testing.T, url string) func() {
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
@@ -52,6 +60,396 @@ func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLIgnoresManualVerifyOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://paid-solana.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: "https://paid-solana.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: " https://general-solana.example.com ", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||
}
|
||||
if got != "https://general-solana.example.com" {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, want general node", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
var primaryCalls int
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
http.Error(w, "temporary", http.StatusBadGateway)
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
var backupCalls int
|
||||
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backupCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer backup.Close()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||
}
|
||||
}
|
||||
if backupCalls != 0 {
|
||||
t.Fatalf("backup calls before threshold = %d, want 0", backupCalls)
|
||||
}
|
||||
|
||||
body, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient after threshold: %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
if backupCalls == 0 {
|
||||
t.Fatal("backup RPC was not called after threshold")
|
||||
}
|
||||
if primaryCalls == 0 {
|
||||
t.Fatal("primary RPC was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientCountsJSONRPCErrorAsNodeFailure(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
var primaryCalls int
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32005,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
var backupCalls int
|
||||
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backupCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer backup.Close()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||
}
|
||||
}
|
||||
body, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient after JSON-RPC error threshold: %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
if primaryCalls < data.RpcFailoverThreshold {
|
||||
t.Fatalf("primary calls = %d, want at least threshold", primaryCalls)
|
||||
}
|
||||
if backupCalls == 0 {
|
||||
t.Fatal("backup RPC was not called after JSON-RPC error threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientWithURLHeadersForwardsCustomHeaders(t *testing.T) {
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Test-Header"); got != "test-value" {
|
||||
t.Errorf("X-Test-Header = %q, want test-value", got)
|
||||
http.Error(w, "bad custom header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("X-Test-Header", "test-value")
|
||||
body, err := solRetryClientWithURLHeaders(server.URL, "getHealth", nil, headers)
|
||||
if err != nil {
|
||||
t.Fatalf("solRetryClientWithURLHeaders(): %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientWithURLDoesNotForwardUserIPByDefault(t *testing.T) {
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body, err := solRetryClientWithURL(server.URL, "getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("solRetryClientWithURL(): %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolCallBackDoesNotCacheSignatureAfterRetryableOrderProcessingError(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
clearProcessedSignatures()
|
||||
t.Cleanup(clearProcessedSignatures)
|
||||
|
||||
const (
|
||||
address = "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
sig = "retryable-sol-signature"
|
||||
tradeID = "sol-retryable-order-001"
|
||||
amount = 1.23
|
||||
)
|
||||
blockTime := time.Now().Add(time.Minute).Unix()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var rpcReq struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&rpcReq); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch rpcReq.Method {
|
||||
case "getSignaturesForAddress":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": []map[string]interface{}{
|
||||
{
|
||||
"signature": sig,
|
||||
"slot": 1,
|
||||
"err": nil,
|
||||
"blockTime": blockTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
case "getTransaction":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": map[string]interface{}{
|
||||
"blockTime": blockTime,
|
||||
"meta": map[string]interface{}{"err": nil},
|
||||
"transaction": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"instructions": []map[string]interface{}{
|
||||
{
|
||||
"programId": SystemProgramID,
|
||||
"parsed": map[string]interface{}{
|
||||
"type": "transfer",
|
||||
"info": map[string]interface{}{
|
||||
"source": "source-wallet",
|
||||
"destination": address,
|
||||
"lamports": 1_230_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unexpected method "+rpcReq.Method, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: server.URL,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed solana rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkSolana,
|
||||
Symbol: "SOL",
|
||||
ContractAddress: "",
|
||||
Decimals: SOL_Decimals,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed SOL chain token: %v", err)
|
||||
}
|
||||
order := &mdb.Orders{
|
||||
TradeId: tradeID,
|
||||
OrderId: "merchant-sol-retryable-001",
|
||||
Amount: 100,
|
||||
Currency: "CNY",
|
||||
ActualAmount: amount,
|
||||
ReceiveAddress: address,
|
||||
Token: "SOL",
|
||||
Network: mdb.NetworkSolana,
|
||||
Status: mdb.StatusWaitPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
if err := data.LockTransaction(mdb.NetworkSolana, address, "SOL", tradeID, amount, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
oldProcessSolanaOrder := processSolanaOrder
|
||||
calls := 0
|
||||
processSolanaOrder = func(req *request.OrderProcessingRequest) error {
|
||||
calls++
|
||||
if req.TradeId != tradeID {
|
||||
t.Fatalf("order processing trade_id = %q, want %q", req.TradeId, tradeID)
|
||||
}
|
||||
return errors.New("temporary database error")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
processSolanaOrder = oldProcessSolanaOrder
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
SolCallBack(address, &wg)
|
||||
wg.Wait()
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("order processing calls = %d, want 1", calls)
|
||||
}
|
||||
if _, ok := gProcessedSignatures.Load(sig); ok {
|
||||
t.Fatalf("signature %s was cached after retryable order processing error", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientHealthy(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
@@ -78,6 +476,13 @@ func TestSolClientHealthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearProcessedSignatures() {
|
||||
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
||||
gProcessedSignatures.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
|
||||
@@ -18,18 +18,28 @@ import (
|
||||
)
|
||||
|
||||
func resolveTronNode() (string, string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
node, err := ResolveTronRpcNode()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
return rpcURL, node.ApiKey, nil
|
||||
}
|
||||
|
||||
func ResolveTronRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
node, err := data.SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
return nil, 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 nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, node.ApiKey, nil
|
||||
node.Url = rpcURL
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func ResolveTronNode() (string, string, error) {
|
||||
@@ -257,6 +267,10 @@ 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 %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||
return
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: walletAddr,
|
||||
|
||||
@@ -144,3 +144,68 @@ func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
|
||||
t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
tradeID = "T202605110002"
|
||||
orderID = "ORD202605110002"
|
||||
tokenSym = "USDT"
|
||||
amount = 25.5
|
||||
)
|
||||
contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
|
||||
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Symbol: tokenSym,
|
||||
ContractAddress: contract.Hex(),
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed chain token: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: tradeID,
|
||||
OrderId: orderID,
|
||||
Amount: 100,
|
||||
Currency: "CNY",
|
||||
ActualAmount: amount,
|
||||
Token: tokenSym,
|
||||
Network: mdb.NetworkEthereum,
|
||||
ReceiveAddress: strings.ToLower(receiveAddress.Hex()),
|
||||
Status: mdb.StatusWaitPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
if err := data.LockTransaction(mdb.NetworkEthereum, order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
rawValue := big.NewInt(25_500_000) // 25.5 with 6 decimals
|
||||
oldBlockTsMs := order.CreatedAt.TimestampMilli() - 1
|
||||
TryProcessEvmERC20Transfer(mdb.NetworkEthereum, contract, receiveAddress, rawValue, "0xold-hash", oldBlockTsMs)
|
||||
|
||||
got, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if got.Status != mdb.StatusWaitPay {
|
||||
t.Fatalf("order status = %d, want %d", got.Status, mdb.StatusWaitPay)
|
||||
}
|
||||
if got.BlockTransactionId != "" {
|
||||
t.Fatalf("block transaction id = %q, want empty", got.BlockTransactionId)
|
||||
}
|
||||
|
||||
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, receiveAddress.Hex(), tokenSym, amount)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup retained lock: %v", err)
|
||||
}
|
||||
if lockTradeID != tradeID {
|
||||
t.Fatalf("runtime lock trade_id = %q, want %q", lockTradeID, tradeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,49 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTronNodeIgnoresManualVerifyOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://paid-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "paid-key",
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTronNodeUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://paid-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "paid-key", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "general-key", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
gotURL, gotKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTronNode(): %v", err)
|
||||
}
|
||||
if gotURL != "https://general-tron.example.com" || gotKey != "general-key" {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q), want general node", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
|
||||
@@ -2,15 +2,20 @@ package route
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -320,13 +325,30 @@ func TestAdminInitPasswordFlow(t *testing.T) {
|
||||
if fetchData["password"] != initPassword {
|
||||
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
|
||||
}
|
||||
var plaintextRows int64
|
||||
if err := dao.Mdb.Unscoped().
|
||||
Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Count(&plaintextRows).Error; err != nil {
|
||||
t.Fatalf("count init password plaintext rows: %v", err)
|
||||
}
|
||||
if plaintextRows != 0 {
|
||||
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows)
|
||||
}
|
||||
|
||||
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())
|
||||
var respFetch2 map[string]interface{}
|
||||
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
|
||||
t.Fatalf("unmarshal second fetch response: %v", err)
|
||||
}
|
||||
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
|
||||
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
|
||||
}
|
||||
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
|
||||
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
|
||||
}
|
||||
|
||||
token := adminLogin(t, e, testAdminUsername, initPassword)
|
||||
@@ -509,14 +531,25 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
|
||||
if nodeID == nil {
|
||||
t.Fatal("CreateRpcNode missing id")
|
||||
}
|
||||
if got, _ := dataObj["purpose"].(string); got != mdb.RpcNodePurposeGeneral {
|
||||
t.Fatalf("created purpose = %q, want %q", got, mdb.RpcNodePurposeGeneral)
|
||||
}
|
||||
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
||||
"url": "https://eth-mainnet2.example.com",
|
||||
"url": "https://eth-mainnet2.example.com",
|
||||
"purpose": mdb.RpcNodePurposeManualVerify,
|
||||
}, token)
|
||||
t.Logf("UpdateRpcNode: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
updatedNode, err := data.GetRpcNodeByID(uint64(nodeID.(float64)))
|
||||
if err != nil {
|
||||
t.Fatalf("reload rpc node: %v", err)
|
||||
}
|
||||
if updatedNode.Purpose != mdb.RpcNodePurposeManualVerify {
|
||||
t.Fatalf("updated purpose = %q, want %q", updatedNode.Purpose, mdb.RpcNodePurposeManualVerify)
|
||||
}
|
||||
|
||||
// Health check — network likely unreachable in test, but route must not 404/500.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
|
||||
@@ -531,6 +564,95 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
func TestAdminRpcNodes_RejectsURLTypeMismatch(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "wss://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10022)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeWs,
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(ws with https): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10023)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
"purpose": "invalid-purpose",
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(invalid purpose): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected invalid purpose to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10020)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
}, token)
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
nodeID := uint64(dataObj["id"].(float64))
|
||||
nodeIDStr := fmt.Sprintf("%d", nodeID)
|
||||
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
||||
"url": "wss://eth-mainnet.example.com",
|
||||
}, token)
|
||||
t.Logf("UpdateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected update mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10022)
|
||||
|
||||
node, err := data.GetRpcNodeByID(nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload rpc node: %v", err)
|
||||
}
|
||||
if node.Url != "https://eth-mainnet.example.com" {
|
||||
t.Fatalf("node url changed after rejected update: %q", node.Url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPathIDParseErrorUsesParamsErrno(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doGetAdmin(e, "/admin/api/v1/api-keys/not-a-number/stats", token)
|
||||
assertErrorCode(t, rec, 10009)
|
||||
}
|
||||
|
||||
func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode int) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected HTTP 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != wantCode {
|
||||
t.Fatalf("status_code = %d, want %d; response=%v", got, wantCode, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[wantCode] {
|
||||
t.Fatalf("message = %q, want %q; response=%v", got, constant.Errno[wantCode], resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wallets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminWallets_CRUD verifies wallet management routes.
|
||||
@@ -647,6 +769,143 @@ func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-ok",
|
||||
OrderId: "order-admin-mark-paid-ok",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
verified := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
|
||||
verified = true
|
||||
if got.TradeId != order.TradeId {
|
||||
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
|
||||
}
|
||||
if blockID != "block-admin-ok" {
|
||||
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
|
||||
}
|
||||
return "canonical-block-admin-ok", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
jsonBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-ok",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
if !verified {
|
||||
t.Fatal("expected chain verifier to be called")
|
||||
}
|
||||
|
||||
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if paid.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if paid.BlockTransactionId != "canonical-block-admin-ok" {
|
||||
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
|
||||
}
|
||||
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-bad-proof",
|
||||
OrderId: "order-admin-mark-paid-bad-proof",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
return "", errors.New("transaction amount mismatch")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-bad",
|
||||
}, token)
|
||||
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-provider",
|
||||
OrderId: "order-admin-mark-paid-provider",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOkPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
called := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
called = true
|
||||
return "block-admin-provider", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-provider",
|
||||
}, token)
|
||||
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("verifier should not run for non-on-chain order")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
||||
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
@@ -657,6 +916,59 @@ func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-resend-empty-url",
|
||||
OrderId: "order-admin-resend-empty-url",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||
CallbackNum: 3,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
|
||||
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-resend-ok",
|
||||
OrderId: "order-admin-resend-ok",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||
CallbackNum: 3,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
|
||||
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
||||
@@ -676,6 +988,164 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local)
|
||||
updatedAt := createdAt.Add(5 * time.Minute)
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-dashboard-yesterday",
|
||||
OrderId: "order-dashboard-yesterday",
|
||||
Amount: 100,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 12.5,
|
||||
ReceiveAddress: "TTestDashboardAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create historical order: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("age historical order: %v", err)
|
||||
}
|
||||
|
||||
todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token))
|
||||
todayData, _ := todayResp["data"].(map[string]interface{})
|
||||
if got, _ := todayData["order_count"].(float64); got != 0 {
|
||||
t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got)
|
||||
}
|
||||
|
||||
statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token))
|
||||
statsData, _ := statsResp["data"].(map[string]interface{})
|
||||
if got, _ := statsData["order_count"].(float64); got != 1 {
|
||||
t.Fatalf("7d order-stats order_count = %v, want 1", got)
|
||||
}
|
||||
if got, _ := statsData["success_count"].(float64); got != 1 {
|
||||
t.Fatalf("7d order-stats success_count = %v, want 1", got)
|
||||
}
|
||||
|
||||
overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token))
|
||||
overviewData, _ := overviewResp["data"].(map[string]interface{})
|
||||
if got, _ := overviewData["order_count"].(float64); got != 1 {
|
||||
t.Fatalf("7d overview order_count = %v, want 1", got)
|
||||
}
|
||||
if got, _ := overviewData["volume"].(float64); got != 12.5 {
|
||||
t.Fatalf("7d overview volume = %v, want 12.5", got)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/api/v1/dashboard/asset-trend?range=7d",
|
||||
"/admin/api/v1/dashboard/revenue-trend?range=7d",
|
||||
"/admin/api/v1/dashboard/asset-trend?range=30d",
|
||||
"/admin/api/v1/dashboard/revenue-trend?range=30d",
|
||||
} {
|
||||
resp := assertOK(t, doGetAdmin(e, path, token))
|
||||
rows, _ := resp["data"].([]interface{})
|
||||
day := createdAt.Format("2006-01-02")
|
||||
found := false
|
||||
for _, row := range rows {
|
||||
item, _ := row.(map[string]interface{})
|
||||
if item["day"] != day {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if got, _ := item["order_count"].(float64); got != 1 {
|
||||
t.Fatalf("%s order_count for %s = %v, want 1", path, day, got)
|
||||
}
|
||||
if got, _ := item["success_count"].(float64); got != 1 {
|
||||
t.Fatalf("%s success_count for %s = %v, want 1", path, day, got)
|
||||
}
|
||||
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
||||
t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows)
|
||||
}
|
||||
}
|
||||
|
||||
addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token))
|
||||
addressRows, _ := addressResp["data"].([]interface{})
|
||||
day := createdAt.Format("2006-01-02")
|
||||
foundAddress := false
|
||||
for _, row := range addressRows {
|
||||
item, _ := row.(map[string]interface{})
|
||||
if item["day"] != day || item["address"] != order.ReceiveAddress {
|
||||
continue
|
||||
}
|
||||
foundAddress = true
|
||||
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
||||
t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got)
|
||||
}
|
||||
}
|
||||
if !foundAddress {
|
||||
t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
now := time.Now()
|
||||
createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local)
|
||||
updatedAt := createdAt.Add(2 * time.Minute)
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-dashboard-today",
|
||||
OrderId: "order-dashboard-today",
|
||||
Amount: 80,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 9.75,
|
||||
ReceiveAddress: "TTestDashboardAddressHourly",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create today order: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("set today order time: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/api/v1/dashboard/asset-trend?range=today",
|
||||
"/admin/api/v1/dashboard/revenue-trend?range=today",
|
||||
} {
|
||||
resp := assertOK(t, doGetAdmin(e, path, token))
|
||||
rows, _ := resp["data"].([]interface{})
|
||||
hour := createdAt.Format("2006-01-02 15:00")
|
||||
found := false
|
||||
for _, row := range rows {
|
||||
item, _ := row.(map[string]interface{})
|
||||
if item["day"] != hour {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if got, _ := item["order_count"].(float64); got != 1 {
|
||||
t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got)
|
||||
}
|
||||
if got, _ := item["success_count"].(float64); got != 1 {
|
||||
t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got)
|
||||
}
|
||||
if got, _ := item["actual_amount"].(float64); got != 9.75 {
|
||||
t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
|
||||
@@ -711,13 +1181,64 @@ func TestAdminSettings_ListAndUpsert(t *testing.T) {
|
||||
// Upsert a setting.
|
||||
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"},
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
||||
"cny": map[string]interface{}{"usdt": 0.14635},
|
||||
"usd": map[string]interface{}{"usdt": 1},
|
||||
}, "type": "json"},
|
||||
},
|
||||
}, token)
|
||||
t.Logf("UpsertSettings: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
func TestAdminSettings_ForcedRateListValidation(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
||||
" CNY ": map[string]interface{}{" USDT ": 0.14635},
|
||||
}, "type": "string"},
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": `{"cny":`, "type": "json"},
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
||||
"CNY": map[string]interface{}{"USDT": 0.14635},
|
||||
"cny": map[string]interface{}{"usdt": 0.14636},
|
||||
}, "type": "json"},
|
||||
},
|
||||
}, token)
|
||||
resp := assertOK(t, rec)
|
||||
results, ok := resp["data"].([]interface{})
|
||||
if !ok || len(results) != 3 {
|
||||
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["ok"] != true {
|
||||
t.Fatalf("valid forced rate list result = %v", first)
|
||||
}
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if second["ok"] != false {
|
||||
t.Fatalf("invalid forced rate list result = %v, want ok=false", second)
|
||||
}
|
||||
third, _ := results[2].(map[string]interface{})
|
||||
if third["ok"] != false {
|
||||
t.Fatalf("duplicate forced rate list result = %v, want ok=false", third)
|
||||
}
|
||||
|
||||
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.14635}}` {
|
||||
t.Fatalf("forced rate list value = %q", got)
|
||||
}
|
||||
if rate := config.GetRateForCoin("USDT", "CNY"); rate != 0.14635 {
|
||||
t.Fatalf("GetRateForCoin(USDT, CNY) = %v, want 0.14635", rate)
|
||||
}
|
||||
var row mdb.Setting
|
||||
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&row).Error; err != nil {
|
||||
t.Fatalf("load forced rate list: %v", err)
|
||||
}
|
||||
if row.Type != mdb.SettingTypeJSON {
|
||||
t.Fatalf("forced rate list type = %q, want %q", row.Type, mdb.SettingTypeJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
@@ -786,6 +1307,148 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "http://127.0.0.1:8080/", "type": "string"},
|
||||
},
|
||||
}, token)
|
||||
resp := assertOK(t, rec)
|
||||
results, ok := resp["data"].([]interface{})
|
||||
if !ok || len(results) != 1 {
|
||||
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
||||
}
|
||||
result, _ := results[0].(map[string]interface{})
|
||||
if result["ok"] != false {
|
||||
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
|
||||
}
|
||||
if got := int(result["error_code"].(float64)); got != 10043 {
|
||||
t.Fatalf("private rate.api_url error_code = %d, want 10043; result=%v", got, result)
|
||||
}
|
||||
if got, _ := result["error"].(string); got != constant.Errno[10043] {
|
||||
t.Fatalf("private rate.api_url error = %q, want %q", got, constant.Errno[10043])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings_AllowsPublicRateAPIURL(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate", "type": "string"},
|
||||
},
|
||||
}, token)
|
||||
resp := assertOK(t, rec)
|
||||
results, ok := resp["data"].([]interface{})
|
||||
if !ok || len(results) != 1 {
|
||||
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
||||
}
|
||||
result, _ := results[0].(map[string]interface{})
|
||||
if result["ok"] != true {
|
||||
t.Fatalf("public rate.api_url result = %v, want ok=true", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-old", "type": "string"},
|
||||
},
|
||||
}, token)
|
||||
assertOK(t, rec)
|
||||
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateApiUrl, token)
|
||||
assertOK(t, rec)
|
||||
if got := data.GetSettingString(mdb.SettingKeyRateApiUrl, "fallback"); got != "fallback" {
|
||||
t.Fatalf("deleted setting still in cache/read path: got %q", got)
|
||||
}
|
||||
|
||||
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-new", "type": "string"},
|
||||
},
|
||||
}, token)
|
||||
assertOK(t, rec)
|
||||
|
||||
rec = doGetAdmin(e, "/admin/api/v1/settings?group=rate", token)
|
||||
resp := assertOK(t, rec)
|
||||
rows, ok := resp["data"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected settings array, got %T", resp["data"])
|
||||
}
|
||||
found := false
|
||||
for _, row := range rows {
|
||||
item, _ := row.(map[string]interface{})
|
||||
if item["key"] == mdb.SettingKeyRateApiUrl {
|
||||
found = true
|
||||
if item["value"] != "https://93.184.216.34/rate-new" {
|
||||
t.Fatalf("rate.api_url value = %v, want new value", item["value"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected settings list to include restored %s", mdb.SettingKeyRateApiUrl)
|
||||
}
|
||||
|
||||
var restored mdb.Setting
|
||||
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateApiUrl).Take(&restored).Error; err != nil {
|
||||
t.Fatalf("load restored setting unscoped: %v", err)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings_DeleteThenReupsertRestoresForcedRateList(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
||||
"CNY": map[string]interface{}{"USDT": 0.14635},
|
||||
}, "type": "json"},
|
||||
},
|
||||
}, token)
|
||||
assertOK(t, rec)
|
||||
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateForcedRateList, token)
|
||||
assertOK(t, rec)
|
||||
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, "fallback"); got != "fallback" {
|
||||
t.Fatalf("deleted forced rate list still in cache/read path: got %q", got)
|
||||
}
|
||||
if got := config.GetRateForCoin("USDT", "CNY"); got != 0 {
|
||||
t.Fatalf("deleted forced rate list still used by rate lookup: got %v", got)
|
||||
}
|
||||
|
||||
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
||||
"CNY": map[string]interface{}{"USDT": 0.15},
|
||||
}, "type": "json"},
|
||||
},
|
||||
}, token)
|
||||
assertOK(t, rec)
|
||||
|
||||
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.15}}` {
|
||||
t.Fatalf("restored forced rate list = %q", got)
|
||||
}
|
||||
if got := config.GetRateForCoin("USDT", "CNY"); got != 0.15 {
|
||||
t.Fatalf("restored forced rate list lookup = %v, want 0.15", got)
|
||||
}
|
||||
|
||||
var restored mdb.Setting
|
||||
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&restored).Error; err != nil {
|
||||
t.Fatalf("load restored forced rate list unscoped: %v", err)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatalf("restored forced rate list still has deleted_at=%v", restored.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil {
|
||||
@@ -1003,7 +1666,7 @@ func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
|
||||
"currency": "CNY",
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
"notify_url": "https://merchant.example/callback",
|
||||
"notify_url": "https://93.184.216.34/callback",
|
||||
"redirect_url": "https://merchant.example/redirect",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
|
||||
|
||||
+4
-3
@@ -37,6 +37,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
||||
payRoute.POST("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
|
||||
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||
|
||||
// payment routes
|
||||
@@ -74,7 +75,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
formParams, err := ctx.FormParams()
|
||||
if err != nil && ctx.Request().Method == http.MethodPost {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
|
||||
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err == nil {
|
||||
copyParams(formParams)
|
||||
@@ -131,7 +132,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||
return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -153,7 +154,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
|
||||
}
|
||||
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||
|
||||
+472
-43
@@ -17,9 +17,12 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/spf13/viper"
|
||||
@@ -80,8 +83,8 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
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)
|
||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.14285714285714285}}`, "json"); err != nil {
|
||||
t.Fatalf("seed rate.forced_rate_list: %v", err)
|
||||
}
|
||||
|
||||
// seed wallet addresses
|
||||
@@ -239,7 +242,7 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
@@ -268,6 +271,57 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
||||
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
|
||||
}
|
||||
|
||||
func TestCreateOrderGmpayRejectsPrivateNotifyURL(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "test-private-notify-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://127.0.0.1/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal private notify response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10041 {
|
||||
t.Fatalf("status_code = %d, want 10041; response=%v", got, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[10041] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[10041])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicJSONBindErrorUsesParamsErrno(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/bad-json-order", strings.NewReader("{"))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal bind error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10009 {
|
||||
t.Fatalf("status_code = %d, want 10009; response=%v", got, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[10009] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[10009])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
|
||||
func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
@@ -278,7 +332,7 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
||||
"token": "sol",
|
||||
"currency": "usd",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
@@ -333,7 +387,7 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
|
||||
"token": {"usdt"},
|
||||
"currency": {"cny"},
|
||||
"network": {"solana"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
"notify_url": {"https://93.184.216.34/notify"},
|
||||
})
|
||||
|
||||
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
|
||||
@@ -483,6 +537,11 @@ func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
|
||||
|
||||
func TestGetPublicConfig(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
oldVersion := config.BuildVersion
|
||||
config.BuildVersion = "v1.0.1"
|
||||
t.Cleanup(func() {
|
||||
config.BuildVersion = oldVersion
|
||||
})
|
||||
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "asd", mdb.SettingTypeString); err != nil {
|
||||
t.Fatalf("seed brand.checkout_name: %v", err)
|
||||
}
|
||||
@@ -526,6 +585,9 @@ func TestGetPublicConfig(t *testing.T) {
|
||||
if len(supports) < 2 {
|
||||
t.Fatalf("expected >= 2 network supports, got %d", len(supports))
|
||||
}
|
||||
if respData["version"] != "v1.0.1" {
|
||||
t.Fatalf("version = %v, want v1.0.1", respData["version"])
|
||||
}
|
||||
site, ok := respData["site"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected site object, got %T", respData["site"])
|
||||
@@ -578,6 +640,12 @@ func TestGetPublicConfig(t *testing.T) {
|
||||
row := item.(map[string]interface{})
|
||||
network := row["network"].(string)
|
||||
seen[network] = true
|
||||
if _, ok := row["display_name"].(string); !ok {
|
||||
t.Fatalf("supported_assets[%s].display_name missing or not string: %#v", network, row["display_name"])
|
||||
}
|
||||
if network == "tron" && row["display_name"] != "TRON" {
|
||||
t.Fatalf("supported_assets.tron.display_name = %v, want TRON", row["display_name"])
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"tron", "solana"} {
|
||||
if !seen[n] {
|
||||
@@ -673,7 +741,7 @@ func seedOkPayNotifyFixture(t *testing.T) *okPayNotifyFixture {
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "http://localhost/notify",
|
||||
NotifyUrl: "https://93.184.216.34/notify",
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
@@ -913,7 +981,7 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
|
||||
@@ -941,7 +1009,7 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
||||
"type": {"alipay"},
|
||||
"money": {"1.00"},
|
||||
"out_trade_no": {"epay-get-001"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
"notify_url": {"https://93.184.216.34/notify"},
|
||||
"return_url": {"http://localhost/return"},
|
||||
})
|
||||
|
||||
@@ -966,7 +1034,7 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
||||
"type": {"alipay"},
|
||||
"money": {"1.00"},
|
||||
"out_trade_no": {"epay-post-001"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
"notify_url": {"https://93.184.216.34/notify"},
|
||||
"return_url": {"http://localhost/return"},
|
||||
"sitename": {"example-shop"},
|
||||
})
|
||||
@@ -989,51 +1057,295 @@ func TestCheckStatus_NotFound(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route returned 404 with empty body — route may not be registered")
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal check-status error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
|
||||
// and a status field when the order exists.
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns the
|
||||
// real order status for existing orders in all checkout-relevant states.
|
||||
func TestCheckStatus_WithOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create an order first via the GMPAY route.
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "check-status-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(createRec.Body.Bytes(), &createResp)
|
||||
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
if tradeId == "" {
|
||||
t.Fatal("no trade_id in create response")
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
}{
|
||||
{name: "wait-pay", status: mdb.StatusWaitPay},
|
||||
{name: "paid", status: mdb.StatusPaySuccess},
|
||||
{name: "expired", status: mdb.StatusExpired},
|
||||
}
|
||||
|
||||
// Now check status.
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "check-status-"+tc.name)
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Update("status", tc.status).Error; err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus(%s): status=%d body=%s", tc.name, rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal check-status response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in check-status response, got: %v", resp)
|
||||
}
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q", got, tradeID)
|
||||
}
|
||||
if got := int(data["status"].(float64)); got != tc.status {
|
||||
t.Fatalf("status = %d, want %d", got, tc.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-ok-001")
|
||||
|
||||
verified := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(order *mdb.Orders, blockID string) (string, error) {
|
||||
verified = true
|
||||
if order.TradeId != tradeID {
|
||||
t.Fatalf("validator trade_id = %s, want %s", order.TradeId, tradeID)
|
||||
}
|
||||
if blockID != "user-submitted-hash" {
|
||||
t.Fatalf("validator block id = %s, want user-submitted-hash", blockID)
|
||||
}
|
||||
return "canonical-user-submitted-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
jsonBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"block_transaction_id": " user-submitted-hash ",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/"+tradeID, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
t.Logf("SubmitTxHash(success): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["data"] == nil {
|
||||
t.Fatal("expected data in check-status response")
|
||||
if !verified {
|
||||
t.Fatal("expected chain verifier to be called")
|
||||
}
|
||||
|
||||
var submitResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &submitResp); err != nil {
|
||||
t.Fatalf("unmarshal submit response: %v", err)
|
||||
}
|
||||
submitData, ok := submitResp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in submit response, got: %v", submitResp)
|
||||
}
|
||||
if got, _ := submitData["block_transaction_id"].(string); got != "canonical-user-submitted-hash" {
|
||||
t.Fatalf("block_transaction_id = %q", got)
|
||||
}
|
||||
if got := int(submitData["status"].(float64)); got != mdb.StatusPaySuccess {
|
||||
t.Fatalf("submit response status = %d, want %d", got, mdb.StatusPaySuccess)
|
||||
}
|
||||
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
statusRec := httptest.NewRecorder()
|
||||
e.ServeHTTP(statusRec, statusReq)
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
|
||||
}
|
||||
var statusResp map[string]interface{}
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResp); err != nil {
|
||||
t.Fatalf("unmarshal check-status response: %v", err)
|
||||
}
|
||||
statusData, ok := statusResp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in check-status response, got: %v", statusResp)
|
||||
}
|
||||
if got := int(statusData["status"].(float64)); got != mdb.StatusPaySuccess {
|
||||
t.Fatalf("check-status status = %d, want %d", got, mdb.StatusPaySuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_VerificationFailureAllowsRetrySameHash(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-retry-001")
|
||||
|
||||
calls := 0
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(order *mdb.Orders, blockID string) (string, error) {
|
||||
calls++
|
||||
if order.TradeId != tradeID {
|
||||
t.Fatalf("validator trade_id = %s, want %s", order.TradeId, tradeID)
|
||||
}
|
||||
if blockID != "retry-hash" {
|
||||
t.Fatalf("validator block id = %s, want retry-hash", blockID)
|
||||
}
|
||||
if calls == 1 {
|
||||
return "", fmt.Errorf("rpc verification failed")
|
||||
}
|
||||
return "canonical-retry-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "retry-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(verification failure): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var failResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &failResp); err != nil {
|
||||
t.Fatalf("unmarshal failure response: %v", err)
|
||||
}
|
||||
if got := int(failResp["status_code"].(float64)); got != 10038 {
|
||||
t.Fatalf("failure status_code = %d, want 10038", got)
|
||||
}
|
||||
if got, _ := failResp["message"].(string); got != constant.Errno[10038] {
|
||||
t.Fatalf("failure message = %q, want %q", got, constant.Errno[10038])
|
||||
}
|
||||
|
||||
reloaded, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order after failure: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||
t.Fatalf("order changed after failed submit: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
statusRec := httptest.NewRecorder()
|
||||
e.ServeHTTP(statusRec, statusReq)
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
|
||||
}
|
||||
var statusResp map[string]interface{}
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResp); err != nil {
|
||||
t.Fatalf("unmarshal check-status response: %v", err)
|
||||
}
|
||||
statusData := statusResp["data"].(map[string]interface{})
|
||||
if got := int(statusData["status"].(float64)); got != mdb.StatusWaitPay {
|
||||
t.Fatalf("check-status status after failed submit = %d, want %d", got, mdb.StatusWaitPay)
|
||||
}
|
||||
|
||||
retryRec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "retry-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(retry same hash): status=%d body=%s", retryRec.Code, retryRec.Body.String())
|
||||
if retryRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected retry success, got %d: %s", retryRec.Code, retryRec.Body.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("validator calls = %d, want 2", calls)
|
||||
}
|
||||
reloaded, err = data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order after retry: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusPaySuccess || reloaded.BlockTransactionId != "canonical-retry-hash" {
|
||||
t.Fatalf("order after retry: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_RejectsOkPayOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-submit-tx-hash-okpay",
|
||||
OrderId: "order-submit-tx-hash-okpay",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "OKPAY",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOkPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
called := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
called = true
|
||||
return "canonical-okpay-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+order.TradeId, map[string]interface{}{
|
||||
"block_transaction_id": "okpay-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(okpay): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("verifier should not run for OkPay/provider orders")
|
||||
}
|
||||
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||
t.Fatalf("order changed after rejected submit: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_RejectsExistingHashBeforeRpc(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
existing := &mdb.Orders{
|
||||
TradeId: "trade-submit-tx-hash-existing",
|
||||
OrderId: "order-submit-tx-hash-existing",
|
||||
BlockTransactionId: "existing-hash",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(existing).Error; err != nil {
|
||||
t.Fatalf("create existing paid order: %v", err)
|
||||
}
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-existing-001")
|
||||
|
||||
called := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
called = true
|
||||
return "existing-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "existing-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(existing hash): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("verifier should not run when hash already exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1061,6 +1373,123 @@ func TestCheckoutCounter_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-paid-001")
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Update("status", mdb.StatusPaySuccess).Error; err != nil {
|
||||
t.Fatalf("mark order paid: %v", err)
|
||||
}
|
||||
|
||||
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||
}
|
||||
if got, _ := data["redirect_url"].(string); got != "https://merchant.example/return" {
|
||||
t.Fatalf("redirect_url = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
|
||||
expiredCreatedAt := carbon.Now().SubMinutes(config.GetOrderExpirationTime() + 1)
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusExpired,
|
||||
"created_at": expiredCreatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("mark order expired: %v", err)
|
||||
}
|
||||
|
||||
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||
}
|
||||
expirationTime, _ := data["expiration_time"].(float64)
|
||||
if int64(expirationTime) > carbon.Now().TimestampMilli() {
|
||||
t.Fatalf("expiration_time = %.0f, want expired timestamp", expirationTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_UnknownOrderReturnsClearError(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/nonexistent-trade-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal checkout counter error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||
}
|
||||
if resp["message"] == "" {
|
||||
t.Fatalf("expected error message, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID string) string {
|
||||
t.Helper()
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
"redirect_url": "https://merchant.example/return",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create order failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected create response data, got: %v", resp)
|
||||
}
|
||||
tradeID, _ := data["trade_id"].(string)
|
||||
if tradeID == "" {
|
||||
t.Fatalf("missing trade_id in create response: %v", resp)
|
||||
}
|
||||
return tradeID
|
||||
}
|
||||
|
||||
func getCheckoutCounterRespData(t *testing.T, e *echo.Echo, tradeID string) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/"+tradeID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal checkout counter response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout counter data, got: %v", resp)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
|
||||
// required fields and returns a graceful error when they are missing.
|
||||
func TestSwitchNetwork_MissingFields(t *testing.T) {
|
||||
@@ -1107,7 +1536,7 @@ func TestSwitchNetwork_WithOrder(t *testing.T) {
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||
if createRec.Code != http.StatusOK {
|
||||
@@ -1199,7 +1628,7 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||
if createRec.Code != http.StatusOK {
|
||||
@@ -1296,7 +1725,7 @@ func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"notify_url": "https://93.184.216.34/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||
if createRec.Code != http.StatusOK {
|
||||
|
||||
@@ -66,18 +66,18 @@ func runBscListener(contracts []common.Address) {
|
||||
}
|
||||
}()
|
||||
|
||||
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
|
||||
wsNode, ok := resolveChainWsNode(mdb.NetworkBsc, "[BSC-WS]")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||
log.Sugar.Infof("[BSC-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
Addresses: contracts,
|
||||
Topics: [][]common.Hash{},
|
||||
}
|
||||
|
||||
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
runEvmWsLogListener(ctx, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
if len(vLog.Topics) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,19 +93,28 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
|
||||
// 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)
|
||||
node, ok := resolveChainWsNode(network, logPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(node.Url), true
|
||||
}
|
||||
|
||||
func resolveChainWsNode(network, logPrefix string, excludeIDs ...uint64) (mdb.RpcNode, bool) {
|
||||
node, err := data.SelectGeneralRpcNode(network, mdb.RpcNodeTypeWs, excludeIDs...)
|
||||
if err == nil && node != nil && node.ID > 0 {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL != "" {
|
||||
return rpcURL, true
|
||||
node.Url = rpcURL
|
||||
return *node, true
|
||||
}
|
||||
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
|
||||
return "", false
|
||||
return mdb.RpcNode{}, 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
|
||||
return mdb.RpcNode{}, false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,79 @@ func TestResolveChainWsURLWithRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChainWsURLIgnoresManualVerifyOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Url: "wss://paid-ethereum.example.com",
|
||||
Type: mdb.RpcNodeTypeWs,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
|
||||
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChainWsURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://paid-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkEthereum, Url: " wss://general-ethereum.example.com ", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
|
||||
if !ok {
|
||||
t.Fatalf("resolveChainWsURL() ok=false, want true")
|
||||
}
|
||||
if got != "wss://general-ethereum.example.com" {
|
||||
t.Fatalf("resolveChainWsURL() = %q, want general node", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChainWsNodeSkipsCoolingNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://primary-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://backup-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
primary, ok := resolveChainWsNode(mdb.NetworkEthereum, "[TEST]")
|
||||
if !ok || primary.Url != "wss://primary-ethereum.example.com" {
|
||||
t.Fatalf("primary node = %#v ok=%v, want primary", primary, ok)
|
||||
}
|
||||
for i := 0; i < data.RpcFailoverThreshold; i++ {
|
||||
data.RecordRpcNodeFailure(primary.ID)
|
||||
}
|
||||
|
||||
got, ok := resolveChainWsNode(mdb.NetworkEthereum, "[TEST]")
|
||||
if !ok {
|
||||
t.Fatalf("resolveChainWsNode() ok=false, want true")
|
||||
}
|
||||
if got.Url != "wss://backup-ethereum.example.com" {
|
||||
t.Fatalf("resolveChainWsNode() = %#v, want backup", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -71,18 +71,18 @@ func runEthereumListener(contracts []common.Address) {
|
||||
}
|
||||
}()
|
||||
|
||||
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
|
||||
wsNode, ok := resolveChainWsNode(mdb.NetworkEthereum, "[ETH-WS]")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||
log.Sugar.Infof("[ETH-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
Addresses: contracts,
|
||||
Topics: [][]common.Hash{},
|
||||
}
|
||||
|
||||
runEvmWsLogListener(ctx, "[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
runEvmWsLogListener(ctx, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
if len(vLog.Topics) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
+44
-14
@@ -2,8 +2,11 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
@@ -11,18 +14,21 @@ import (
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
)
|
||||
|
||||
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs,
|
||||
// runEvmWsLogListener connects to node.Url, subscribes to Transfer logs,
|
||||
// and dispatches each log to handleLog. It retries on transient errors
|
||||
// with exponential backoff. The ctx lets the caller trigger a clean
|
||||
// exit — e.g. when admin disables the chain, the caller cancels the
|
||||
// context and the function returns instead of reconnecting forever.
|
||||
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||
// with exponential backoff until the node reaches the failure threshold.
|
||||
// The ctx lets the caller trigger a clean exit — e.g. when admin disables
|
||||
// the chain, the caller cancels the context and the function returns.
|
||||
func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||
const (
|
||||
minBackoff = 2 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
rejoinWait = 3 * time.Second
|
||||
minBackoff = 2 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
rejoinWait = 3 * time.Second
|
||||
stableResetAfter = 60 * time.Second
|
||||
)
|
||||
failWait := minBackoff
|
||||
wsURL := node.Url
|
||||
nodeLabel := data.RpcNodeLogLabel(node)
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
@@ -32,6 +38,9 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
|
||||
client, err := ethclient.Dial(wsURL)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
||||
if recordEvmWsNodeFailure(logPrefix, node, "dial") {
|
||||
return
|
||||
}
|
||||
if !sleepOrDone(ctx, failWait) {
|
||||
return
|
||||
}
|
||||
@@ -44,6 +53,9 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
|
||||
if err != nil {
|
||||
client.Close()
|
||||
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
||||
if recordEvmWsNodeFailure(logPrefix, node, "subscribe") {
|
||||
return
|
||||
}
|
||||
if !sleepOrDone(ctx, failWait) {
|
||||
return
|
||||
}
|
||||
@@ -52,20 +64,26 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
|
||||
}
|
||||
failWait = minBackoff
|
||||
|
||||
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix)
|
||||
log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
|
||||
|
||||
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
|
||||
connectedAt := time.Now()
|
||||
recvErr := recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(connectedAt) >= stableResetAfter {
|
||||
data.RecordRpcNodeSuccess(node.ID)
|
||||
} else if recvErr != nil && recordEvmWsNodeFailure(logPrefix, node, recvErr.Error()) {
|
||||
return
|
||||
}
|
||||
if !sleepOrDone(ctx, rejoinWait) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
|
||||
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) error {
|
||||
defer func() {
|
||||
sub.Unsubscribe()
|
||||
client.Close()
|
||||
@@ -75,24 +93,36 @@ func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscr
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
||||
return
|
||||
return nil
|
||||
case err := <-sub.Err():
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
||||
return fmt.Errorf("subscription error")
|
||||
} else {
|
||||
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
|
||||
return fmt.Errorf("subscription closed")
|
||||
}
|
||||
return
|
||||
case vLog, ok := <-logsCh:
|
||||
if !ok {
|
||||
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
||||
return
|
||||
return fmt.Errorf("log channel closed")
|
||||
}
|
||||
handleLog(client, vLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordEvmWsNodeFailure(logPrefix string, node mdb.RpcNode, reason string) bool {
|
||||
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||
nodeLabel := data.RpcNodeLogLabel(node)
|
||||
if !cooling {
|
||||
log.Sugar.Warnf("%s WSS node failed (%s), node=%s failures=%d/%d", logPrefix, reason, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||
return false
|
||||
}
|
||||
log.Sugar.Warnf("%s WSS node reached fail threshold (%s), node=%s, resolving another node", logPrefix, reason, nodeLabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// sleepOrDone waits for d or for ctx cancellation, whichever comes
|
||||
// first. Returns true if the sleep completed normally, false if ctx
|
||||
// was cancelled (caller should exit).
|
||||
|
||||
@@ -65,18 +65,18 @@ func runPlasmaListener(contracts []common.Address) {
|
||||
}
|
||||
}()
|
||||
|
||||
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
|
||||
wsNode, ok := resolveChainWsNode(mdb.NetworkPlasma, "[PLASMA-WS]")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||
log.Sugar.Infof("[PLASMA-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
Addresses: contracts,
|
||||
Topics: [][]common.Hash{},
|
||||
}
|
||||
|
||||
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
if len(vLog.Topics) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,18 +65,18 @@ func runPolygonListener(contracts []common.Address) {
|
||||
}
|
||||
}()
|
||||
|
||||
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
|
||||
wsNode, ok := resolveChainWsNode(mdb.NetworkPolygon, "[POLYGON-WS]")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||
log.Sugar.Infof("[POLYGON-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
Addresses: contracts,
|
||||
Topics: [][]common.Hash{},
|
||||
}
|
||||
|
||||
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||
if len(vLog.Topics) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,6 +337,8 @@ func processBlock(block *Block, tokenMap map[string]mdb.ChainToken) {
|
||||
type Scanner struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
nodeID uint64
|
||||
nodeLabel string
|
||||
lastBlock int64
|
||||
// 统计
|
||||
totalBlocks int64
|
||||
@@ -349,16 +351,16 @@ func NewScanner() *Scanner {
|
||||
}
|
||||
|
||||
func (s *Scanner) Init() error {
|
||||
baseURL, apiKey, err := service.ResolveTronNode()
|
||||
node, err := service.ResolveTronRpcNode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve tron node: %w", err)
|
||||
}
|
||||
s.baseURL = baseURL
|
||||
s.apiKey = apiKey
|
||||
s.useRpcNode(node)
|
||||
|
||||
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
|
||||
log.Sugar.Infof("[TRON-BLOCK] using RPC node %s", s.nodeLabel)
|
||||
block, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||
if err != nil {
|
||||
s.recordRpcFailure("initial get latest block")
|
||||
return fmt.Errorf("获取初始块失败: %w", err)
|
||||
}
|
||||
s.lastBlock = block.BlockHeader.RawData.Number
|
||||
@@ -366,6 +368,13 @@ func (s *Scanner) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) useRpcNode(node *mdb.RpcNode) {
|
||||
s.baseURL = strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
s.apiKey = node.ApiKey
|
||||
s.nodeID = node.ID
|
||||
s.nodeLabel = data.RpcNodeLogLabel(*node)
|
||||
}
|
||||
|
||||
func (s *Scanner) Run() {
|
||||
log.Sugar.Info("[TRON-BLOCK] start scanning (TRC20 + TRX)")
|
||||
ticker := time.NewTicker(PollInterval)
|
||||
@@ -388,14 +397,17 @@ func (s *Scanner) poll() {
|
||||
latest, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
|
||||
s.recordRpcFailure("get latest block")
|
||||
return
|
||||
}
|
||||
latestNum := latest.BlockHeader.RawData.Number
|
||||
if latestNum <= s.lastBlock {
|
||||
data.RecordRpcNodeSuccess(s.nodeID)
|
||||
return
|
||||
}
|
||||
|
||||
tokenMap := loadTronTRC20TokenMap()
|
||||
hadBlockFetchError := false
|
||||
for num := s.lastBlock + 1; num <= latestNum; num++ {
|
||||
var block *Block
|
||||
if num == latestNum {
|
||||
@@ -404,7 +416,8 @@ func (s *Scanner) poll() {
|
||||
block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
|
||||
continue
|
||||
hadBlockFetchError = true
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
@@ -420,6 +433,29 @@ func (s *Scanner) poll() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if hadBlockFetchError {
|
||||
s.recordRpcFailure("get historical block")
|
||||
return
|
||||
}
|
||||
data.RecordRpcNodeSuccess(s.nodeID)
|
||||
}
|
||||
|
||||
func (s *Scanner) recordRpcFailure(reason string) {
|
||||
failures, cooling := data.RecordRpcNodeFailure(s.nodeID)
|
||||
if !cooling {
|
||||
log.Sugar.Warnf("[TRON-BLOCK] RPC node failed (%s), node=%s failures=%d/%d", reason, s.nodeLabel, failures, data.RpcFailoverThreshold)
|
||||
return
|
||||
}
|
||||
|
||||
log.Sugar.Warnf("[TRON-BLOCK] RPC node reached fail threshold (%s), node=%s, trying another node", reason, s.nodeLabel)
|
||||
node, err := service.ResolveTronRpcNode(s.nodeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[TRON-BLOCK] no alternate RPC node available after failure, keep current node=%s: %v", s.nodeLabel, err)
|
||||
return
|
||||
}
|
||||
oldLabel := s.nodeLabel
|
||||
s.useRpcNode(node)
|
||||
log.Sugar.Warnf("[TRON-BLOCK] switched RPC node from %s to %s", oldLabel, s.nodeLabel)
|
||||
}
|
||||
|
||||
func StartTronBlockScannerListener() {
|
||||
|
||||
@@ -1,23 +1,112 @@
|
||||
package task
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
func TestTronAddressToHexConvertsBase58Contract(t *testing.T) {
|
||||
got, err := TronAddressToHex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")
|
||||
if err != nil {
|
||||
t.Fatalf("TronAddressToHex(): %v", err)
|
||||
"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 TestTronScannerSwitchesNodeAfterFailureThreshold(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
|
||||
primary := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://primary-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
backup := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://backup-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(primary).Error; err != nil {
|
||||
t.Fatalf("seed primary rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(backup).Error; err != nil {
|
||||
t.Fatalf("seed backup rpc_node: %v", err)
|
||||
}
|
||||
|
||||
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
if got != want {
|
||||
t.Fatalf("TronAddressToHex() = %q, want %q", got, want)
|
||||
scanner := NewScanner()
|
||||
scanner.useRpcNode(primary)
|
||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||
scanner.recordRpcFailure("test")
|
||||
if scanner.nodeID != primary.ID {
|
||||
t.Fatalf("node switched before threshold to id=%d", scanner.nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
scanner.recordRpcFailure("test")
|
||||
if scanner.nodeID != backup.ID {
|
||||
t.Fatalf("scanner node id = %d, want backup id=%d", scanner.nodeID, backup.ID)
|
||||
}
|
||||
if scanner.baseURL != backup.Url {
|
||||
t.Fatalf("scanner baseURL = %q, want %q", scanner.baseURL, backup.Url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTronHexAddress(t *testing.T) {
|
||||
got := normalizeTronHexAddress("0X41A614F803B6FD780986A42C78EC9C7F77E6DED13C")
|
||||
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeTronHexAddress() = %q, want %q", got, want)
|
||||
func TestTronScannerStopsOnHistoricalBlockFetchError(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wallet/getnowblock":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"block_header": map[string]interface{}{
|
||||
"raw_data": map[string]interface{}{
|
||||
"number": 3,
|
||||
"timestamp": int64(1000),
|
||||
},
|
||||
},
|
||||
"transactions": []interface{}{},
|
||||
})
|
||||
case "/wallet/getblockbynum":
|
||||
http.Error(w, "temporary", http.StatusBadGateway)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
node.Url = server.URL
|
||||
scanner := NewScanner()
|
||||
scanner.useRpcNode(node)
|
||||
scanner.lastBlock = 1
|
||||
|
||||
scanner.poll()
|
||||
if scanner.lastBlock != 1 {
|
||||
t.Fatalf("lastBlock = %d, want 1 so failed block is retried", scanner.lastBlock)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
const rpcProbeTimeout = 5 * time.Second
|
||||
|
||||
// RpcHealthJob periodically probes every enabled rpc_nodes row and
|
||||
// writes status/last_latency_ms. Results drive SelectRpcNode weighted
|
||||
// picking at runtime.
|
||||
// RpcHealthJob periodically probes enabled general/both rpc_nodes rows.
|
||||
// Manual verification nodes are left for on-demand admin checks so paid
|
||||
// endpoints are not consumed by the scheduler.
|
||||
type RpcHealthJob struct{}
|
||||
|
||||
var gRpcHealthJobLock sync.Mutex
|
||||
@@ -25,7 +25,7 @@ func (r RpcHealthJob) Run() {
|
||||
gRpcHealthJobLock.Lock()
|
||||
defer gRpcHealthJobLock.Unlock()
|
||||
|
||||
nodes, err := data.ListRpcNodes("")
|
||||
nodes, err := data.ListRpcNodesForHealth()
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package constant
|
||||
|
||||
import "errors"
|
||||
|
||||
var Errno = map[int]string{
|
||||
400: "system error",
|
||||
401: "signature verification failed",
|
||||
@@ -21,6 +23,30 @@ var Errno = map[int]string{
|
||||
10017: "payment provider is not enabled",
|
||||
10018: "payment provider configuration is incomplete",
|
||||
10019: "payment provider does not support this token or network",
|
||||
10020: "invalid rpc node purpose",
|
||||
10021: "invalid rpc node url",
|
||||
10022: "rpc node http url required",
|
||||
10023: "rpc node websocket url required",
|
||||
10024: "invalid rpc node type",
|
||||
10025: "invalid username or password",
|
||||
10026: "admin user disabled",
|
||||
10027: "admin unauthorized",
|
||||
10028: "admin user not found",
|
||||
10029: "old password incorrect",
|
||||
10030: "wallet not found",
|
||||
10031: "api key not found",
|
||||
10032: "rpc node not found",
|
||||
10033: "order callback not applicable",
|
||||
10034: "order notify url empty",
|
||||
10035: "resend callback failed",
|
||||
10036: "invalid notification config",
|
||||
10037: "invalid notification events",
|
||||
10038: "manual payment verification failed",
|
||||
10039: "manual payment only supports on-chain orders",
|
||||
10040: "initial admin password unavailable, check from logs",
|
||||
10041: "invalid notify url",
|
||||
10042: "payment provider order creation failed",
|
||||
10043: "invalid setting item",
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -44,6 +70,30 @@ var (
|
||||
PaymentProviderNotEnabled = Err(10017)
|
||||
PaymentProviderConfigErr = Err(10018)
|
||||
PaymentProviderNotSupport = Err(10019)
|
||||
RpcNodePurposeErr = Err(10020)
|
||||
RpcNodeURLErr = Err(10021)
|
||||
RpcNodeHTTPURLErr = Err(10022)
|
||||
RpcNodeWebSocketURLErr = Err(10023)
|
||||
RpcNodeTypeErr = Err(10024)
|
||||
AdminInvalidCredentialErr = Err(10025)
|
||||
AdminUserDisabledErr = Err(10026)
|
||||
AdminUnauthorizedErr = Err(10027)
|
||||
AdminUserNotFoundErr = Err(10028)
|
||||
AdminOldPasswordErr = Err(10029)
|
||||
WalletNotFoundErr = Err(10030)
|
||||
ApiKeyNotFoundErr = Err(10031)
|
||||
RpcNodeNotFoundErr = Err(10032)
|
||||
OrderCallbackNotApplicable = Err(10033)
|
||||
OrderNotifyURLEmptyErr = Err(10034)
|
||||
OrderResendCallbackErr = Err(10035)
|
||||
NotificationConfigErr = Err(10036)
|
||||
NotificationEventsErr = Err(10037)
|
||||
ManualPaymentVerifyErr = Err(10038)
|
||||
ManualPaymentProviderErr = Err(10039)
|
||||
InitialAdminPasswordErr = Err(10040)
|
||||
NotifyURLErr = Err(10041)
|
||||
PaymentProviderCreateErr = Err(10042)
|
||||
SettingItemErr = Err(10043)
|
||||
)
|
||||
|
||||
type RspError struct {
|
||||
@@ -67,6 +117,14 @@ func (re *RspError) Render() (code int, msg string) {
|
||||
return re.Code, re.Msg
|
||||
}
|
||||
|
||||
func ResolveErrno(err error) (code int, msg string) {
|
||||
var rspErr *RspError
|
||||
if errors.As(err, &rspErr) {
|
||||
return rspErr.Render()
|
||||
}
|
||||
return SystemErr.(*RspError).Render()
|
||||
}
|
||||
|
||||
// HttpStatus maps a RspError code to the HTTP status the handler
|
||||
// should use on the wire. Small codes (< 1000) are treated as real
|
||||
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/page"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -65,13 +66,12 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
|
||||
func (r *Resp) FailJson(e echo.Context, err error) error {
|
||||
rr := new(Response)
|
||||
httpStatus := http.StatusBadRequest
|
||||
switch t := err.(type) {
|
||||
case *constant.RspError:
|
||||
rr.StatusCode, rr.Message = t.Render()
|
||||
httpStatus = t.HttpStatus()
|
||||
default:
|
||||
rr.StatusCode = http.StatusBadRequest
|
||||
rr.Message = err.Error()
|
||||
var rspErr *constant.RspError
|
||||
if errors.As(err, &rspErr) {
|
||||
rr.StatusCode, rr.Message = rspErr.Render()
|
||||
httpStatus = rspErr.HttpStatus()
|
||||
} else {
|
||||
rr.StatusCode, rr.Message = constant.ResolveErrno(err)
|
||||
}
|
||||
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
||||
return r.Json(e, httpStatus, &rr)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
nethttp "net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestFailJsonHidesPlainErrorsBehindSystemErrno(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(nethttp.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
|
||||
if err := new(Resp).FailJson(ctx, errors.New("database connection details")); err != nil {
|
||||
t.Fatalf("FailJson returned error: %v", err)
|
||||
}
|
||||
if rec.Code != nethttp.StatusBadRequest {
|
||||
t.Fatalf("http status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if got := int(body["status_code"].(float64)); got != 400 {
|
||||
t.Fatalf("status_code = %d, want 400; body=%v", got, body)
|
||||
}
|
||||
if got, _ := body["message"].(string); got != constant.Errno[400] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[400])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailJsonPreservesWrappedRspErrorErrno(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(nethttp.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
|
||||
err := fmt.Errorf("wrap: %w", constant.OrderNotExists)
|
||||
if callErr := new(Resp).FailJson(ctx, err); callErr != nil {
|
||||
t.Fatalf("FailJson returned error: %v", callErr)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if got := int(body["status_code"].(float64)); got != 10008 {
|
||||
t.Fatalf("status_code = %d, want 10008; body=%v", got, body)
|
||||
}
|
||||
if got, _ := body["message"].(string); got != constant.Errno[10008] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[10008])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dnsLookupTimeout = 3 * time.Second
|
||||
|
||||
var lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
}
|
||||
|
||||
// ValidatePublicHTTPURL verifies that raw is an HTTP(S) URL whose host does not
|
||||
// point to localhost, private networks, link-local addresses, or metadata IPs.
|
||||
func ValidatePublicHTTPURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return errors.New("url is required")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid url: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return errors.New("url scheme must be http or https")
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
if host == "" {
|
||||
return errors.New("url host is required")
|
||||
}
|
||||
if strings.Contains(host, "%") {
|
||||
return fmt.Errorf("url host %q is not allowed", host)
|
||||
}
|
||||
if strings.TrimSuffix(strings.ToLower(host), ".") == "localhost" {
|
||||
return fmt.Errorf("url host %q is not allowed", host)
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return validatePublicIP(host, ip)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := lookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve url host %q: %w", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("resolve url host %q: no addresses", host)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if err := validatePublicIP(host, addr.IP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePublicIP(host string, ip net.IP) error {
|
||||
if isUnsafeIP(ip) {
|
||||
return fmt.Errorf("url host %q resolves to disallowed address %s", host, ip.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnsafeIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
!ip.IsGlobalUnicast()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withLookupIPAddr(t *testing.T, fn func(context.Context, string) ([]net.IPAddr, error)) {
|
||||
t.Helper()
|
||||
old := lookupIPAddr
|
||||
lookupIPAddr = fn
|
||||
t.Cleanup(func() {
|
||||
lookupIPAddr = old
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatePublicHTTPURLAllowsPublicHTTPURL(t *testing.T) {
|
||||
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
if host != "example.com" {
|
||||
t.Fatalf("lookup host = %q, want example.com", host)
|
||||
}
|
||||
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
|
||||
})
|
||||
|
||||
if err := ValidatePublicHTTPURL("https://example.com/api/"); err != nil {
|
||||
t.Fatalf("ValidatePublicHTTPURL returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicHTTPURLRejectsUnsafeURLs(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"://bad",
|
||||
"ftp://example.com/file",
|
||||
"https:///missing-host",
|
||||
"http://localhost/notify",
|
||||
"http://localhost./notify",
|
||||
"http://127.0.0.1/notify",
|
||||
"http://[::1]/notify",
|
||||
"http://10.0.0.1/notify",
|
||||
"http://172.16.0.1/notify",
|
||||
"http://172.31.255.255/notify",
|
||||
"http://192.168.1.1/notify",
|
||||
"http://169.254.1.1/notify",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
}
|
||||
|
||||
for _, raw := range tests {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
if err := ValidatePublicHTTPURL(raw); err == nil {
|
||||
t.Fatalf("ValidatePublicHTTPURL(%q) returned nil, want error", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicHTTPURLRejectsDomainResolvingToPrivateIP(t *testing.T) {
|
||||
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil
|
||||
})
|
||||
|
||||
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
|
||||
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicHTTPURLRejectsUnresolvableDomain(t *testing.T) {
|
||||
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return nil, errors.New("lookup failed")
|
||||
})
|
||||
|
||||
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
|
||||
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./unauthorized-error-NzfN6z7p.js";var t=e;export{t as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./unauthorized-error-Dobf70xk.js";var t=e;export{t as component};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./forbidden-BTlzw0ab.js";var t=e;export{t as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./forbidden-C53ESqXn.js";var t=e;export{t as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./not-found-error-CrcHrNtf.js";var t=e;export{t as component};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./not-found-error-AwhoAjmy.js";var t=e;export{t as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./general-error-B3tm7Ven.js";var t=e;export{t as component};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./general-error-_bJc_DOu.js";var t=e;export{t as component};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./maintenance-error-CV0HxTkm.js";var t=e;export{t as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./maintenance-error-aweE6mOQ.js";var t=e;export{t as component};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{dp as n}from"./messages-Bbvu5aaF.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-39EveoQU.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-D2hlI1L9.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{nf as e,of as t,rf as n}from"./messages-CIE1P6Ia.js";import{o as r,s as i,t as a}from"./useRouter-CL3quaq7.js";import{t as o}from"./useStore-ai9dzYBp.js";import{f as s}from"./ClientOnly-CEDaVo0i.js";import"./redirect-dE0wk0WH.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-BiUjabdf.js";import{n as p}from"./matchContext-SBet5blV.js";import{t as m}from"./atom-DI1Bn-1s.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=t(n(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=e();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{h as e}from"./editor.api2-BpueNIaM.js";import"./toggleHighContrast-DRztGxhw.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{nf as e}from"./messages-CIE1P6Ia.js";import{m as t}from"./search-provider-DchV6D_p.js";import{n,t as r}from"./theme-switch-B7fw7FPo.js";import{t as i}from"./general-error-_bJc_DOu.js";import{t as a}from"./not-found-error-AwhoAjmy.js";import{t as o}from"./_error-DAY2QxwE.js";import{t as s}from"./unauthorized-error-NzfN6z7p.js";import{t as c}from"./forbidden-BTlzw0ab.js";import{t as l}from"./maintenance-error-CV0HxTkm.js";import{n as u,r as d,t as f}from"./header-DVwumvGY.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{dp as e}from"./messages-Bbvu5aaF.js";import{m as t}from"./search-provider-CMLTXja2.js";import{n,t as r}from"./theme-switch-g0D_A-ng.js";import{t as i}from"./general-error-B3tm7Ven.js";import{t as a}from"./not-found-error-CrcHrNtf.js";import{t as o}from"./_error-CBKE93YH.js";import{t as s}from"./unauthorized-error-Dobf70xk.js";import{t as c}from"./forbidden-C53ESqXn.js";import{t as l}from"./maintenance-error-aweE6mOQ.js";import{n as u,r as d,t as f}from"./header-Bfa1tMiw.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-BUB-_p3M.js","assets/search-provider-CMLTXja2.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-FGEIDBp-.js","assets/messages-Bbvu5aaF.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-CIKVlzfB.js","assets/button-DlGFBuNZ.js","assets/clsx-D9aGYY3V.js","assets/dist-B_ALJuOq.js","assets/dist-Cy-xiyt7.js","assets/dist--ZhT0Dzg.js","assets/dist-BkAVwTGu.js","assets/es2015-b-3Ydtpy.js","assets/dist-BaRXEuYn.js","assets/dist-DI4dxjh-.js","assets/dist-BHsKknyh.js","assets/dist-DRkG7ylN.js","assets/dist-CLy3Z8cG.js","assets/dist-BixeH3nX.js","assets/dist-BYCFrz1s.js","assets/separator-CRhLMH2U.js","assets/tooltip-CXiEyuQR.js","assets/dist-l3nGCB93.js","assets/dist-mVZdxKLu.js","assets/auth-store-BnVIXJIp.js","assets/dist-0D92g85D.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-CAasmd2P.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-DCa0Wx6n.js","assets/skeleton-CPJRjth3.js","assets/wallet-GeyQ8E8l.js","assets/sun-BwqrnykR.js","assets/shield-check-CiWq1V4z.js","assets/logo-DB0DDsg6.js","assets/input-CJ09jXcS.js","assets/font-provider-CUqnfmoa.js","assets/theme-provider-l0tGtGY9.js","assets/theme-switch-g0D_A-ng.js","assets/dropdown-menu-Ci0ZDJlU.js","assets/check-DldThLle.js","assets/header-Bfa1tMiw.js","assets/link-BwMKq7lq.js","assets/ClientOnly-39EveoQU.js","assets/forbidden-C53ESqXn.js","assets/general-error-B3tm7Ven.js","assets/maintenance-error-aweE6mOQ.js","assets/not-found-error-CrcHrNtf.js","assets/unauthorized-error-Dobf70xk.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-D7-crJIH.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-BUB-_p3M.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-B3-6TkHf.js","assets/messages-CIE1P6Ia.js","assets/search-provider-DchV6D_p.js","assets/dist-BOyWZkOA.js","assets/dist-zZsAOs5t.js","assets/confirm-dialog-DPWBpav5.js","assets/button-DPKsOYfN.js","assets/dist-CUwdI9Tv.js","assets/dist-BH4FUpAP.js","assets/dist-B9YK1cRx.js","assets/dist-BI6lFb8A.js","assets/es2015-BDFx2a2p.js","assets/dist-C-qJ9rUm.js","assets/dist-Cxq3h89h.js","assets/dist-DjrTvOz8.js","assets/dist-DPTyN5ZI.js","assets/dist-_6-FE520.js","assets/dist-DK8eACcB.js","assets/separator-Bz_yZHgG.js","assets/tooltip-BNFXRYy7.js","assets/dist-CZ7rtMKA.js","assets/dist-D6-bRo--.js","assets/auth-store-BeA1erEm.js","assets/dist-B1rrci-H.js","assets/with-selector-BK6E76Je.js","assets/cookies-C6BqVyqT.js","assets/useRouter-CL3quaq7.js","assets/useNavigate-C5YEhTcX.js","assets/useStore-ai9dzYBp.js","assets/command-DI0ZDeCT.js","assets/createLucideIcon-BXOf0WMH.js","assets/x-tS4qX1rL.js","assets/skeleton-C-PibTxy.js","assets/wallet-vbRRIceN.js","assets/sun-Fu6_fO-I.js","assets/shield-check-BugnmKO8.js","assets/logo-BRWnCy_j.js","assets/input-nQtdqwWU.js","assets/font-provider-TnXqBJPc.js","assets/theme-provider-DnDcqfvA.js","assets/theme-switch-B7fw7FPo.js","assets/dropdown-menu-FG6X2wl2.js","assets/check-DEsrGU9-.js","assets/header-DVwumvGY.js","assets/link-Do_O4b4J.js","assets/ClientOnly-CEDaVo0i.js","assets/forbidden-BTlzw0ab.js","assets/general-error-_bJc_DOu.js","assets/maintenance-error-CV0HxTkm.js","assets/not-found-error-AwhoAjmy.js","assets/unauthorized-error-NzfN6z7p.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,r as t,t as n}from"./preload-helper-qVCMknma.js";var r=t(`/_authenticated/errors/$error`)({component:e(()=>n(()=>import(`./_error-B3-6TkHf.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50])),`component`)});export{r as t};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-BuddFlN0.js","assets/messages-CIE1P6Ia.js","assets/button-DPKsOYfN.js","assets/select-enFb_iQj.js","assets/dist-CZ7rtMKA.js","assets/dist-zZsAOs5t.js","assets/dist-B9YK1cRx.js","assets/dist-BI6lFb8A.js","assets/dist-DK8eACcB.js","assets/dist-BOyWZkOA.js","assets/dist-DPTyN5ZI.js","assets/dist-Cxq3h89h.js","assets/dist-BH4FUpAP.js","assets/es2015-BDFx2a2p.js","assets/dist-_6-FE520.js","assets/dist-D6-bRo--.js","assets/createLucideIcon-BXOf0WMH.js","assets/check-DEsrGU9-.js","assets/chevron-down-DI7bcriW.js","assets/auth-store-BeA1erEm.js","assets/dist-B1rrci-H.js","assets/with-selector-BK6E76Je.js","assets/cookies-C6BqVyqT.js","assets/useNavigate-C5YEhTcX.js","assets/useRouter-CL3quaq7.js","assets/copy-to-clipboard-CvHNuxB8.js","assets/arrow-left-D3A52CCl.js","assets/radio-group-mxeTRMb0.js","assets/dist-DjrTvOz8.js","assets/dist-C-qJ9rUm.js","assets/loader-circle-CAWAMWy3.js","assets/triangle-alert-egU1X4up.js","assets/x-tS4qX1rL.js","assets/checkout-model-CxAw66kk.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,r as t,t as n}from"./preload-helper-qVCMknma.js";var r=t(`/cashier/$trade_id/`)({component:e(()=>n(()=>import(`./_trade_id-BuddFlN0.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33])),`component`)});export{r as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-D2lai0y2.js","assets/chunk-DECur_0Z.js","assets/button-DlGFBuNZ.js","assets/clsx-D9aGYY3V.js","assets/messages-Bbvu5aaF.js","assets/react-CO2uhaBc.js","assets/select-BRRg0L32.js","assets/dist-l3nGCB93.js","assets/dist-FGEIDBp-.js","assets/dist--ZhT0Dzg.js","assets/dist-BkAVwTGu.js","assets/dist-BYCFrz1s.js","assets/dist-CRowTfGU.js","assets/dist-CLy3Z8cG.js","assets/dist-DI4dxjh-.js","assets/dist-Cy-xiyt7.js","assets/es2015-b-3Ydtpy.js","assets/dist-BixeH3nX.js","assets/dist-mVZdxKLu.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-BnVIXJIp.js","assets/dist-0D92g85D.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard--eZunBzP.js","assets/arrow-left-Ck1MERx0.js","assets/radio-group-DIzxqq_2.js","assets/dist-BHsKknyh.js","assets/dist-BaRXEuYn.js","assets/dist-DRkG7ylN.js","assets/loader-circle-DRj7MiMc.js","assets/send-g-CrrC4k.js","assets/triangle-alert-BEkslGAT.js","assets/dialog-DCa0Wx6n.js","assets/dist-B_ALJuOq.js","assets/labels-DcMMAJnk.js","assets/input-CJ09jXcS.js","assets/checkout-model-BfGeSuFW.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-D7-crJIH.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-D2lai0y2.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41])),`component`)});export{r as t};
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user