mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33cc2e1cfb | |||
| f23f47a1e8 |
@@ -1,7 +1,11 @@
|
|||||||
name: docker-alpine-epusdt
|
name: docker-alpine-epusdt
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
- "master"
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=raw,value=alpine
|
type=raw,value=alpine
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
type=ref,event=branch,suffix=-alpine
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=sha,prefix=sha-,suffix=-alpine
|
type=sha,prefix=sha-,suffix=-alpine
|
||||||
|
|
||||||
@@ -48,6 +53,5 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: |
|
build-args: |
|
||||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||||
BUILD_VERSION=${{ github.ref_name }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
name: sync-www
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- dev
|
|
||||||
paths-ignore:
|
|
||||||
- "src/www/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: sync-www-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-sync:
|
|
||||||
name: Build Frontend And Sync src/www
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
env:
|
|
||||||
FRONTEND_REPOSITORY: ${{ vars.WWW_FRONTEND_REPOSITORY }}
|
|
||||||
FRONTEND_REF: ${{ vars.WWW_FRONTEND_REF }}
|
|
||||||
FRONTEND_BUILD_DIR: ${{ vars.WWW_FRONTEND_BUILD_DIR }}
|
|
||||||
FRONTEND_TOKEN: ${{ secrets.WWW_FRONTEND_TOKEN }}
|
|
||||||
WWW_TARGET_DIR: src/www
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Backend
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
ref: ${{ github.ref_name }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate Configuration
|
|
||||||
run: |
|
|
||||||
if [[ -z "$FRONTEND_REPOSITORY" ]]; then
|
|
||||||
echo "Set repository variable WWW_FRONTEND_REPOSITORY, for example owner/repo"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -z "$FRONTEND_REF" ]]; then
|
|
||||||
echo "Set repository variable WWW_FRONTEND_REF, for example main"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -z "$FRONTEND_BUILD_DIR" ]]; then
|
|
||||||
echo "Set repository variable WWW_FRONTEND_BUILD_DIR, for example dist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -z "$FRONTEND_TOKEN" ]]; then
|
|
||||||
echo "Set repository secret WWW_FRONTEND_TOKEN to read the private frontend repository"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Clone Frontend
|
|
||||||
run: |
|
|
||||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
|
||||||
frontend_path="$RUNNER_TEMP/$frontend_name"
|
|
||||||
git clone \
|
|
||||||
--depth=1 \
|
|
||||||
--branch "$FRONTEND_REF" \
|
|
||||||
"https://x-access-token:${FRONTEND_TOKEN}@github.com/${FRONTEND_REPOSITORY}.git" \
|
|
||||||
"$frontend_path"
|
|
||||||
|
|
||||||
- name: Set Up Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Build Frontend
|
|
||||||
run: |
|
|
||||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
|
||||||
cd "$RUNNER_TEMP/$frontend_name"
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Replace src/www
|
|
||||||
run: |
|
|
||||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
|
||||||
cd "$RUNNER_TEMP/$frontend_name"
|
|
||||||
if [[ ! -d "$FRONTEND_BUILD_DIR" ]]; then
|
|
||||||
echo "Frontend build output does not exist: $FRONTEND_BUILD_DIR"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
target_path="$GITHUB_WORKSPACE/$WWW_TARGET_DIR"
|
|
||||||
mkdir -p "$target_path"
|
|
||||||
rsync -a --delete "$FRONTEND_BUILD_DIR"/ "$target_path"/
|
|
||||||
|
|
||||||
- name: Commit And Push
|
|
||||||
run: |
|
|
||||||
if [[ -z "$(git status --porcelain -- "$WWW_TARGET_DIR")" ]]; then
|
|
||||||
echo "src/www is already up to date"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add "$WWW_TARGET_DIR"
|
|
||||||
git commit -m "chore(www): sync frontend build"
|
|
||||||
git pull --rebase origin "$GITHUB_REF_NAME"
|
|
||||||
git push origin "HEAD:$GITHUB_REF_NAME"
|
|
||||||
+1
-2
@@ -8,9 +8,8 @@ WORKDIR /app
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
WORKDIR /app/src
|
WORKDIR /app/src
|
||||||
ARG BUILD_VERSION=0.0.0-dev
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
RUN go build -trimpath -ldflags="-s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion=${BUILD_VERSION}" -o /app/epusdt .
|
RUN go build -o /app/epusdt .
|
||||||
|
|
||||||
FROM alpine:latest AS runner
|
FROM alpine:latest AS runner
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|||||||
@@ -51,19 +51,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安全审计
|
|
||||||
Epusdt 已完成第三方安全审计。
|
|
||||||
[查看安全审计报告](https://github.com/VectorBits/audit/blob/main/epusdt-secure-audit-report-2026-05-14.pdf)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 广泛兼容,即插即用
|
## 广泛兼容,即插即用
|
||||||
|
|
||||||
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
||||||
|
|
||||||
| 领域 | 已支持系统 |
|
| 领域 | 已支持系统 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||||
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
| **发卡系统** | [独角数卡(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) |
|
| **代理面板** | [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/) |
|
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
||||||
@@ -110,31 +104,6 @@ Epusdt
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 程序截图
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<img src="wiki/img/web2.png" alt="Epusdt 管理面板首页" height="260"><br>
|
|
||||||
<sub>管理面板首页</sub>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<img src="wiki/img/web1.png" alt="Epusdt 管理面板" height="260"><br>
|
|
||||||
<sub>管理面板</sub>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<img src="wiki/img/pay1.jpeg" alt="Epusdt 收银台" height="260"><br>
|
|
||||||
<sub>收银台</sub>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<img src="wiki/img/pay2.jpeg" alt="Epusdt 支付页面" height="260"><br>
|
|
||||||
<sub>支付页面</sub>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现原理
|
## 实现原理
|
||||||
|
|
||||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ builds:
|
|||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -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 }}
|
- -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 }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/mq"
|
"github.com/GMWalletApp/epusdt/mq"
|
||||||
"github.com/GMWalletApp/epusdt/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
@@ -22,13 +21,6 @@ func InitApp() {
|
|||||||
config.Init()
|
config.Init()
|
||||||
log.Init()
|
log.Init()
|
||||||
dao.Init()
|
dao.Init()
|
||||||
logLevel := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel)
|
|
||||||
if err := log.SetLevel(logLevel); err != nil {
|
|
||||||
color.Red.Printf("[bootstrap] apply log level setting %q err=%s; fallback=%s\n", logLevel, err, mdb.SettingDefaultSystemLogLevel)
|
|
||||||
if fallbackErr := log.SetLevel(mdb.SettingDefaultSystemLogLevel); fallbackErr != nil {
|
|
||||||
color.Red.Printf("[bootstrap] apply fallback log level err=%s\n", fallbackErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Wire settings-table lookups into the config package so
|
// Wire settings-table lookups into the config package so
|
||||||
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||||
config.SettingsGetString = func(key string) string {
|
config.SettingsGetString = func(key string) string {
|
||||||
|
|||||||
+23
-26
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"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 (
|
var (
|
||||||
HTTPAccessLog bool
|
HTTPAccessLog bool
|
||||||
SQLDebug bool
|
SQLDebug bool
|
||||||
@@ -346,29 +353,19 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
if coin == base {
|
if coin == base {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
|
if coin == "usdt" {
|
||||||
return forcedRate
|
switch base {
|
||||||
}
|
case "usd":
|
||||||
if coin == "usdt" && base == "usd" {
|
return 1
|
||||||
return 1
|
case "cny":
|
||||||
}
|
usdtRate := GetUsdtRate()
|
||||||
if rate := getRateForCoinFromAPI(coin, base); rate > 0 {
|
if usdtRate > 0 {
|
||||||
return rate
|
return 1 / usdtRate
|
||||||
}
|
}
|
||||||
if coin == "ton" {
|
return 0
|
||||||
return getRateForCoinFromAPI("toncoin", base)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return getRateForCoinFromAPI(coin, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRateForCoinFromAPI(coin string, base string) float64 {
|
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||||
@@ -404,17 +401,17 @@ func getRateForCoinFromAPI(coin string, base string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUsdtRate() float64 {
|
func GetUsdtRate() float64 {
|
||||||
// rate.forced_rate_list stores token amount per one base currency unit.
|
// Only the admin setting can force the USDT/CNY rate. When the
|
||||||
// GetUsdtRate returns the inverse display value: CNY per USDT.
|
// setting is unset, zero, or negative, fall back to the rate API.
|
||||||
if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
|
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||||
return 1 / forcedRate
|
return forced
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||||
if apiRate > 0 {
|
if apiRate > 0 {
|
||||||
return 1 / apiRate
|
return 1 / apiRate
|
||||||
}
|
}
|
||||||
log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
|
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
|
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
t.Cleanup(viper.Reset)
|
t.Cleanup(viper.Reset)
|
||||||
t.Setenv("API_RATE_URL", "")
|
t.Setenv("API_RATE_URL", "")
|
||||||
@@ -249,19 +249,16 @@ func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
installSettingsGetter(t, map[string]string{
|
installSettingsGetter(t, map[string]string{
|
||||||
"rate.forced_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
|
"rate.forced_usdt_rate": "7.25",
|
||||||
"rate.api_url": "https://rate.example.test",
|
"rate.api_url": "https://rate.example.test",
|
||||||
})
|
})
|
||||||
|
|
||||||
got := GetUsdtRate()
|
got := GetUsdtRate()
|
||||||
if math.Abs(got-7.25) > 1e-9 {
|
if got != 7.25 {
|
||||||
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
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 {
|
if apiCalled {
|
||||||
t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
|
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +281,7 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
installSettingsGetter(t, map[string]string{
|
installSettingsGetter(t, map[string]string{
|
||||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
"rate.forced_usdt_rate": "-1",
|
||||||
"rate.api_url": "https://rate.example.test",
|
"rate.api_url": "https://rate.example.test",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -300,64 +297,6 @@ 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 TestGetRateForCoinUsesToncoinAPIFallbackForTon(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":{"toncoin":1.82}}`)),
|
|
||||||
Request: r,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
installSettingsGetter(t, map[string]string{
|
|
||||||
"rate.forced_rate_list": `{"cny":{"ton":0}}`,
|
|
||||||
"rate.api_url": "https://rate.example.test",
|
|
||||||
})
|
|
||||||
|
|
||||||
rate := GetRateForCoin("ton", "cny")
|
|
||||||
if math.Abs(rate-1.82) > 1e-9 {
|
|
||||||
t.Fatalf("GetRateForCoin(ton, cny) = %v, want 1.82", rate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
t.Cleanup(viper.Reset)
|
t.Cleanup(viper.Reset)
|
||||||
@@ -374,7 +313,7 @@ func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing
|
|||||||
})
|
})
|
||||||
|
|
||||||
installSettingsGetter(t, map[string]string{
|
installSettingsGetter(t, map[string]string{
|
||||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
"rate.forced_usdt_rate": "0",
|
||||||
"rate.api_url": "https://rate.example.test",
|
"rate.api_url": "https://rate.example.test",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -404,7 +343,7 @@ func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
installSettingsGetter(t, map[string]string{
|
installSettingsGetter(t, map[string]string{
|
||||||
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
"rate.forced_usdt_rate": "0",
|
||||||
"rate.api_url": "https://rate.example.test",
|
"rate.api_url": "https://rate.example.test",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ func settingsRateApiUrl() string {
|
|||||||
return SettingsGetString("rate.api_url")
|
return SettingsGetString("rate.api_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsForcedRateList() string {
|
func settingsForcedUsdtRate() float64 {
|
||||||
if SettingsGetString == nil {
|
if SettingsGetString == nil {
|
||||||
return ""
|
return 0
|
||||||
}
|
}
|
||||||
return SettingsGetString("rate.forced_rate_list")
|
raw := SettingsGetString("rate.forced_usdt_rate")
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, err := parseFloat(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return f
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"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 {
|
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||||
req := new(CreateApiKeyRequest)
|
req := new(CreateApiKeyRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -150,11 +150,11 @@ func isUniqueViolation(err error) bool {
|
|||||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(UpdateApiKeyRequest)
|
req := new(UpdateApiKeyRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -187,11 +187,11 @@ func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(ChangeApiKeyStatusRequest)
|
req := new(ChangeApiKeyStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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 {
|
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
secret := randomHex(32)
|
secret := randomHex(32)
|
||||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
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 {
|
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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 {
|
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
row, err := data.GetApiKeyByID(id)
|
row, err := data.GetApiKeyByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
return c.FailJson(ctx, errors.New("api key not found"))
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, map[string]interface{}{
|
return c.SucJson(ctx, map[string]interface{}{
|
||||||
"call_count": row.CallCount,
|
"call_count": row.CallCount,
|
||||||
@@ -291,14 +291,14 @@ func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
row, err := data.GetApiKeyByID(id)
|
row, err := data.GetApiKeyByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
return c.FailJson(ctx, errors.New("api key not found"))
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
@@ -54,7 +53,7 @@ type InitialPasswordResponse struct {
|
|||||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||||
req := new(LoginRequest)
|
req := new(LoginRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -64,10 +63,10 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||||
return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
|
return c.FailJson(ctx, errors.New("invalid username or password"))
|
||||||
}
|
}
|
||||||
if user.Status != mdb.AdminUserStatusEnable {
|
if user.Status != mdb.AdminUserStatusEnable {
|
||||||
return c.FailJson(ctx, constant.AdminUserDisabledErr)
|
return c.FailJson(ctx, errors.New("user disabled"))
|
||||||
}
|
}
|
||||||
token, err := appjwt.Sign(user.ID, user.Username)
|
token, err := appjwt.Sign(user.ID, user.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,9 +94,6 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||||
password, err := data.ConsumeInitialAdminPassword()
|
password, err := data.ConsumeInitialAdminPassword()
|
||||||
if err != nil {
|
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.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, InitialPasswordResponse{
|
return c.SucJson(ctx, InitialPasswordResponse{
|
||||||
@@ -148,14 +144,14 @@ func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||||
uid := currentAdminUserID(ctx)
|
uid := currentAdminUserID(ctx)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||||
}
|
}
|
||||||
user, err := data.GetAdminUserByID(uid)
|
user, err := data.GetAdminUserByID(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 {
|
if user.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.AdminUserNotFoundErr)
|
return c.FailJson(ctx, errors.New("user not found"))
|
||||||
}
|
}
|
||||||
// Warn the frontend when the operator hasn't changed the seeded
|
// Warn the frontend when the operator hasn't changed the seeded
|
||||||
// initial password.
|
// initial password.
|
||||||
@@ -180,11 +176,11 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||||
uid := currentAdminUserID(ctx)
|
uid := currentAdminUserID(ctx)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||||
}
|
}
|
||||||
req := new(ChangePasswordRequest)
|
req := new(ChangePasswordRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -194,7 +190,7 @@ func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||||
return c.FailJson(ctx, constant.AdminOldPasswordErr)
|
return c.FailJson(ctx, errors.New("old password incorrect"))
|
||||||
}
|
}
|
||||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +13,8 @@ type UpdateChainRequest struct {
|
|||||||
DisplayName *string `json:"display_name" example:"Tron"`
|
DisplayName *string `json:"display_name" example:"Tron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChains returns the chains table for all seeded supported networks.
|
// ListChains returns the chains table (tron/ethereum/solana seeded; new
|
||||||
|
// networks like bsc/polygon inserted manually once supported).
|
||||||
// @Summary List chains
|
// @Summary List chains
|
||||||
// @Description Returns all supported blockchain networks
|
// @Description Returns all supported blockchain networks
|
||||||
// @Tags Admin Chains
|
// @Tags Admin Chains
|
||||||
@@ -40,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
|||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)"
|
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
|
||||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
@@ -49,7 +49,7 @@ func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
|||||||
network := ctx.Param("network")
|
network := ctx.Param("network")
|
||||||
req := new(UpdateChainRequest)
|
req := new(UpdateChainRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,7 +65,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||||
req := new(CreateChainTokenRequest)
|
req := new(CreateChainTokenRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -108,11 +107,11 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(UpdateChainTokenRequest)
|
req := new(UpdateChainTokenRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.ContractAddress != nil {
|
if req.ContractAddress != nil {
|
||||||
@@ -148,11 +147,11 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(ChangeChainTokenStatusRequest)
|
req := new(ChangeChainTokenStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -173,7 +172,7 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,49 +29,6 @@ type OrderStatsResponse struct {
|
|||||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RpcStatsDashboardResponse is the RPC runtime stats payload.
|
|
||||||
type RpcStatsDashboardResponse struct {
|
|
||||||
GeneratedAt string `json:"generated_at" example:"2026-06-09T12:00:00+08:00"`
|
|
||||||
Summary RpcStatsSummaryResponse `json:"summary"`
|
|
||||||
Chains []RpcStatsChainResponse `json:"chains"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RpcStatsSummaryResponse is the overall RPC runtime summary.
|
|
||||||
type RpcStatsSummaryResponse struct {
|
|
||||||
TotalChains int `json:"total_chains" example:"6"`
|
|
||||||
EnabledChains int `json:"enabled_chains" example:"5"`
|
|
||||||
ActiveChains int `json:"active_chains" example:"4"`
|
|
||||||
OkChains int `json:"ok_chains" example:"3"`
|
|
||||||
WarningChains int `json:"warning_chains" example:"1"`
|
|
||||||
DownChains int `json:"down_chains" example:"0"`
|
|
||||||
UnknownChains int `json:"unknown_chains" example:"1"`
|
|
||||||
DisabledChains int `json:"disabled_chains" example:"1"`
|
|
||||||
SuccessCount int64 `json:"success_count" example:"123"`
|
|
||||||
FailureCount int64 `json:"failure_count" example:"2"`
|
|
||||||
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RpcStatsChainResponse is one chain's in-process RPC runtime stats.
|
|
||||||
type RpcStatsChainResponse struct {
|
|
||||||
Network string `json:"network" example:"tron"`
|
|
||||||
DisplayName string `json:"display_name" example:"TRON"`
|
|
||||||
Enabled bool `json:"enabled" example:"true"`
|
|
||||||
HasData bool `json:"has_data" example:"true"`
|
|
||||||
Status string `json:"status" example:"ok" enums:"ok,warning,down,unknown,disabled"`
|
|
||||||
SuccessCount int64 `json:"success_count" example:"123"`
|
|
||||||
FailureCount int64 `json:"failure_count" example:"2"`
|
|
||||||
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
|
||||||
LatestBlockHeight int64 `json:"latest_block_height" example:"64532100"`
|
|
||||||
LastSyncAt string `json:"last_sync_at" example:"2026-06-09T12:00:00+08:00"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcStatsStreamEnvelope struct {
|
|
||||||
StatusCode int `json:"status_code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data RpcStatsDashboardResponse `json:"data"`
|
|
||||||
RequestID string `json:"request_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RpcHealthCheckResponse is the response for a health check probe.
|
// RpcHealthCheckResponse is the response for a health check probe.
|
||||||
type RpcHealthCheckResponse struct {
|
type RpcHealthCheckResponse struct {
|
||||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||||
@@ -95,18 +49,16 @@ type RotateSecretResponse struct {
|
|||||||
|
|
||||||
// BatchImportResult is one row in the batch import response.
|
// BatchImportResult is one row in the batch import response.
|
||||||
type BatchImportResult struct {
|
type BatchImportResult struct {
|
||||||
Address string `json:"address" example:"TTestTronAddress001"`
|
Address string `json:"address" example:"TTestTronAddress001"`
|
||||||
OK bool `json:"ok" example:"true"`
|
OK bool `json:"ok" example:"true"`
|
||||||
ErrorCode int `json:"error_code,omitempty" example:"10001"`
|
Error string `json:"error,omitempty" example:""`
|
||||||
Error string `json:"error,omitempty" example:"wallet address already exists"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsUpsertResult is one row in the settings upsert response.
|
// SettingsUpsertResult is one row in the settings upsert response.
|
||||||
type SettingsUpsertResult struct {
|
type SettingsUpsertResult struct {
|
||||||
Key string `json:"key" example:"rate.api_url"`
|
Key string `json:"key" example:"rate.api_url"`
|
||||||
OK bool `json:"ok" example:"true"`
|
OK bool `json:"ok" example:"true"`
|
||||||
ErrorCode int `json:"error_code,omitempty" example:"10043"`
|
Error string `json:"error,omitempty" example:""`
|
||||||
Error string `json:"error,omitempty" example:"invalid setting item"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overview returns the top-card numbers for the dashboard landing page.
|
// Overview returns the top-card numbers for the dashboard landing page.
|
||||||
@@ -276,174 +228,6 @@ func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RpcStats returns process-local RPC runtime counters grouped by chain.
|
|
||||||
// Values reset on process restart and are not persisted to the database.
|
|
||||||
// @Summary RPC runtime statistics
|
|
||||||
// @Description Streams in-process RPC success/failure counts, success rate, latest block height and last sync time by chain over one authenticated SSE connection.
|
|
||||||
// @Tags Admin Dashboard
|
|
||||||
// @Security AdminJWT
|
|
||||||
// @Produce text/event-stream
|
|
||||||
// @Success 200 {object} response.ApiResponse{data=admin.RpcStatsDashboardResponse}
|
|
||||||
// @Failure 400 {object} response.ApiResponse
|
|
||||||
// @Router /admin/api/v1/dashboard/rpc-stats [get]
|
|
||||||
func (c *BaseAdminController) RpcStats(ctx echo.Context) error {
|
|
||||||
return c.streamRpcStats(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRpcStatsResponse() (RpcStatsDashboardResponse, error) {
|
|
||||||
chains, err := data.ListChains()
|
|
||||||
if err != nil {
|
|
||||||
return RpcStatsDashboardResponse{}, err
|
|
||||||
}
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
now := time.Now()
|
|
||||||
resp := RpcStatsDashboardResponse{
|
|
||||||
GeneratedAt: now.Format(time.RFC3339),
|
|
||||||
Chains: make([]RpcStatsChainResponse, 0, len(chains)),
|
|
||||||
}
|
|
||||||
for _, chain := range chains {
|
|
||||||
network := strings.ToLower(strings.TrimSpace(chain.Network))
|
|
||||||
stat := stats[network]
|
|
||||||
successRate := rpcStatsSuccessRate(stat.SuccessCount, stat.FailureCount)
|
|
||||||
hasData := stat.SuccessCount+stat.FailureCount > 0
|
|
||||||
status := rpcStatsStatus(chain.Enabled, stat.SuccessCount, stat.FailureCount)
|
|
||||||
lastSyncAt := ""
|
|
||||||
if !stat.LastSyncAt.IsZero() {
|
|
||||||
lastSyncAt = stat.LastSyncAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.Summary.TotalChains++
|
|
||||||
if chain.Enabled {
|
|
||||||
resp.Summary.EnabledChains++
|
|
||||||
}
|
|
||||||
if hasData {
|
|
||||||
resp.Summary.ActiveChains++
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case "ok":
|
|
||||||
resp.Summary.OkChains++
|
|
||||||
case "warning":
|
|
||||||
resp.Summary.WarningChains++
|
|
||||||
case "down":
|
|
||||||
resp.Summary.DownChains++
|
|
||||||
case "disabled":
|
|
||||||
resp.Summary.DisabledChains++
|
|
||||||
default:
|
|
||||||
resp.Summary.UnknownChains++
|
|
||||||
}
|
|
||||||
resp.Summary.SuccessCount += stat.SuccessCount
|
|
||||||
resp.Summary.FailureCount += stat.FailureCount
|
|
||||||
|
|
||||||
resp.Chains = append(resp.Chains, RpcStatsChainResponse{
|
|
||||||
Network: chain.Network,
|
|
||||||
DisplayName: rpcStatsDisplayName(chain.Network, chain.DisplayName),
|
|
||||||
Enabled: chain.Enabled,
|
|
||||||
HasData: hasData,
|
|
||||||
Status: status,
|
|
||||||
SuccessCount: stat.SuccessCount,
|
|
||||||
FailureCount: stat.FailureCount,
|
|
||||||
SuccessRate: successRate,
|
|
||||||
LatestBlockHeight: stat.LatestBlockHeight,
|
|
||||||
LastSyncAt: lastSyncAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
resp.Summary.SuccessRate = rpcStatsSuccessRate(resp.Summary.SuccessCount, resp.Summary.FailureCount)
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *BaseAdminController) streamRpcStats(ctx echo.Context) error {
|
|
||||||
initial, err := buildRpcStatsResponse()
|
|
||||||
if err != nil {
|
|
||||||
return c.FailJson(ctx, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := ctx.Response()
|
|
||||||
resp.Header().Set(echo.HeaderContentType, "text/event-stream; charset=utf-8")
|
|
||||||
resp.Header().Set("Cache-Control", "no-cache")
|
|
||||||
resp.Header().Set("Connection", "keep-alive")
|
|
||||||
resp.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
|
|
||||||
flusher, ok := resp.Writer.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.WriteHeader(http.StatusOK)
|
|
||||||
if _, err := fmt.Fprint(resp.Writer, "retry: 5000\n\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeRpcStatsSSE(ctx, flusher, initial); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Request().Context().Done():
|
|
||||||
return nil
|
|
||||||
case <-ticker.C:
|
|
||||||
payload, err := buildRpcStatsResponse()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeRpcStatsSSE(ctx, flusher, payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRpcStatsSSE(ctx echo.Context, flusher http.Flusher, payload RpcStatsDashboardResponse) error {
|
|
||||||
envelope := rpcStatsStreamEnvelope{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Message: "success",
|
|
||||||
Data: payload,
|
|
||||||
RequestID: ctx.Request().Header.Get(echo.HeaderXRequestID),
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(envelope)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := fmt.Fprintf(ctx.Response().Writer, "event: rpc_stats\ndata: %s\n\n", b); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcStatsDisplayName(network string, displayName string) string {
|
|
||||||
if name := strings.TrimSpace(displayName); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(network)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcStatsSuccessRate(successCount, failureCount int64) float64 {
|
|
||||||
total := successCount + failureCount
|
|
||||||
if total <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(successCount) / float64(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcStatsStatus(enabled bool, successCount, failureCount int64) string {
|
|
||||||
if !enabled {
|
|
||||||
return "disabled"
|
|
||||||
}
|
|
||||||
if successCount+failureCount == 0 {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
if successCount == 0 {
|
|
||||||
return "down"
|
|
||||||
}
|
|
||||||
if failureCount > 0 {
|
|
||||||
return "warning"
|
|
||||||
}
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentOrders returns the last N orders for the dashboard list card.
|
// RecentOrders returns the last N orders for the dashboard list card.
|
||||||
// @Summary Recent orders
|
// @Summary Recent orders
|
||||||
// @Description Returns the last N orders for the dashboard
|
// @Description Returns the last N orders for the dashboard
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"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 {
|
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||||
req := new(CreateNotificationChannelRequest)
|
req := new(CreateNotificationChannelRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
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 {
|
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +130,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
|||||||
|
|
||||||
req := new(UpdateNotificationChannelRequest)
|
req := new(UpdateNotificationChannelRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -175,7 +175,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
|||||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,7 +185,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
|||||||
|
|
||||||
req := new(ChangeChannelStatusRequest)
|
req := new(ChangeChannelStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -209,7 +209,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
|||||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
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.
|
// + chat_id; webhook/email kept loose until those senders exist.
|
||||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return constant.NotificationConfigErr
|
return errors.New("config required")
|
||||||
}
|
}
|
||||||
if channelType == mdb.NotificationTypeTelegram {
|
if channelType == mdb.NotificationTypeTelegram {
|
||||||
configJSON, _ := json.Marshal(cfg)
|
configJSON, _ := json.Marshal(cfg)
|
||||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||||
return constant.NotificationConfigErr
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -260,10 +260,10 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
case "1", "true", "on", "yes":
|
case "1", "true", "on", "yes":
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, constant.NotificationEventsErr
|
return false, errors.New("invalid boolean string")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return false, constant.NotificationEventsErr
|
return false, errors.New("invalid event value type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
for _, v := range t {
|
for _, v := range t {
|
||||||
s, ok := v.(string)
|
s, ok := v.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, constant.NotificationEventsErr
|
return nil, errors.New("events array must contain strings")
|
||||||
}
|
}
|
||||||
s = trimKey(s)
|
s = trimKey(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -309,6 +309,6 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
default:
|
default:
|
||||||
return nil, constant.NotificationEventsErr
|
return nil, errors.New("events must be object or string array")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -12,11 +13,14 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
"github.com/labstack/echo/v4"
|
"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.
|
// OrderListResponse wraps the paginated order list.
|
||||||
type OrderListResponse struct {
|
type OrderListResponse struct {
|
||||||
List []mdb.Orders `json:"list"`
|
List []mdb.Orders `json:"list"`
|
||||||
@@ -88,7 +92,7 @@ func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.OrderNotExists)
|
return c.FailJson(ctx, errors.New("order not found"))
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, order)
|
return c.SucJson(ctx, order)
|
||||||
}
|
}
|
||||||
@@ -110,28 +114,20 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.OrderNotExists)
|
return c.FailJson(ctx, errors.New("order not found"))
|
||||||
}
|
}
|
||||||
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
|
if order.Status != mdb.StatusWaitPay {
|
||||||
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||||
}
|
}
|
||||||
ok, err := data.CloseOrderManually(tradeID)
|
ok, err := data.CloseOrderManually(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, constant.OrderStatusConflict)
|
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
||||||
}
|
|
||||||
// Release only concrete on-chain locks. Placeholder orders and provider-only
|
|
||||||
// orders do not reserve a local wallet amount.
|
|
||||||
if order.Status == mdb.StatusWaitPay &&
|
|
||||||
(strings.TrimSpace(order.PayProvider) == "" || order.PayProvider == mdb.PaymentProviderOnChain) &&
|
|
||||||
strings.TrimSpace(order.Network) != "" &&
|
|
||||||
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
|
||||||
strings.TrimSpace(order.Token) != "" &&
|
|
||||||
order.ActualAmount > 0 {
|
|
||||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
|
||||||
}
|
}
|
||||||
|
// Release the transaction lock so the amount slot becomes reusable.
|
||||||
|
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,35 +136,46 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
// callback + notification fire normally.
|
// callback + notification fire normally.
|
||||||
// @Summary Mark order paid
|
// @Summary Mark order paid
|
||||||
// @Description Manually mark a waiting order as paid (operator补单)
|
// @Description Manually mark a waiting order as paid (operator补单)
|
||||||
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
|
||||||
// @Tags Admin Orders
|
// @Tags Admin Orders
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Param request body request.ManualPaymentRequest true "Block transaction ID"
|
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
tradeID := ctx.Param("trade_id")
|
||||||
adminUserID := currentAdminUserID(ctx)
|
req := new(MarkPaidRequest)
|
||||||
req := new(request.ManualPaymentRequest)
|
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
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, err)
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
|
||||||
}
|
}
|
||||||
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
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)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
|
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,
|
||||||
|
})
|
||||||
if err != nil {
|
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)
|
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)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,38 +193,13 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
|||||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
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)
|
ok, err := data.ReopenOrderCallback(tradeID)
|
||||||
if err != nil {
|
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)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
err = constant.OrderResendCallbackErr
|
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
||||||
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)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +380,7 @@ func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.OrderNotExists)
|
return c.FailJson(ctx, errors.New("order not found"))
|
||||||
}
|
}
|
||||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||||
type CreateRpcNodeRequest struct {
|
type CreateRpcNodeRequest struct {
|
||||||
Network string `json:"network" validate:"required" example:"tron"`
|
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长连接 lite=TON liteserver配置
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||||
Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" example:"http"`
|
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
||||||
Weight int `json:"weight" example:"1"`
|
Weight int `json:"weight" example:"1"`
|
||||||
ApiKey string `json:"api_key" example:""`
|
ApiKey string `json:"api_key" example:""`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
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.
|
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||||
@@ -31,7 +28,6 @@ type UpdateRpcNodeRequest struct {
|
|||||||
Weight *int `json:"weight" example:"1"`
|
Weight *int `json:"weight" example:"1"`
|
||||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
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.
|
// ListRpcNodes returns rows optionally filtered by network.
|
||||||
@@ -68,7 +64,7 @@ func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||||
req := new(CreateRpcNodeRequest)
|
req := new(CreateRpcNodeRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -81,23 +77,13 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
enabled = *req.Enabled
|
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{
|
row := &mdb.RpcNode{
|
||||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||||
Url: nodeURL,
|
Url: strings.TrimSpace(req.Url),
|
||||||
Type: nodeType,
|
Type: strings.ToLower(strings.TrimSpace(req.Type)),
|
||||||
Weight: weight,
|
Weight: weight,
|
||||||
ApiKey: req.ApiKey,
|
ApiKey: req.ApiKey,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
Purpose: purpose,
|
|
||||||
Status: mdb.RpcNodeStatusUnknown,
|
Status: mdb.RpcNodeStatusUnknown,
|
||||||
LastLatencyMs: -1,
|
LastLatencyMs: -1,
|
||||||
}
|
}
|
||||||
@@ -107,7 +93,7 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
return c.SucJson(ctx, row)
|
return c.SucJson(ctx, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRpcNode patches url/weight/api_key/enabled/purpose.
|
// UpdateRpcNode patches url/weight/api_key/enabled.
|
||||||
// @Summary Update RPC node
|
// @Summary Update RPC node
|
||||||
// @Description Patch RPC node fields
|
// @Description Patch RPC node fields
|
||||||
// @Tags Admin RPC Nodes
|
// @Tags Admin RPC Nodes
|
||||||
@@ -122,26 +108,15 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(UpdateRpcNodeRequest)
|
req := new(UpdateRpcNodeRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Url != nil {
|
if req.Url != nil {
|
||||||
row, err := data.GetRpcNodeByID(id)
|
fields["url"] = *req.Url
|
||||||
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 {
|
if req.Weight != nil {
|
||||||
fields["weight"] = *req.Weight
|
fields["weight"] = *req.Weight
|
||||||
@@ -152,13 +127,6 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
fields["enabled"] = *req.Enabled
|
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 {
|
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +146,7 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -187,8 +155,9 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||||
// For HTTP/WS/lite endpoints this is a TCP-level check against the configured
|
// For HTTP endpoints this is a GET to the URL with a short timeout; for
|
||||||
// URL host. TON lite rows usually point to a global.config.json HTTPS URL.
|
// WS we just attempt a TCP-level check via the same HTTP client (the
|
||||||
|
// handshake URL resolves the host identically).
|
||||||
// @Summary Health check RPC node
|
// @Summary Health check RPC node
|
||||||
// @Description Perform an on-demand health probe on an RPC node
|
// @Description Perform an on-demand health probe on an RPC node
|
||||||
// @Tags Admin RPC Nodes
|
// @Tags Admin RPC Nodes
|
||||||
@@ -201,14 +170,14 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
row, err := data.GetRpcNodeByID(id)
|
row, err := data.GetRpcNodeByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
return c.FailJson(ctx, errors.New("node not found"))
|
||||||
}
|
}
|
||||||
status, latency := task.ProbeNode(row.Url)
|
status, latency := task.ProbeNode(row.Url)
|
||||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||||
@@ -219,45 +188,3 @@ func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
|||||||
"last_latency_ms": latency,
|
"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
|
|
||||||
case mdb.RpcNodeTypeLite:
|
|
||||||
if scheme == "http" || scheme == "https" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return constant.RpcNodeHTTPURLErr
|
|
||||||
default:
|
|
||||||
return constant.RpcNodeTypeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,9 +9,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
appLog "github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/security"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,15 +16,15 @@ import (
|
|||||||
// Supported groups and keys:
|
// Supported groups and keys:
|
||||||
//
|
//
|
||||||
// - group=rate:
|
// - group=rate:
|
||||||
// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase
|
// 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 no positive forced rate exists
|
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
|
||||||
// rate.adjust_percent (float) — rate adjustment percentage
|
// rate.adjust_percent (float) — rate adjustment percentage
|
||||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||||
//
|
//
|
||||||
// - group=epay:
|
// - group=epay:
|
||||||
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
|
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
|
||||||
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
|
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
|
||||||
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
|
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
|
||||||
//
|
//
|
||||||
// - group=okpay:
|
// - group=okpay:
|
||||||
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
||||||
@@ -55,12 +51,11 @@ import (
|
|||||||
// - group=system:
|
// - group=system:
|
||||||
// system.order_expiration_time (int) — order expiry in minutes
|
// system.order_expiration_time (int) — order expiry in minutes
|
||||||
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
||||||
// system.log_level (string) — runtime log level: debug, info, warn, error (default error)
|
|
||||||
type SettingUpsertItem struct {
|
type SettingUpsertItem struct {
|
||||||
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||||
Key string `json:"key" example:"epay.default_network"`
|
Key string `json:"key" example:"epay.default_network"`
|
||||||
Value interface{} `json:"value"`
|
Value string `json:"value" example:"tron"`
|
||||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||||
@@ -93,13 +88,13 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
// independently so a malformed row in the middle doesn't drop earlier
|
// independently so a malformed row in the middle doesn't drop earlier
|
||||||
// ones. Errors are returned per-key so the UI can surface them.
|
// ones. Errors are returned per-key so the UI can surface them.
|
||||||
// @Summary Upsert settings
|
// @Summary Upsert settings
|
||||||
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
// @Description Batch insert/update settings. Returns per-key status.
|
||||||
// @Description Supported groups: brand, rate, system, epay, okpay.
|
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||||
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders).
|
// @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 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_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||||
// @Description brand group keys: brand.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 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), system.log_level (debug|info|warn|error, default error).
|
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2).
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -111,60 +106,35 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||||
req := new(SettingsUpsertRequest)
|
req := new(SettingsUpsertRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
type result struct {
|
type result struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
ErrorCode int `json:"error_code,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
out := make([]result, 0, len(req.Items))
|
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 {
|
for _, item := range req.Items {
|
||||||
key := strings.TrimSpace(item.Key)
|
key := strings.TrimSpace(item.Key)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
out = append(out, errorResult(item.Key, constant.SettingItemErr))
|
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, err := normalizeSettingValue(item.Value)
|
if err := validateSettingItem(item.Group, key, item.Value); err != nil {
|
||||||
if err != nil {
|
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
if key == mdb.SettingKeyAmountPrecision {
|
if key == mdb.SettingKeyAmountPrecision {
|
||||||
item.Group = mdb.SettingGroupSystem
|
item.Group = mdb.SettingGroupSystem
|
||||||
item.Type = mdb.SettingTypeInt
|
item.Type = mdb.SettingTypeInt
|
||||||
}
|
}
|
||||||
if key == mdb.SettingKeyRateForcedRateList {
|
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
|
||||||
item.Group = mdb.SettingGroupRate
|
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||||
item.Type = mdb.SettingTypeJSON
|
|
||||||
}
|
|
||||||
if key == mdb.SettingKeySystemLogLevel {
|
|
||||||
item.Group = mdb.SettingGroupSystem
|
|
||||||
item.Type = mdb.SettingTypeString
|
|
||||||
}
|
|
||||||
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
|
||||||
out = append(out, errorResult(key, err))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if key == mdb.SettingKeySystemLogLevel {
|
|
||||||
if err := appLog.SetLevel(value); err != nil {
|
|
||||||
out = append(out, errorResult(key, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, result{Key: key, OK: true})
|
out = append(out, result{Key: key, OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,113 +159,27 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
|||||||
return c.SucJson(ctx, out)
|
return c.SucJson(ctx, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSettingValue(value interface{}) (string, error) {
|
func validateSettingItem(group, key, value 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 {
|
switch key {
|
||||||
case mdb.SettingKeyAmountPrecision:
|
case mdb.SettingKeyAmountPrecision:
|
||||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
return fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||||
}
|
}
|
||||||
precision, err := strconv.Atoi(strings.TrimSpace(value))
|
precision, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return value, fmt.Errorf("%s must be an integer", key)
|
return fmt.Errorf("%s must be an integer", key)
|
||||||
}
|
}
|
||||||
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
|
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
|
||||||
return value, fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
|
return 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)
|
|
||||||
}
|
|
||||||
case mdb.SettingKeySystemLogLevel:
|
|
||||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
|
||||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
|
||||||
}
|
|
||||||
normalized, err := appLog.NormalizeLevel(value)
|
|
||||||
if err != nil {
|
|
||||||
return value, err
|
|
||||||
}
|
|
||||||
return normalized, 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
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
|
// DeleteSetting removes one row. The next read of that key will fall
|
||||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||||
// @Summary Delete setting
|
// @Summary Delete setting
|
||||||
// @Description Remove a setting by key (falls back to default). Deleting system.log_level immediately resets runtime logging to error.
|
// @Description Remove a setting by key (falls back to default)
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -311,10 +195,9 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
|||||||
if err := data.DeleteSetting(key); err != nil {
|
if err := data.DeleteSetting(key); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if key == mdb.SettingKeySystemLogLevel {
|
|
||||||
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
|
||||||
return c.FailJson(ctx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.SucJson(ctx, nil)
|
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
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"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 {
|
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||||
req := new(AdminAddWalletRequest)
|
req := new(AdminAddWalletRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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 {
|
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
wallet, err := data.GetWalletAddressById(id)
|
wallet, err := data.GetWalletAddressById(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if wallet.ID == 0 {
|
if wallet.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.WalletNotFoundErr)
|
return c.FailJson(ctx, errors.New("wallet not found"))
|
||||||
}
|
}
|
||||||
counts, _ := data.CountOrdersByAddress()
|
counts, _ := data.CountOrdersByAddress()
|
||||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
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 {
|
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(AdminUpdateWalletRequest)
|
req := new(AdminUpdateWalletRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Remark != nil {
|
if req.Remark != nil {
|
||||||
@@ -202,11 +202,11 @@ func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
req := new(AdminChangeWalletStatusRequest)
|
req := new(AdminChangeWalletStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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 {
|
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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
|
// them all as observation wallets. Per-row failures are collected and
|
||||||
// returned so the UI can surface which addresses already existed.
|
// returned so the UI can surface which addresses already existed.
|
||||||
// @Summary Batch import wallets
|
// @Summary Batch import wallets
|
||||||
// @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
|
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
||||||
// @Tags Admin Wallets
|
// @Tags Admin Wallets
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -254,23 +254,21 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||||
req := new(AdminBatchImportRequest)
|
req := new(AdminBatchImportRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
type result struct {
|
type result struct {
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
ErrorCode int `json:"error_code,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
out := make([]result, 0, len(req.Addresses))
|
out := make([]result, 0, len(req.Addresses))
|
||||||
for _, addr := range req.Addresses {
|
for _, addr := range req.Addresses {
|
||||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code, msg := constant.ResolveErrno(err)
|
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
||||||
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"errors"
|
||||||
"github.com/GMWalletApp/epusdt/util/http"
|
"github.com/GMWalletApp/epusdt/util/http"
|
||||||
"github.com/gookit/validate"
|
"github.com/gookit/validate"
|
||||||
"github.com/gookit/validate/locales/zhcn"
|
"github.com/gookit/validate/locales/zhcn"
|
||||||
@@ -46,6 +46,6 @@ func (c *BaseController) ValidateStruct(ctx echo.Context, i interface{}, scene .
|
|||||||
if v.Validate() {
|
if v.Validate() {
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
return constant.ParamsMarshalErr
|
return errors.New(v.Errors.One())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// CreateTransaction 创建交易
|
// CreateTransaction 创建交易
|
||||||
// @Summary Create transaction
|
// @Summary Create transaction
|
||||||
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||||
// @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid.
|
|
||||||
// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature.
|
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
@@ -34,16 +32,16 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||||
// @Param order_id formData string false "Merchant order ID"
|
// @Param order_id formData string false "Merchant order ID"
|
||||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||||
// @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
|
// @Param token formData string false "Crypto token (e.g. usdt)"
|
||||||
// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
|
// @Param network formData string false "Network (e.g. tron)"
|
||||||
// @Param amount formData number false "Amount"
|
// @Param amount formData number false "Amount"
|
||||||
// @Param notify_url formData string false "Callback URL"
|
// @Param notify_url formData string false "Callback URL"
|
||||||
// @Param signature formData string false "MD5 signature"
|
// @Param signature formData string false "MD5 signature"
|
||||||
// @Param redirect_url formData string false "Redirect URL"
|
// @Param redirect_url formData string false "Redirect URL"
|
||||||
// @Param name formData string false "Order name"
|
// @Param name formData string false "Order name"
|
||||||
// @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
|
// @Param payment_type formData string false "Payment type"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||||
req := new(request.CreateTransactionRequest)
|
req := new(request.CreateTransactionRequest)
|
||||||
@@ -60,17 +58,17 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
|||||||
return c.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchNetwork 切换支付网络,补全占位父单或创建/返回子订单
|
// SwitchNetwork 切换支付网络,创建或返回子订单
|
||||||
// @Summary Switch payment network
|
// @Summary Switch payment network
|
||||||
// @Description Switch to a different payment target. A status=4 placeholder is completed in place and returns the same parent trade_id with is_selected=false; an already concrete status=1 parent creates or returns the only sub-order when switching to a different target.
|
// @Description Switch to a different payment target, creating or returning a sub-order.
|
||||||
// @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
|
// @Description Normal values such as tron/solana/ethereum create on-chain child orders.
|
||||||
// @Description For status=4 placeholders from GMPay or EPay submit.php, both on-chain targets and okpay complete the parent in place without creating a child order. Sub-orders cannot be switched again.
|
// @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10011 sub-order limit, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /pay/switch-network [post]
|
// @Router /pay/switch-network [post]
|
||||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||||
req := new(request.SwitchNetworkRequest)
|
req := new(request.SwitchNetworkRequest)
|
||||||
@@ -87,7 +85,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, constant.SystemErr)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
||||||
@@ -100,7 +98,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
||||||
// the canonical form — the GET variant is identical save the transport.
|
// the canonical form — the GET variant is identical save the transport.
|
||||||
// @Summary Create transaction and redirect (EPAY compat)
|
// @Summary Create transaction and redirect (EPAY compat)
|
||||||
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
|
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
// @Produce html
|
// @Produce html
|
||||||
@@ -123,7 +121,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// @Param sign formData string true "MD5 signature"
|
// @Param sign formData string true "MD5 signature"
|
||||||
// @Param sign_type formData string false "Signature type (MD5)"
|
// @Param sign_type formData string false "Signature type (MD5)"
|
||||||
// @Success 302 "Redirect to checkout counter"
|
// @Success 302 "Redirect to checkout counter"
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"net/http"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckoutCounter 收银台
|
// CheckoutCounter 收银台
|
||||||
// @Summary Checkout counter page
|
// @Summary Checkout counter page
|
||||||
// @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, 4=waiting token/network selection).
|
// @Description Render the payment checkout counter JSON response for a given trade
|
||||||
// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
|
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
// @Success 200 {object} 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]
|
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
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.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
@@ -32,12 +34,12 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
|||||||
|
|
||||||
// CheckStatus 支付状态检测
|
// CheckStatus 支付状态检测
|
||||||
// @Summary Check payment status
|
// @Summary Check payment status
|
||||||
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
|
// @Description Check the payment status of an order by trade ID
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /pay/check-status/{trade_id} [get]
|
// @Router /pay/check-status/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
@@ -51,33 +53,3 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
|||||||
}
|
}
|
||||||
return c.SucJson(ctx, resp)
|
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.
|
|
||||||
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
|
||||||
// @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,7 +5,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
|
||||||
"github.com/GMWalletApp/epusdt/middleware"
|
"github.com/GMWalletApp/epusdt/middleware"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
@@ -54,14 +53,20 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sort.Strings(symbols)
|
sort.Strings(symbols)
|
||||||
displayName := strings.TrimSpace(ch.DisplayName)
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = network
|
|
||||||
}
|
|
||||||
supports = append(supports, response.NetworkTokenSupport{
|
supports = append(supports, response.NetworkTokenSupport{
|
||||||
Network: network,
|
Network: network,
|
||||||
DisplayName: displayName,
|
Tokens: symbols,
|
||||||
Tokens: symbols,
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARC testnet tokens are hardcoded until the chain is formally added to
|
||||||
|
// the database. Show ARC in the list only if there is at least one active
|
||||||
|
// wallet address for the network.
|
||||||
|
arcWallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
||||||
|
if err == nil && len(arcWallets) > 0 {
|
||||||
|
supports = append(supports, response.NetworkTokenSupport{
|
||||||
|
Network: mdb.NetworkArc,
|
||||||
|
Tokens: []string{"EURC", "USDC"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +114,11 @@ func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
|||||||
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
||||||
},
|
},
|
||||||
Epay: response.EpayPublicConfig{
|
Epay: response.EpayPublicConfig{
|
||||||
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
|
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
||||||
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||||
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
|
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||||
},
|
},
|
||||||
OkPay: okpay,
|
OkPay: okpay,
|
||||||
Version: config.GetAppVersion(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/xssnick/tonutils-go v1.16.0
|
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
gopkg.in/telebot.v3 v3.0.0
|
gopkg.in/telebot.v3 v3.0.0
|
||||||
|
|||||||
@@ -348,8 +348,6 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
|
|||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/xssnick/tonutils-go v1.16.0 h1:MLYVZiiMB0Q+XudLo7WyjOli4gMuenNmJpBqETqa5DE=
|
|
||||||
github.com/xssnick/tonutils-go v1.16.0/go.mod h1:CNSM+L0FYufOFhprg8otc9Y/ecFfLrWqFMq00Gh9yk4=
|
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
)
|
)
|
||||||
@@ -66,19 +65,11 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||||
for _, network := range []string{
|
for _, network := range []string{
|
||||||
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
||||||
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon,
|
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
|
||||||
} {
|
} {
|
||||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
mainDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
|
||||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
|
||||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
|
||||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
|
||||||
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
|
||||||
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||||
// `pid=1001` still match.
|
// `pid=1001` still match.
|
||||||
@@ -94,11 +85,11 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
})
|
})
|
||||||
if err := dao.Mdb.Create(&mdb.Setting{
|
if err := dao.Mdb.Create(&mdb.Setting{
|
||||||
Group: "rate",
|
Group: "rate",
|
||||||
Key: "rate.forced_rate_list",
|
Key: "rate.forced_usdt_rate",
|
||||||
Value: `{"cny":{"usdt":1}}`,
|
Value: "1.0",
|
||||||
Type: "json",
|
Type: "string",
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("seed rate.forced_rate_list: %v", err)
|
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
|
|||||||
+10
-23
@@ -27,9 +27,16 @@ const (
|
|||||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
|
||||||
if err != nil {
|
if raw == "" {
|
||||||
return err
|
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")
|
||||||
}
|
}
|
||||||
claims, err := appjwt.Parse(token)
|
claims, err := appjwt.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,23 +48,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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,7 +46,6 @@ func MdbTableInit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seedChains()
|
seedChains()
|
||||||
backfillRpcNodePurpose()
|
|
||||||
seedRpcNodes()
|
seedRpcNodes()
|
||||||
seedChainTokens()
|
seedChainTokens()
|
||||||
seedDefaultSettings()
|
seedDefaultSettings()
|
||||||
@@ -64,7 +63,6 @@ func seedChains() {
|
|||||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||||
@@ -99,9 +97,6 @@ func seedChainTokens() {
|
|||||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||||
// Plasma
|
// Plasma
|
||||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||||
// TON
|
|
||||||
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
|
||||||
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||||
@@ -111,7 +106,14 @@ func seedChainTokens() {
|
|||||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||||
func seedRpcNodes() {
|
func seedRpcNodes() {
|
||||||
defaults := defaultRpcNodes()
|
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},
|
||||||
|
}
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
var count int64
|
var count int64
|
||||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||||
@@ -129,30 +131,6 @@ 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.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, 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.
|
// seedDefaultSettings inserts built-in default settings.
|
||||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||||
func seedDefaultSettings() {
|
func seedDefaultSettings() {
|
||||||
@@ -162,10 +140,9 @@ func seedDefaultSettings() {
|
|||||||
}
|
}
|
||||||
defaults := []mdb.Setting{
|
defaults := []mdb.Setting{
|
||||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
|
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
package dao
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/libtnb/sqlite"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
|
|
||||||
var got *mdb.RpcNode
|
|
||||||
nodes := defaultRpcNodes()
|
|
||||||
for i := range nodes {
|
|
||||||
node := nodes[i]
|
|
||||||
if node.Network == mdb.NetworkTon && node.Type == mdb.RpcNodeTypeLite {
|
|
||||||
got = &node
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("missing TON lite seed rpc node")
|
|
||||||
}
|
|
||||||
if got.Url != "https://ton-blockchain.github.io/global.config.json" {
|
|
||||||
t.Fatalf("TON lite seed url = %q", got.Url)
|
|
||||||
}
|
|
||||||
if got.Purpose != mdb.RpcNodePurposeGeneral {
|
|
||||||
t.Fatalf("TON lite seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
|
|
||||||
}
|
|
||||||
if !got.Enabled {
|
|
||||||
t.Fatal("TON lite seed enabled = false, want true")
|
|
||||||
}
|
|
||||||
if got.Status != mdb.RpcNodeStatusUnknown {
|
|
||||||
t.Fatalf("TON lite seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
|
||||||
db := setupSeedSettingsTestDB(t)
|
|
||||||
Mdb = db
|
|
||||||
|
|
||||||
seedDefaultSettings()
|
|
||||||
|
|
||||||
var row mdb.Setting
|
|
||||||
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load system.log_level seed: %v", err)
|
|
||||||
}
|
|
||||||
if row.Group != mdb.SettingGroupSystem {
|
|
||||||
t.Fatalf("system.log_level group = %q, want %q", row.Group, mdb.SettingGroupSystem)
|
|
||||||
}
|
|
||||||
if row.Value != mdb.SettingDefaultSystemLogLevel {
|
|
||||||
t.Fatalf("system.log_level value = %q, want %q", row.Value, mdb.SettingDefaultSystemLogLevel)
|
|
||||||
}
|
|
||||||
if row.Type != mdb.SettingTypeString {
|
|
||||||
t.Fatalf("system.log_level type = %q, want %q", row.Type, mdb.SettingTypeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
|
|
||||||
db := setupSeedSettingsTestDB(t)
|
|
||||||
Mdb = db
|
|
||||||
|
|
||||||
seedDefaultSettings()
|
|
||||||
|
|
||||||
rows := make(map[string]mdb.Setting)
|
|
||||||
for _, key := range []string{
|
|
||||||
mdb.SettingKeyEpayDefaultToken,
|
|
||||||
mdb.SettingKeyEpayDefaultCurrency,
|
|
||||||
mdb.SettingKeyEpayDefaultNetwork,
|
|
||||||
} {
|
|
||||||
var row mdb.Setting
|
|
||||||
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load %s seed: %v", key, err)
|
|
||||||
}
|
|
||||||
rows[key] = row
|
|
||||||
}
|
|
||||||
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
|
|
||||||
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
|
|
||||||
}
|
|
||||||
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
|
|
||||||
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
|
|
||||||
}
|
|
||||||
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
|
|
||||||
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
|
|
||||||
db := setupSeedSettingsTestDB(t)
|
|
||||||
Mdb = db
|
|
||||||
for _, row := range []mdb.Setting{
|
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
|
||||||
} {
|
|
||||||
if err := Mdb.Create(&row).Error; err != nil {
|
|
||||||
t.Fatalf("precreate %s: %v", row.Key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seedDefaultSettings()
|
|
||||||
|
|
||||||
for key, want := range map[string]string{
|
|
||||||
mdb.SettingKeyEpayDefaultToken: "usdt",
|
|
||||||
mdb.SettingKeyEpayDefaultNetwork: "tron",
|
|
||||||
} {
|
|
||||||
var row mdb.Setting
|
|
||||||
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load %s seed: %v", key, err)
|
|
||||||
}
|
|
||||||
if row.Value != want {
|
|
||||||
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
|
|
||||||
db := setupSeedSettingsTestDB(t)
|
|
||||||
Mdb = db
|
|
||||||
if err := Mdb.Create(&mdb.Setting{
|
|
||||||
Group: mdb.SettingGroupSystem,
|
|
||||||
Key: mdb.SettingKeySystemLogLevel,
|
|
||||||
Value: "debug",
|
|
||||||
Type: mdb.SettingTypeString,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("precreate system.log_level: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seedDefaultSettings()
|
|
||||||
|
|
||||||
var row mdb.Setting
|
|
||||||
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load system.log_level seed: %v", err)
|
|
||||||
}
|
|
||||||
if row.Value != "debug" {
|
|
||||||
t.Fatalf("system.log_level value = %q, want existing debug", row.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
oldDB := Mdb
|
|
||||||
viper.Reset()
|
|
||||||
viper.Set("app_uri", "https://example.com")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
Mdb = oldDB
|
|
||||||
viper.Reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-settings.db")), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&mdb.Setting{}); err != nil {
|
|
||||||
t.Fatalf("migrate settings: %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
@@ -40,9 +40,6 @@ type InitialAdminPasswordHashInfo struct {
|
|||||||
// can print it to the console. Idempotent — subsequent calls return
|
// can print it to the console. Idempotent — subsequent calls return
|
||||||
// ("", false, nil).
|
// ("", false, nil).
|
||||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||||
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
var count int64
|
var count int64
|
||||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
@@ -71,12 +68,6 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
|
|||||||
return password, true, nil
|
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 {
|
func randomAdminPassword() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
@@ -130,11 +121,9 @@ func initAdminPasswordState(plain string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) 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{
|
return tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "key"}},
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
DoUpdates: updates,
|
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +154,7 @@ func ConsumeInitialAdminPassword() (string, error) {
|
|||||||
return ErrInitAdminPasswordUnavailable
|
return ErrInitAdminPasswordUnavailable
|
||||||
}
|
}
|
||||||
password = row.Value
|
password = row.Value
|
||||||
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,9 @@ func GetChainByNetwork(network string) (*mdb.Chain, error) {
|
|||||||
// missing row is treated as disabled — scanners must be able to
|
// missing row is treated as disabled — scanners must be able to
|
||||||
// short-circuit even before the admin creates the chain entry.
|
// short-circuit even before the admin creates the chain entry.
|
||||||
func IsChainEnabled(network string) bool {
|
func IsChainEnabled(network string) bool {
|
||||||
|
if strings.ToLower(strings.TrimSpace(network)) == mdb.NetworkArc {
|
||||||
|
return true
|
||||||
|
}
|
||||||
row, err := GetChainByNetwork(network)
|
row, err := GetChainByNetwork(network)
|
||||||
if err != nil || row.ID == 0 {
|
if err != nil || row.ID == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseOrderManually transitions a pending order to expired. It covers both
|
// CloseOrderManually transitions a pending order to expired. Only
|
||||||
// payable orders and placeholder orders waiting for token/network selection.
|
// touches rows currently in StatusWaitPay so idempotent / safe.
|
||||||
func CloseOrderManually(tradeID string) (bool, error) {
|
func CloseOrderManually(tradeID string) (bool, error) {
|
||||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Where("trade_id = ?", tradeID).
|
Where("trade_id = ?", tradeID).
|
||||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
Where("status = ?", mdb.StatusWaitPay).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -40,15 +39,9 @@ func normalizeLockNetwork(network string) string {
|
|||||||
|
|
||||||
func normalizeLockAddress(network, address string) string {
|
func normalizeLockAddress(network, address string) string {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
network = normalizeLockNetwork(network)
|
if isEVMNetwork(normalizeLockNetwork(network)) {
|
||||||
if isEVMNetwork(network) {
|
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
}
|
}
|
||||||
if network == mdb.NetworkTon {
|
|
||||||
if raw, err := addressutil.TonRawAddressKey(address); err == nil {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,56 +108,6 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
|
|||||||
return order, err
|
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.
|
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||||
result := tx.Model(&mdb.Orders{}).
|
result := tx.Model(&mdb.Orders{}).
|
||||||
@@ -209,7 +152,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
|
|||||||
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
||||||
result := dao.Mdb.Model(mdb.Orders{}).
|
result := dao.Mdb.Model(mdb.Orders{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
Where("status = ?", mdb.StatusWaitPay).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
@@ -225,15 +168,6 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountSubOrders counts all sub-orders ever created under a parent.
|
|
||||||
func CountSubOrders(parentTradeId string) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Where("parent_trade_id = ?", parentTradeId).
|
|
||||||
Count(&count).Error
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
||||||
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
||||||
order := new(mdb.Orders)
|
order := new(mdb.Orders)
|
||||||
@@ -297,61 +231,6 @@ func MarkOrderSelected(tradeId string) error {
|
|||||||
Update("is_selected", true).Error
|
Update("is_selected", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
|
|
||||||
// and makes it payable. It only updates status=WaitSelect rows so concurrent
|
|
||||||
// switch-network attempts cannot overwrite a payable order.
|
|
||||||
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
|
|
||||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Where("trade_id = ?", tradeID).
|
|
||||||
Where("status = ?", mdb.StatusWaitSelect).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"status": mdb.StatusWaitPay,
|
|
||||||
"network": strings.ToLower(strings.TrimSpace(network)),
|
|
||||||
"token": strings.ToUpper(strings.TrimSpace(token)),
|
|
||||||
"receive_address": receiveAddress,
|
|
||||||
"actual_amount": actualAmount,
|
|
||||||
"is_selected": false,
|
|
||||||
"created_at": time.Now(),
|
|
||||||
})
|
|
||||||
return result.RowsAffected > 0, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
|
|
||||||
// directly into an OkPay order. It intentionally does not create a local chain
|
|
||||||
// lock because OkPay owns the hosted payment target.
|
|
||||||
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
|
|
||||||
result := tx.Model(&mdb.Orders{}).
|
|
||||||
Where("trade_id = ?", tradeID).
|
|
||||||
Where("status = ?", mdb.StatusWaitSelect).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"status": mdb.StatusWaitPay,
|
|
||||||
"network": mdb.PaymentProviderOkPay,
|
|
||||||
"token": strings.ToUpper(strings.TrimSpace(token)),
|
|
||||||
"receive_address": "OKPAY",
|
|
||||||
"actual_amount": actualAmount,
|
|
||||||
"is_selected": false,
|
|
||||||
"pay_provider": mdb.PaymentProviderOkPay,
|
|
||||||
"created_at": time.Now(),
|
|
||||||
})
|
|
||||||
return result.RowsAffected > 0, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
|
|
||||||
// as selected for a hosted payment provider. It intentionally does not fill
|
|
||||||
// chain fields or create a transaction lock; the provider child order carries
|
|
||||||
// the concrete payment target.
|
|
||||||
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
|
|
||||||
result := tx.Model(&mdb.Orders{}).
|
|
||||||
Where("trade_id = ?", tradeID).
|
|
||||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"status": mdb.StatusWaitPay,
|
|
||||||
"is_selected": true,
|
|
||||||
"created_at": time.Now(),
|
|
||||||
})
|
|
||||||
return result.RowsAffected > 0, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
||||||
// child-order attempts that should not remain selectable/reusable.
|
// child-order attempts that should not remain selectable/reusable.
|
||||||
func ExpireOrderByTradeID(tradeId string) error {
|
func ExpireOrderByTradeID(tradeId string) error {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -29,40 +28,25 @@ type AddressDailyStat struct {
|
|||||||
ActualAmount float64 `json:"actual_amount"`
|
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],
|
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||||
// zero-filling any days in the range that have no orders.
|
// zero-filling any days in the range that have no orders.
|
||||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||||
// what the user means by "流水". Pending orders count toward OrderCount
|
// what the user means by "流水". Pending orders count toward OrderCount
|
||||||
// to keep成交率 computable.
|
// to keep成交率 computable.
|
||||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
rows, err := queryOrderStats(start, end, false)
|
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
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -72,34 +56,25 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
|||||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||||
// zero-filling any hours that have no orders.
|
// zero-filling any hours that have no orders.
|
||||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
rows, err := queryOrderStats(start, end, true)
|
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
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillHourlyStats(start, end, rows), nil
|
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
|
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||||
@@ -147,7 +122,15 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
|||||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing days.
|
// for the per-address stacked chart, zero-filling missing days.
|
||||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
rows, err := queryAssetByAddress(start, end, false)
|
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
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -157,33 +140,21 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
|||||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing hours.
|
// for the per-address stacked chart, zero-filling missing hours.
|
||||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
rows, err := queryAssetByAddress(start, end, true)
|
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
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillAddressHourlyStats(start, end, rows), nil
|
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.
|
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||||
// All addresses that appear in rows are represented for every day in range.
|
// All addresses that appear in rows are represented for every day in range.
|
||||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||||
@@ -43,56 +42,6 @@ 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) {
|
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -157,30 +106,3 @@ func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
|||||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
|
||||||
addr := address.MustParseAddr(bounceable)
|
|
||||||
nonBounce := addr.Bounce(false).String()
|
|
||||||
|
|
||||||
if err := LockTransaction(mdb.NetworkTon, bounceable, "TON", "trade-ton", 1.23, time.Hour); err != nil {
|
|
||||||
t.Fatalf("lock ton transaction: %v", err)
|
|
||||||
}
|
|
||||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, nonBounce, "TON", 1.23)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup ton lock: %v", err)
|
|
||||||
}
|
|
||||||
if gotTradeID != "trade-ton" {
|
|
||||||
t.Fatalf("ton lock lookup = %q, want trade-ton", gotTradeID)
|
|
||||||
}
|
|
||||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", 1.23)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup ton raw lock: %v", err)
|
|
||||||
}
|
|
||||||
if gotTradeID != "trade-ton" {
|
|
||||||
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
||||||
@@ -21,27 +20,8 @@ func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) err
|
|||||||
return tx.Model(row).Create(row).Error
|
return tx.Model(row).Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
|
||||||
return tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"provider_order_id",
|
|
||||||
"pay_url",
|
|
||||||
"amount",
|
|
||||||
"coin",
|
|
||||||
"status",
|
|
||||||
"notify_raw",
|
|
||||||
"updated_at",
|
|
||||||
}),
|
|
||||||
}).Create(row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||||
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
|
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
|
|
||||||
return tx.Model(&mdb.ProviderOrder{}).
|
|
||||||
Where("trade_id = ?", tradeID).
|
Where("trade_id = ?", tradeID).
|
||||||
Where("provider = ?", provider).
|
Where("provider = ?", provider).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/url"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/dromara/carbon/v2"
|
"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.
|
// ListRpcNodes returns rows optionally filtered by network.
|
||||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||||
var rows []mdb.RpcNode
|
var rows []mdb.RpcNode
|
||||||
@@ -50,84 +20,6 @@ func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
|||||||
return rows, err
|
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.
|
// GetRpcNodeByID fetches one row.
|
||||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||||
row := new(mdb.RpcNode)
|
row := new(mdb.RpcNode)
|
||||||
@@ -137,7 +29,6 @@ func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
|||||||
|
|
||||||
// CreateRpcNode inserts a row.
|
// CreateRpcNode inserts a row.
|
||||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
|
||||||
return dao.Mdb.Create(row).Error
|
return dao.Mdb.Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +37,6 @@ func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
|||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
return nil
|
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
|
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,15 +48,13 @@ func DeleteRpcNodeByID(id uint64) error {
|
|||||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||||
// Falls back to enabled rows with status=unknown when no health check has
|
// Falls back to enabled rows with status=unknown when no health check has
|
||||||
// run yet. Explicitly down rows are not selected. Manual verification nodes
|
// run yet. Explicitly down rows are not selected.
|
||||||
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
|
|
||||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||||
var rows []mdb.RpcNode
|
var rows []mdb.RpcNode
|
||||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
Where("network = ?", strings.ToLower(network)).
|
Where("network = ?", strings.ToLower(network)).
|
||||||
Where("type = ?", strings.ToLower(nodeType)).
|
Where("type = ?", strings.ToLower(nodeType)).
|
||||||
Where("enabled = ?", true).
|
Where("enabled = ?", true).
|
||||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -196,144 +82,6 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
|||||||
return pickWeighted(candidates), nil
|
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
|
// 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
|
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||||
// to truly disable).
|
// to truly disable).
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
@@ -100,306 +99,3 @@ func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
|
|||||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RpcRuntimeStatsSnapshot is an in-process snapshot of RPC activity for one
|
|
||||||
// chain. It is intentionally not persisted; restarting the process resets it.
|
|
||||||
type RpcRuntimeStatsSnapshot struct {
|
|
||||||
Network string
|
|
||||||
SuccessCount int64
|
|
||||||
FailureCount int64
|
|
||||||
LatestBlockHeight int64
|
|
||||||
LastSyncAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcRuntimeStatsCounter struct {
|
|
||||||
successCount int64
|
|
||||||
failureCount int64
|
|
||||||
latestBlockHeight int64
|
|
||||||
lastSyncAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var gRpcRuntimeStats = struct {
|
|
||||||
sync.RWMutex
|
|
||||||
byNetwork map[string]*rpcRuntimeStatsCounter
|
|
||||||
}{
|
|
||||||
byNetwork: make(map[string]*rpcRuntimeStatsCounter),
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRpcStatsNetwork(network string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(network))
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcStatsCounterLocked(network string) *rpcRuntimeStatsCounter {
|
|
||||||
counter := gRpcRuntimeStats.byNetwork[network]
|
|
||||||
if counter == nil {
|
|
||||||
counter = &rpcRuntimeStatsCounter{}
|
|
||||||
gRpcRuntimeStats.byNetwork[network] = counter
|
|
||||||
}
|
|
||||||
return counter
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordRpcSuccess records one successful application-level RPC operation.
|
|
||||||
func RecordRpcSuccess(network string) {
|
|
||||||
network = normalizeRpcStatsNetwork(network)
|
|
||||||
if network == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gRpcRuntimeStats.Lock()
|
|
||||||
defer gRpcRuntimeStats.Unlock()
|
|
||||||
counter := rpcStatsCounterLocked(network)
|
|
||||||
counter.successCount++
|
|
||||||
counter.lastSyncAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordRpcFailure records one failed application-level RPC operation.
|
|
||||||
func RecordRpcFailure(network string) {
|
|
||||||
network = normalizeRpcStatsNetwork(network)
|
|
||||||
if network == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gRpcRuntimeStats.Lock()
|
|
||||||
defer gRpcRuntimeStats.Unlock()
|
|
||||||
counter := rpcStatsCounterLocked(network)
|
|
||||||
counter.failureCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordRpcBlockHeight records the latest observed chain height.
|
|
||||||
func RecordRpcBlockHeight(network string, height int64) {
|
|
||||||
network = normalizeRpcStatsNetwork(network)
|
|
||||||
if network == "" || height <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gRpcRuntimeStats.Lock()
|
|
||||||
defer gRpcRuntimeStats.Unlock()
|
|
||||||
counter := rpcStatsCounterLocked(network)
|
|
||||||
counter.lastSyncAt = time.Now()
|
|
||||||
if height > counter.latestBlockHeight {
|
|
||||||
counter.latestBlockHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SnapshotRpcRuntimeStats returns a copy of the current per-chain counters.
|
|
||||||
func SnapshotRpcRuntimeStats() map[string]RpcRuntimeStatsSnapshot {
|
|
||||||
gRpcRuntimeStats.RLock()
|
|
||||||
defer gRpcRuntimeStats.RUnlock()
|
|
||||||
out := make(map[string]RpcRuntimeStatsSnapshot, len(gRpcRuntimeStats.byNetwork))
|
|
||||||
for network, counter := range gRpcRuntimeStats.byNetwork {
|
|
||||||
out[network] = RpcRuntimeStatsSnapshot{
|
|
||||||
Network: network,
|
|
||||||
SuccessCount: counter.successCount,
|
|
||||||
FailureCount: counter.failureCount,
|
|
||||||
LatestBlockHeight: counter.latestBlockHeight,
|
|
||||||
LastSyncAt: counter.lastSyncAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetRpcRuntimeStatsForTest clears process-local stats for isolated tests.
|
|
||||||
func ResetRpcRuntimeStatsForTest() {
|
|
||||||
gRpcRuntimeStats.Lock()
|
|
||||||
defer gRpcRuntimeStats.Unlock()
|
|
||||||
gRpcRuntimeStats.byNetwork = make(map[string]*rpcRuntimeStatsCounter)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRpcRuntimeStatsRecordsCountsAndLatestHeight(t *testing.T) {
|
|
||||||
ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
RecordRpcSuccess(" TRON ")
|
|
||||||
RecordRpcSuccess("tron")
|
|
||||||
RecordRpcFailure("TRON")
|
|
||||||
RecordRpcBlockHeight("Tron", 100)
|
|
||||||
RecordRpcBlockHeight("tron", 90)
|
|
||||||
|
|
||||||
stats := SnapshotRpcRuntimeStats()
|
|
||||||
got, ok := stats["tron"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("missing tron stats: %#v", stats)
|
|
||||||
}
|
|
||||||
if got.Network != "tron" {
|
|
||||||
t.Fatalf("network = %q, want tron", got.Network)
|
|
||||||
}
|
|
||||||
if got.SuccessCount != 2 {
|
|
||||||
t.Fatalf("success count = %d, want 2", got.SuccessCount)
|
|
||||||
}
|
|
||||||
if got.FailureCount != 1 {
|
|
||||||
t.Fatalf("failure count = %d, want 1", got.FailureCount)
|
|
||||||
}
|
|
||||||
if got.LatestBlockHeight != 100 {
|
|
||||||
t.Fatalf("latest block height = %d, want 100", got.LatestBlockHeight)
|
|
||||||
}
|
|
||||||
if got.LastSyncAt.Before(start) {
|
|
||||||
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRpcRuntimeStatsIgnoresEmptyNetworkAndInvalidHeight(t *testing.T) {
|
|
||||||
ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
RecordRpcSuccess("")
|
|
||||||
RecordRpcFailure(" ")
|
|
||||||
RecordRpcBlockHeight("tron", 0)
|
|
||||||
|
|
||||||
stats := SnapshotRpcRuntimeStats()
|
|
||||||
if len(stats) != 0 {
|
|
||||||
t.Fatalf("stats = %#v, want empty", stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRpcRuntimeStatsBlockHeightUpdatesLastSyncAt(t *testing.T) {
|
|
||||||
ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
RecordRpcBlockHeight("ethereum", 123)
|
|
||||||
|
|
||||||
stats := SnapshotRpcRuntimeStats()
|
|
||||||
got := stats["ethereum"]
|
|
||||||
if got.SuccessCount != 0 {
|
|
||||||
t.Fatalf("success count = %d, want 0", got.SuccessCount)
|
|
||||||
}
|
|
||||||
if got.LastSyncAt.Before(start) {
|
|
||||||
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResetRpcRuntimeStatsForTest(t *testing.T) {
|
|
||||||
ResetRpcRuntimeStatsForTest()
|
|
||||||
RecordRpcSuccess("tron")
|
|
||||||
ResetRpcRuntimeStatsForTest()
|
|
||||||
|
|
||||||
stats := SnapshotRpcRuntimeStats()
|
|
||||||
if len(stats) != 0 {
|
|
||||||
t.Fatalf("stats = %#v, want empty after reset", stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -144,11 +144,9 @@ func SetSetting(group, key, value, valueType string) error {
|
|||||||
valueType = mdb.SettingTypeString
|
valueType = mdb.SettingTypeString
|
||||||
}
|
}
|
||||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
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{
|
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "key"}},
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
DoUpdates: updates,
|
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
// isEVMNetwork 判断是否是 EVM 网络
|
// isEVMNetwork 判断是否是 EVM 网络
|
||||||
func isEVMNetwork(network string) bool {
|
func isEVMNetwork(network string) bool {
|
||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -35,23 +34,10 @@ func normalizeWalletAddressByNetwork(network, address string) string {
|
|||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
|
|
||||||
network = normalizeWalletNetwork(network)
|
|
||||||
address = strings.TrimSpace(address)
|
|
||||||
if network == mdb.NetworkTon {
|
|
||||||
return addressutil.NormalizeTonAddress(address)
|
|
||||||
}
|
|
||||||
return normalizeWalletAddressByNetwork(network, address), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
var err error
|
address = normalizeWalletAddressByNetwork(network, address)
|
||||||
address, err = normalizeWalletAddressByNetworkE(network, address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,13 +75,9 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
|||||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
var err error
|
address = normalizeWalletAddressByNetwork(network, address)
|
||||||
address, err = normalizeWalletAddressByNetworkE(network, address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
walletAddress := new(mdb.WalletAddress)
|
walletAddress := new(mdb.WalletAddress)
|
||||||
err = dao.Mdb.Model(walletAddress).
|
err := dao.Mdb.Model(walletAddress).
|
||||||
Where("network = ?", network).
|
Where("network = ?", network).
|
||||||
Where("address = ?", address).
|
Where("address = ?", address).
|
||||||
Limit(1).Find(walletAddress).Error
|
Limit(1).Find(walletAddress).Error
|
||||||
@@ -144,12 +126,6 @@ func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, er
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
} else if network == mdb.NetworkTon {
|
|
||||||
for i := range list {
|
|
||||||
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
|
||||||
list[i].Address = normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
@@ -173,12 +149,6 @@ func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
} else if network == mdb.NetworkTon {
|
|
||||||
for i := range list {
|
|
||||||
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
|
||||||
list[i].Address = normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||||
@@ -83,23 +81,3 @@ func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
|||||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
|
||||||
addr := address.MustParseAddr(bounceable)
|
|
||||||
expected := addr.Bounce(false).String()
|
|
||||||
|
|
||||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("add raw ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
if row.Address != expected {
|
|
||||||
t.Fatalf("stored TON address = %q, want %q", row.Address, expected)
|
|
||||||
}
|
|
||||||
if _, err = AddWalletAddressWithNetwork(mdb.NetworkTon, bounceable); err != constant.WalletAddressAlreadyExists {
|
|
||||||
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-26
@@ -1,24 +1,15 @@
|
|||||||
package mdb
|
package mdb
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusWaitPay = 1
|
StatusWaitPay = 1
|
||||||
StatusPaySuccess = 2
|
StatusPaySuccess = 2
|
||||||
StatusExpired = 3
|
StatusExpired = 3
|
||||||
// StatusWaitSelect is a placeholder order waiting for the payer to choose
|
|
||||||
// token+network or a hosted provider such as OkPay. It is not payable until
|
|
||||||
// switch-network moves it to StatusWaitPay.
|
|
||||||
StatusWaitSelect = 4
|
|
||||||
CallBackConfirmOk = 1
|
CallBackConfirmOk = 1
|
||||||
CallBackConfirmNo = 2
|
CallBackConfirmNo = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PaymentTypeEpay is the normalized special payment_type value. Orders
|
|
||||||
// matching it case-insensitively use the legacy EPay callback shape.
|
|
||||||
PaymentTypeEpay = "Epay"
|
PaymentTypeEpay = "Epay"
|
||||||
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
|
|
||||||
// It uses the default GMPay JSON callback shape.
|
|
||||||
PaymentTypeGmpay = "Gmpay"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,15 +19,14 @@ const (
|
|||||||
// PaymentProviderOkPay means this concrete order record is settled through
|
// PaymentProviderOkPay means this concrete order record is settled through
|
||||||
// the third-party OkayPay/OkPay hosted checkout flow.
|
// the third-party OkayPay/OkPay hosted checkout flow.
|
||||||
//
|
//
|
||||||
// It can be used by a status=4 parent completed in place on first
|
// In the current design this is typically used by a switch-network-created
|
||||||
// switch-network, or by a switch-network-created child order after the
|
// child order, while the parent order keeps its original merchant-facing
|
||||||
// parent is already concrete. Merchant callback shape is still controlled by
|
// semantics and callback behavior.
|
||||||
// PaymentType, not by this provider value.
|
|
||||||
PaymentProviderOkPay = "okpay"
|
PaymentProviderOkPay = "okpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Orders struct {
|
type Orders struct {
|
||||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
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"`
|
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..."`
|
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||||
@@ -46,23 +36,21 @@ type Orders struct {
|
|||||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
|
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
||||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||||
// PaymentType is not a channel selector. "Epay" changes callback format
|
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||||
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
|
|
||||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
|
||||||
// PayProvider identifies how this specific order row is collected.
|
// PayProvider identifies how this specific order row is collected.
|
||||||
//
|
//
|
||||||
// Semantics:
|
// Semantics:
|
||||||
// - chain parent orders and regular chain child orders use on_chain
|
// - parent orders and regular chain child orders use on_chain
|
||||||
// - third-party hosted checkout rows use their provider name
|
// - third-party hosted checkout child orders use their provider name
|
||||||
// (for example okpay)
|
// (for example okpay)
|
||||||
//
|
//
|
||||||
// Existing rows default to on_chain for backward compatibility so upgrades
|
// Existing rows default to on_chain for backward compatibility so upgrades
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderOrder stores provider-specific checkout data for one internal order
|
// ProviderOrder stores provider-specific checkout data for one internal order
|
||||||
// row. For OkPay this can bind either to a status=4 parent completed in place
|
// row. For OkPay this is expected to bind to the concrete child order created
|
||||||
// or to a concrete child order created by later switch-network calls.
|
// by switch-network, not to the parent merchant order.
|
||||||
//
|
//
|
||||||
// Binding rules:
|
// Binding rules:
|
||||||
// - trade_id always points to orders.trade_id
|
// - trade_id always points to orders.trade_id
|
||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
// The main orders table remains the source of truth for merchant-facing state,
|
// The main orders table remains the source of truth for merchant-facing state,
|
||||||
// while this table keeps provider-specific identifiers and callback payloads.
|
// while this table keeps provider-specific identifiers and callback payloads.
|
||||||
type ProviderOrder struct {
|
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:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
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"`
|
||||||
// Provider values should match the bound Orders.PayProvider value.
|
// 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"`
|
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
|
// ProviderOrderID is the downstream provider's own order identifier for the
|
||||||
|
|||||||
+11
-19
@@ -5,7 +5,6 @@ import "github.com/dromara/carbon/v2"
|
|||||||
const (
|
const (
|
||||||
RpcNodeTypeHttp = "http"
|
RpcNodeTypeHttp = "http"
|
||||||
RpcNodeTypeWs = "ws"
|
RpcNodeTypeWs = "ws"
|
||||||
RpcNodeTypeLite = "lite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,26 +13,19 @@ const (
|
|||||||
RpcNodeStatusDown = "down"
|
RpcNodeStatusDown = "down"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// RpcNode records one RPC endpoint for a network. Clients select a node
|
||||||
RpcNodePurposeGeneral = "general"
|
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
||||||
RpcNodePurposeManualVerify = "manual_verify"
|
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
||||||
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 {
|
type RpcNode struct {
|
||||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
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"`
|
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" example:"http"`
|
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"`
|
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"`
|
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"`
|
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=异常
|
// 健康状态 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"`
|
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"`
|
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ package mdb
|
|||||||
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
||||||
// secret_key) live in the api_keys table; notification configs live in
|
// secret_key) live in the api_keys table; notification configs live in
|
||||||
// NotificationChannel.
|
// NotificationChannel.
|
||||||
// Default rows for the system/epay/okpay groups are seeded on first startup
|
// Default rows for the epay/okpay groups are seeded on first startup (see
|
||||||
// (see model/dao/mdb_table_init.go seedDefaultSettings). Other groups start
|
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
||||||
// empty and fall back to hardcoded defaults until an admin sets them. The
|
// empty and fall back to hardcoded defaults until an admin sets them.
|
||||||
// system.jwt_secret key is auto-generated on first startup.
|
// Only exception: system.jwt_secret is auto-generated on first startup.
|
||||||
const (
|
const (
|
||||||
SettingGroupBrand = "brand"
|
SettingGroupBrand = "brand"
|
||||||
SettingGroupRate = "rate"
|
SettingGroupRate = "rate"
|
||||||
@@ -31,7 +31,6 @@ const (
|
|||||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||||
SettingKeyAmountPrecision = "system.amount_precision"
|
SettingKeyAmountPrecision = "system.amount_precision"
|
||||||
SettingKeySystemLogLevel = "system.log_level"
|
|
||||||
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||||
SettingKeyBrandSiteName = "brand.site_name"
|
SettingKeyBrandSiteName = "brand.site_name"
|
||||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||||
@@ -42,7 +41,7 @@ const (
|
|||||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||||
SettingKeyBrandBackgroundColor = "brand.background_color"
|
SettingKeyBrandBackgroundColor = "brand.background_color"
|
||||||
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
|
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
|
||||||
SettingKeyRateForcedRateList = "rate.forced_rate_list"
|
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||||
SettingKeyRateApiUrl = "rate.api_url"
|
SettingKeyRateApiUrl = "rate.api_url"
|
||||||
@@ -63,16 +62,12 @@ const (
|
|||||||
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
SettingDefaultSystemLogLevel = "error"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
|
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_rate_list"`
|
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:"{\"cny\":{\"usdt\":0.14635}}"`
|
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:"json"`
|
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:"强制汇率列表"`
|
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
||||||
BaseModel
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const (
|
|||||||
NetworkTron = "tron"
|
NetworkTron = "tron"
|
||||||
NetworkSolana = "solana"
|
NetworkSolana = "solana"
|
||||||
NetworkEthereum = "ethereum"
|
NetworkEthereum = "ethereum"
|
||||||
NetworkBsc = "binance"
|
NetworkBsc = "bsc"
|
||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
NetworkTon = "ton"
|
NetworkArc = "arc-testnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,18 +6,14 @@ import "github.com/gookit/validate"
|
|||||||
type CreateTransactionRequest struct {
|
type CreateTransactionRequest struct {
|
||||||
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||||
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||||
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
|
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
|
||||||
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
|
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
||||||
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||||
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||||
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||||
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `json:"name" form:"name" example:"VIP月卡"`
|
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||||
// PaymentType is a compatibility flag, not a gateway selector. Only
|
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||||
// "Epay" (case-insensitive) switches callback format to legacy EPay;
|
|
||||||
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
|
|
||||||
// It is optional for GMPay, but must be included in the signature when sent.
|
|
||||||
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||||
@@ -43,20 +39,9 @@ type OrderProcessingRequest struct {
|
|||||||
BlockTransactionId string
|
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 切换支付网络
|
// SwitchNetworkRequest 切换支付网络
|
||||||
type SwitchNetworkRequest struct {
|
type SwitchNetworkRequest struct {
|
||||||
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
|
||||||
Token string `json:"token" validate:"required" example:"USDT"`
|
Token string `json:"token" validate:"required" example:"USDT"`
|
||||||
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
|
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ package response
|
|||||||
// Used for swagger documentation only — the actual response is built
|
// Used for swagger documentation only — the actual response is built
|
||||||
// by util/http.Resp.
|
// by util/http.Resp.
|
||||||
type ApiResponse struct {
|
type ApiResponse struct {
|
||||||
// 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"`
|
||||||
StatusCode int `json:"status_code" example:"200"`
|
Message string `json:"message" example:"success"`
|
||||||
// Default server-side errno text. Frontend should prefer status_code for display translation.
|
Data interface{} `json:"data"`
|
||||||
Message string `json:"message" example:"success"`
|
RequestID string `json:"request_id" example:"req-123456"`
|
||||||
Data interface{} `json:"data"`
|
|
||||||
// Request id echoed from X-Request-ID when present.
|
|
||||||
RequestID string `json:"request_id" example:"req-123456"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,21 @@ package response
|
|||||||
|
|
||||||
// CreateTransactionResponse 创建订单成功返回
|
// CreateTransactionResponse 创建订单成功返回
|
||||||
type CreateTransactionResponse struct {
|
type CreateTransactionResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderNotifyResponse 订单异步回调结构体
|
// OrderNotifyResponse 订单异步回调结构体
|
||||||
type OrderNotifyResponse struct {
|
type OrderNotifyResponse struct {
|
||||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
@@ -31,13 +30,13 @@ type OrderNotifyResponse struct {
|
|||||||
|
|
||||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||||
type OrderNotifyResponseEpay struct {
|
type OrderNotifyResponseEpay struct {
|
||||||
PID int `json:"pid" example:"1001"` // 商户ID
|
PID int `json:"pid" example:"1001"` // 商户ID
|
||||||
TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
|
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||||
Type string `json:"type" example:"usdt"` // 订单类型
|
Type string `json:"type" example:"usdt"` // 订单类型
|
||||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type CheckoutCounterResponse struct {
|
type CheckoutCounterResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH;status=4 占位订单为空
|
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
||||||
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
|
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
|
||||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
|
||||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||||
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
|
IsSelected bool `json:"is_selected" example:"false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
Status int `json:"status" enums:"1,2,3,4" example:"1"`
|
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,15 +1,14 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type NetworkTokenSupport struct {
|
type NetworkTokenSupport struct {
|
||||||
Network string `json:"network" example:"tron"`
|
Network string `json:"network" example:"tron"`
|
||||||
DisplayName string `json:"display_name" example:"TRON"`
|
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EpayPublicConfig struct {
|
type EpayPublicConfig struct {
|
||||||
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
DefaultToken string `json:"default_token" example:"usdt"`
|
||||||
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
|
DefaultCurrency string `json:"default_currency" example:"cny"`
|
||||||
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
DefaultNetwork string `json:"default_network" example:"tron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitePublicConfig struct {
|
type SitePublicConfig struct {
|
||||||
@@ -37,5 +36,4 @@ type PublicConfigResponse struct {
|
|||||||
Site SitePublicConfig `json:"site"`
|
Site SitePublicConfig `json:"site"`
|
||||||
Epay EpayPublicConfig `json:"epay"`
|
Epay EpayPublicConfig `json:"epay"`
|
||||||
OkPay OkPayPublicConfig `json:"okpay"`
|
OkPay OkPayPublicConfig `json:"okpay"`
|
||||||
Version string `json:"version" example:"v1.0.1"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,818 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"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"
|
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/dromara/carbon/v2"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/tlb"
|
|
||||||
"github.com/xssnick/tonutils-go/ton"
|
|
||||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
|
||||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
func upsertTestChainToken(t *testing.T, token mdb.ChainToken) {
|
|
||||||
t.Helper()
|
|
||||||
if err := dao.Mdb.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"contract_address",
|
|
||||||
"decimals",
|
|
||||||
"enabled",
|
|
||||||
"min_amount",
|
|
||||||
}),
|
|
||||||
}).Create(&token).Error; err != nil {
|
|
||||||
t.Fatalf("upsert token: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
|
||||||
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
|
||||||
hash := strings.Repeat("a", 64)
|
|
||||||
|
|
||||||
ref, err := parseManualTonTxRef("ton:" + receiveRaw + ":123:" + strings.ToUpper(hash))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse canonical TON tx ref: %v", err)
|
|
||||||
}
|
|
||||||
if ref.ReceiveRaw != receiveRaw || ref.LT != 123 || ref.HashHex != hash || !ref.HasLT {
|
|
||||||
t.Fatalf("canonical ref = %#v", ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
ref, err = parseManualTonTxRef("456:0X" + strings.ToUpper(hash))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse lt:hash TON tx ref: %v", err)
|
|
||||||
}
|
|
||||||
if ref.ReceiveRaw != "" || ref.LT != 456 || ref.HashHex != hash || !ref.HasLT {
|
|
||||||
t.Fatalf("lt:hash ref = %#v", ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
ref, err = parseManualTonTxRef("0X" + strings.ToUpper(hash))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse hash-only TON tx ref: %v", err)
|
|
||||||
}
|
|
||||||
if ref.ReceiveRaw != "" || ref.LT != 0 || ref.HashHex != hash || ref.HasLT {
|
|
||||||
t.Fatalf("hash-only ref = %#v", ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeManualTonAPI struct {
|
|
||||||
ton.APIClientWrapped
|
|
||||||
master *ton.BlockIDExt
|
|
||||||
account *tlb.Account
|
|
||||||
txs []*tlb.Transaction
|
|
||||||
getAccountCalls int
|
|
||||||
listCalls []manualTonListCall
|
|
||||||
}
|
|
||||||
|
|
||||||
type manualTonListCall struct {
|
|
||||||
limit uint32
|
|
||||||
lt uint64
|
|
||||||
hashHex string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManualTonAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
|
||||||
return f.master, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManualTonAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManualTonAPI) GetAccount(context.Context, *ton.BlockIDExt, *address.Address) (*tlb.Account, error) {
|
|
||||||
f.getAccountCalls++
|
|
||||||
if f.account == nil {
|
|
||||||
return &tlb.Account{}, nil
|
|
||||||
}
|
|
||||||
return f.account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManualTonAPI) ListTransactions(_ context.Context, _ *address.Address, limit uint32, lt uint64, txHash []byte) ([]*tlb.Transaction, error) {
|
|
||||||
f.listCalls = append(f.listCalls, manualTonListCall{
|
|
||||||
limit: limit,
|
|
||||||
lt: lt,
|
|
||||||
hashHex: hex.EncodeToString(txHash),
|
|
||||||
})
|
|
||||||
return f.txs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateManualTonPaymentWithAPINativeTON(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
|
||||||
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
|
||||||
Update("enabled", false).Error; err != nil {
|
|
||||||
t.Fatalf("disable TON USDT token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
|
||||||
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
|
||||||
order := &mdb.Orders{
|
|
||||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
Token: "TON",
|
|
||||||
ActualAmount: 1.23,
|
|
||||||
ReceiveAddress: receive.Bounce(false).String(),
|
|
||||||
}
|
|
||||||
api := &fakeManualTonAPI{
|
|
||||||
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
|
||||||
txs: []*tlb.Transaction{tx},
|
|
||||||
}
|
|
||||||
ref := manualTonTxRef{LT: tx.LT, HashHex: hex.EncodeToString(tx.Hash), HasLT: true}
|
|
||||||
|
|
||||||
got, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, ref)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("validateManualTonPaymentWithAPI(): %v", err)
|
|
||||||
}
|
|
||||||
if api.getAccountCalls != 0 {
|
|
||||||
t.Fatalf("exact TON ref fetched account state %d times, want 0", api.getAccountCalls)
|
|
||||||
}
|
|
||||||
if len(api.listCalls) != 1 {
|
|
||||||
t.Fatalf("ListTransactions calls = %#v, want one exact lookup", api.listCalls)
|
|
||||||
}
|
|
||||||
if call := api.listCalls[0]; call.limit != 1 || call.lt != tx.LT || call.hashHex != hex.EncodeToString(tx.Hash) {
|
|
||||||
t.Fatalf("ListTransactions exact call = %#v, want limit=1 lt/hash from ref", call)
|
|
||||||
}
|
|
||||||
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("canonical id = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateManualTonPaymentCandidatesUSDTJetton(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x68}, 32)).Bounce(false).Testnet(false)
|
|
||||||
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x69}, 32)).Bounce(false).Testnet(false)
|
|
||||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
|
||||||
QueryID: 7,
|
|
||||||
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
|
||||||
Sender: sender,
|
|
||||||
ForwardPayload: cell.BeginCell().EndCell(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build jetton notification body: %v", err)
|
|
||||||
}
|
|
||||||
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
|
||||||
order := &mdb.Orders{
|
|
||||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
Token: "USDT",
|
|
||||||
ActualAmount: 1,
|
|
||||||
ReceiveAddress: receive.Bounce(false).String(),
|
|
||||||
}
|
|
||||||
state := &manualTonState{
|
|
||||||
jettonWallets: map[string]mdb.ChainToken{
|
|
||||||
addressutil.TonRawAddressObjectKey(jettonWallet): {
|
|
||||||
BaseModel: mdb.BaseModel{ID: 2},
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
Symbol: "USDT",
|
|
||||||
Decimals: 6,
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := validateManualTonPaymentCandidates(order, receive, manualTonTxRef{
|
|
||||||
LT: tx.LT,
|
|
||||||
HashHex: hex.EncodeToString(tx.Hash),
|
|
||||||
HasLT: true,
|
|
||||||
}, []*tlb.Transaction{tx}, state)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("validateManualTonPaymentCandidates(): %v", err)
|
|
||||||
}
|
|
||||||
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("canonical id = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateManualTonPaymentWithAPIRejectsAmbiguousHashOnly(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
|
||||||
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
|
||||||
Update("enabled", false).Error; err != nil {
|
|
||||||
t.Fatalf("disable TON USDT token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x67}, 32)).Bounce(false).Testnet(false)
|
|
||||||
tx1 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
|
||||||
tx2 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
|
||||||
tx2.LT = tx1.LT + 1
|
|
||||||
order := &mdb.Orders{
|
|
||||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
Token: "TON",
|
|
||||||
ActualAmount: 1.23,
|
|
||||||
ReceiveAddress: receive.Bounce(false).String(),
|
|
||||||
}
|
|
||||||
api := &fakeManualTonAPI{
|
|
||||||
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
|
||||||
account: &tlb.Account{
|
|
||||||
LastTxLT: tx2.LT,
|
|
||||||
LastTxHash: tx2.Hash,
|
|
||||||
},
|
|
||||||
txs: []*tlb.Transaction{tx1, tx2},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, manualTonTxRef{
|
|
||||||
HashHex: hex.EncodeToString(tx1.Hash),
|
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "matched multiple") {
|
|
||||||
t.Fatalf("validate ambiguous hash-only error = %v, want multiple-match error", err)
|
|
||||||
}
|
|
||||||
if api.getAccountCalls != 1 {
|
|
||||||
t.Fatalf("hash-only TON ref fetched account state %d times, want 1", api.getAccountCalls)
|
|
||||||
}
|
|
||||||
if len(api.listCalls) != 1 {
|
|
||||||
t.Fatalf("ListTransactions calls = %#v, want one recent lookup", api.listCalls)
|
|
||||||
}
|
|
||||||
if call := api.listCalls[0]; call.limit != 100 || call.lt != tx2.LT || call.hashHex != hex.EncodeToString(tx2.Hash) {
|
|
||||||
t.Fatalf("ListTransactions recent call = %#v, want account last tx cursor", call)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
upsertTestChainToken(t, mdb.ChainToken{
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
Symbol: "USDT",
|
|
||||||
ContractAddress: contractAddress,
|
|
||||||
Decimals: 6,
|
|
||||||
Enabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
upsertTestChainToken(t, mdb.ChainToken{
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
Symbol: "USDT",
|
|
||||||
ContractAddress: contractAddress,
|
|
||||||
Decimals: 6,
|
|
||||||
Enabled: true,
|
|
||||||
})
|
|
||||||
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,14 +268,10 @@ func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
|||||||
TradeId: order.TradeId,
|
TradeId: order.TradeId,
|
||||||
BlockTransactionId: payload.OrderID,
|
BlockTransactionId: payload.OrderID,
|
||||||
})
|
})
|
||||||
processed := err == nil
|
|
||||||
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if processed {
|
|
||||||
sendPaymentNotification(order)
|
|
||||||
}
|
|
||||||
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
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 TestHandleOkPayNotifySettlesPlaceholderParent(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
const (
|
|
||||||
shopID = "okpay-shop-placeholder"
|
|
||||||
shopToken = "okpay-shop-placeholder-token"
|
|
||||||
parentTradeID = "okpay-placeholder-parent-001"
|
|
||||||
providerOrderID = "okpay-placeholder-provider-001"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
parent := &mdb.Orders{
|
|
||||||
TradeId: parentTradeID,
|
|
||||||
OrderId: "merchant-okpay-placeholder-parent-001",
|
|
||||||
Amount: 1,
|
|
||||||
Currency: "CNY",
|
|
||||||
ActualAmount: 0.15,
|
|
||||||
ReceiveAddress: "OKPAY",
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.PaymentProviderOkPay,
|
|
||||||
Status: mdb.StatusWaitPay,
|
|
||||||
IsSelected: true,
|
|
||||||
PaymentType: mdb.PaymentTypeGmpay,
|
|
||||||
PayProvider: mdb.PaymentProviderOkPay,
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(parent).Error; err != nil {
|
|
||||||
t.Fatalf("seed parent order: %v", err)
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(&mdb.ProviderOrder{
|
|
||||||
TradeId: parentTradeID,
|
|
||||||
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, parentTradeID, "0.15000000", "USDT")
|
|
||||||
if err := HandleOkPayNotify(form, "placeholder-okpay-form"); err != nil {
|
|
||||||
t.Fatalf("handle okpay notify: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadedParent, err := data.GetOrderInfoByTradeId(parentTradeID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload parent: %v", err)
|
|
||||||
}
|
|
||||||
if reloadedParent.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("parent status = %d, want %d", reloadedParent.Status, mdb.StatusPaySuccess)
|
|
||||||
}
|
|
||||||
if reloadedParent.PayBySubId != 0 {
|
|
||||||
t.Fatalf("parent pay_by_sub_id = %d, want 0", reloadedParent.PayBySubId)
|
|
||||||
}
|
|
||||||
if reloadedParent.Token != "USDT" || reloadedParent.Network != mdb.PaymentProviderOkPay || reloadedParent.ReceiveAddress != "OKPAY" || reloadedParent.ActualAmount != 0.15 {
|
|
||||||
t.Fatalf("okpay parent fields changed: token=%q network=%q address=%q actual=%v", reloadedParent.Token, reloadedParent.Network, reloadedParent.ReceiveAddress, reloadedParent.ActualAmount)
|
|
||||||
}
|
|
||||||
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parentTradeID, mdb.PaymentProviderOkPay)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload provider row: %v", err)
|
|
||||||
}
|
|
||||||
if providerRow.Status != mdb.ProviderOrderStatusPaid {
|
|
||||||
t.Fatalf("provider status = %q, want %q", providerRow.Status, mdb.ProviderOrderStatusPaid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
cryptorand "crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -14,11 +14,9 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
"github.com/GMWalletApp/epusdt/util/security"
|
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
@@ -47,73 +45,37 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
network = strings.ToLower(strings.TrimSpace(network))
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
case mdb.NetworkTon:
|
|
||||||
if normalized, err := addressutil.NormalizeTonAddress(address); err == nil {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
return address
|
|
||||||
default:
|
default:
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
|
||||||
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if tokenRow == nil || tokenRow.ID == 0 {
|
|
||||||
return nil, constant.SupportedAssetNotFound
|
|
||||||
}
|
|
||||||
return tokenRow, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCreateTransactionTokenNetwork(token, network string) error {
|
|
||||||
if (token == "") != (network == "") {
|
|
||||||
return constant.ParamsMarshalErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
|
|
||||||
return &response.CreateTransactionResponse{
|
|
||||||
TradeId: order.TradeId,
|
|
||||||
OrderId: order.OrderId,
|
|
||||||
Amount: order.Amount,
|
|
||||||
Currency: order.Currency,
|
|
||||||
ActualAmount: order.ActualAmount,
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: order.Token,
|
|
||||||
Status: order.Status,
|
|
||||||
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
|
|
||||||
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
||||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
|
||||||
if err := validateCreateTransactionTokenNetwork(token, network); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
|
||||||
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
|
||||||
return nil, constant.NotifyURLErr
|
|
||||||
}
|
|
||||||
paymentType := mdb.PaymentTypeGmpay
|
|
||||||
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
|
|
||||||
paymentType = mdb.PaymentTypeEpay
|
|
||||||
}
|
|
||||||
|
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
|
|
||||||
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||||
amountPrecision := data.GetAmountPrecision()
|
amountPrecision := data.GetAmountPrecision()
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -122,49 +84,9 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, constant.OrderAlreadyExists
|
return nil, constant.OrderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
|
||||||
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if token == "" && network == "" {
|
|
||||||
tradeID := GenerateCode()
|
|
||||||
order := &mdb.Orders{
|
|
||||||
TradeId: tradeID,
|
|
||||||
OrderId: req.OrderId,
|
|
||||||
Amount: payAmount,
|
|
||||||
Currency: currency,
|
|
||||||
Status: mdb.StatusWaitSelect,
|
|
||||||
NotifyUrl: notifyURL,
|
|
||||||
RedirectUrl: req.RedirectUrl,
|
|
||||||
Name: req.Name,
|
|
||||||
PaymentType: paymentType,
|
|
||||||
PayProvider: mdb.PaymentProviderOnChain,
|
|
||||||
ApiKeyID: apiKeyID(apiKey),
|
|
||||||
}
|
|
||||||
if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buildCreateTransactionResponse(order), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !data.IsChainEnabled(network) {
|
if !data.IsChainEnabled(network) {
|
||||||
return nil, constant.ChainNotEnabled
|
return nil, constant.ChainNotEnabled
|
||||||
}
|
}
|
||||||
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
|
||||||
if rate <= 0 {
|
|
||||||
return nil, constant.RateAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -194,10 +116,10 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
Token: token,
|
Token: token,
|
||||||
Network: network,
|
Network: network,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: notifyURL,
|
NotifyUrl: req.NotifyUrl,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
PaymentType: paymentType,
|
PaymentType: req.PaymentType,
|
||||||
PayProvider: mdb.PaymentProviderOnChain,
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
ApiKeyID: apiKeyID(apiKey),
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
}
|
}
|
||||||
@@ -212,7 +134,19 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCreateTransactionResponse(order), nil
|
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
|
||||||
|
resp := &response.CreateTransactionResponse{
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
OrderId: order.OrderId,
|
||||||
|
Amount: order.Amount,
|
||||||
|
Currency: order.Currency,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
ExpirationTime: expirationTime,
|
||||||
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
||||||
@@ -245,21 +179,15 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Load order to check parent-child relationship
|
// Load order to check parent-child relationship
|
||||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 {
|
|
||||||
if unlockErr := data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); unlockErr != nil {
|
|
||||||
log.Sugar.Warnf("[order] fallback unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, unlockErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||||
}
|
}
|
||||||
if hasTransactionLock(order) {
|
|
||||||
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
|
||||||
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parent order paid directly: expire all sub-orders and release their locks
|
// Parent order paid directly: expire all sub-orders and release their locks
|
||||||
if order.ParentTradeId == "" {
|
if order.ParentTradeId == "" {
|
||||||
@@ -277,10 +205,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasTransactionLock(&sub) {
|
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
||||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||||
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -331,10 +257,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Release parent's own wallet lock
|
// Release parent's own wallet lock
|
||||||
if hasTransactionLock(parent) {
|
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
||||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release sibling locks after their status transitions commit.
|
// Release sibling locks after their status transitions commit.
|
||||||
@@ -344,10 +268,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasTransactionLock(&sib) {
|
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,11 +316,9 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
|||||||
|
|
||||||
// GenerateCode creates a unique trade id.
|
// GenerateCode creates a unique trade id.
|
||||||
func GenerateCode() string {
|
func GenerateCode() string {
|
||||||
buf := make([]byte, 18)
|
date := time.Now().Format("20060102")
|
||||||
if _, err := cryptorand.Read(buf); err != nil {
|
r := rand.Intn(1000)
|
||||||
panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
|
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrderInfoByTradeId returns a validated order.
|
// GetOrderInfoByTradeId returns a validated order.
|
||||||
@@ -413,106 +333,9 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
|||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitManualPayment verifies a submitted transaction hash and marks the
|
const MaxSubOrders = 2
|
||||||
// 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)
|
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasTransactionLock(order *mdb.Orders) bool {
|
|
||||||
return order != nil &&
|
|
||||||
isOnChainOrder(order.PayProvider) &&
|
|
||||||
strings.TrimSpace(order.Network) != "" &&
|
|
||||||
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
|
||||||
strings.TrimSpace(order.Token) != "" &&
|
|
||||||
order.ActualAmount > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const MaxSubOrders = 1
|
|
||||||
|
|
||||||
// SwitchNetwork completes a status=4 placeholder in place, or creates/returns
|
|
||||||
// an existing sub-order for a different payment target once the parent is
|
|
||||||
// already concrete.
|
|
||||||
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
@@ -531,7 +354,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
if parent.ParentTradeId != "" {
|
if parent.ParentTradeId != "" {
|
||||||
return nil, constant.CannotSwitchSubOrder
|
return nil, constant.CannotSwitchSubOrder
|
||||||
}
|
}
|
||||||
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
|
if parent.Status != mdb.StatusWaitPay {
|
||||||
return nil, constant.OrderNotWaitPay
|
return nil, constant.OrderNotWaitPay
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,10 +362,6 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return switchToOkPay(parent, token)
|
return switchToOkPay(parent, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parent.Status == mdb.StatusWaitSelect {
|
|
||||||
return completeWaitSelectOrder(parent, token, network)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Same token+network as parent → mark selected and return
|
// 2. Same token+network as parent → mark selected and return
|
||||||
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
@@ -563,9 +382,8 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(existing), nil
|
return buildCheckoutResponse(existing), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Only one sub-order may ever be created under a parent. Existing
|
// 4. Check sub-order limit
|
||||||
// active sub-orders are reused above; any other target after that is blocked.
|
count, err := data.CountActiveSubOrders(parent.TradeId)
|
||||||
count, err := data.CountSubOrders(parent.TradeId)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -573,16 +391,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.SubOrderLimitExceeded
|
return nil, constant.SubOrderLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Validate the target chain and asset before any rate lookup so
|
// 5. Calculate amount for the new network
|
||||||
// disabled/unknown tokens return the stable unsupported-asset error.
|
|
||||||
if !data.IsChainEnabled(network) {
|
|
||||||
return nil, constant.ChainNotEnabled
|
|
||||||
}
|
|
||||||
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Calculate amount for the new network
|
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
return nil, constant.RateAmountErr
|
return nil, constant.RateAmountErr
|
||||||
@@ -593,7 +402,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.PayAmountErr
|
return nil, constant.PayAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Find and lock wallet
|
// 6. Find and lock wallet
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -612,7 +424,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.NotAvailableAmountErr
|
return nil, constant.NotAvailableAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Create sub-order
|
// 7. Create sub-order
|
||||||
tx := dao.Mdb.Begin()
|
tx := dao.Mdb.Begin()
|
||||||
subOrder := &mdb.Orders{
|
subOrder := &mdb.Orders{
|
||||||
TradeId: subTradeID,
|
TradeId: subTradeID,
|
||||||
@@ -652,63 +464,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(subOrder), nil
|
return buildCheckoutResponse(subOrder), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (*response.CheckoutCounterResponse, error) {
|
|
||||||
if !data.IsChainEnabled(network) {
|
|
||||||
return nil, constant.ChainNotEnabled
|
|
||||||
}
|
|
||||||
if _, err := ensureEnabledOrderAsset(network, token); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
||||||
if rate <= 0 {
|
|
||||||
return nil, constant.RateAmountErr
|
|
||||||
}
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(walletAddress) <= 0 {
|
|
||||||
return nil, constant.NotAvailableWalletAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
||||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(parent.TradeId, network, token, amount, walletAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if availableAddress == "" {
|
|
||||||
return nil, constant.NotAvailableAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := data.CompleteWaitSelectOrder(parent.TradeId, network, token, availableAddress, availableAmount)
|
|
||||||
if err != nil {
|
|
||||||
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !updated {
|
|
||||||
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
|
||||||
return nil, constant.OrderStatusConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buildCheckoutResponse(order), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
||||||
paymentType := mdb.PaymentTypeGmpay
|
|
||||||
if strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
|
|
||||||
paymentType = mdb.PaymentTypeEpay
|
|
||||||
}
|
|
||||||
return &response.CheckoutCounterResponse{
|
return &response.CheckoutCounterResponse{
|
||||||
TradeId: order.TradeId,
|
TradeId: order.TradeId,
|
||||||
Amount: order.Amount,
|
Amount: order.Amount,
|
||||||
@@ -717,93 +473,14 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
|
|||||||
Currency: order.Currency,
|
Currency: order.Currency,
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
Network: order.Network,
|
Network: order.Network,
|
||||||
Status: order.Status,
|
|
||||||
PaymentType: strings.ToLower(paymentType),
|
|
||||||
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: order.RedirectUrl,
|
RedirectUrl: order.RedirectUrl,
|
||||||
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||||
CreatedAt: order.CreatedAt.TimestampMilli(),
|
CreatedAt: order.CreatedAt.TimestampMilli(),
|
||||||
IsSelected: order.IsSelected,
|
IsSelected: order.IsSelected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeWaitSelectOkPayOrder(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
||||||
if rate <= 0 {
|
|
||||||
return nil, constant.RateAmountErr
|
|
||||||
}
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
||||||
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
|
||||||
if returnURL == "" {
|
|
||||||
returnURL = data.GetOkPayReturnURL()
|
|
||||||
}
|
|
||||||
if returnURL == "" {
|
|
||||||
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx := dao.Mdb.Begin()
|
|
||||||
providerRow := &mdb.ProviderOrder{
|
|
||||||
TradeId: parent.TradeId,
|
|
||||||
Provider: mdb.PaymentProviderOkPay,
|
|
||||||
ProviderOrderID: "",
|
|
||||||
PayURL: "",
|
|
||||||
Amount: amount,
|
|
||||||
Coin: token,
|
|
||||||
Status: mdb.ProviderOrderStatusCreating,
|
|
||||||
NotifyRaw: "",
|
|
||||||
}
|
|
||||||
if err := data.UpsertProviderOrderCreatingWithTransaction(tx, providerRow); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
okpayOrder, err := createOkPayDepositOrder(parent.TradeId, amount, token, returnURL)
|
|
||||||
if err != nil {
|
|
||||||
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
return nil, constant.PaymentProviderCreateErr
|
|
||||||
}
|
|
||||||
|
|
||||||
finalizeTx := dao.Mdb.Begin()
|
|
||||||
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, parent.TradeId, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
updated, err := data.CompleteWaitSelectOkPayOrderWithTransaction(finalizeTx, parent.TradeId, token, amount)
|
|
||||||
if err != nil {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !updated {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
return nil, constant.OrderStatusConflict
|
|
||||||
}
|
|
||||||
if err = finalizeTx.Commit().Error; err != nil {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp := buildCheckoutResponse(order)
|
|
||||||
resp.PaymentUrl = okpayOrder.PayURL
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
||||||
if !data.GetOkPayEnabled() {
|
if !data.GetOkPayEnabled() {
|
||||||
return nil, constant.PaymentProviderNotEnabled
|
return nil, constant.PaymentProviderNotEnabled
|
||||||
@@ -815,29 +492,6 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
return nil, constant.PaymentProviderNotSupport
|
return nil, constant.PaymentProviderNotSupport
|
||||||
}
|
}
|
||||||
|
|
||||||
if parent.Status == mdb.StatusWaitSelect {
|
|
||||||
return completeWaitSelectOkPayOrder(parent, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parent.PayProvider == mdb.PaymentProviderOkPay && strings.EqualFold(parent.Token, token) {
|
|
||||||
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
|
||||||
return nil, constant.SystemErr
|
|
||||||
}
|
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
|
||||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
||||||
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp := buildCheckoutResponse(order)
|
|
||||||
resp.PaymentUrl = providerRow.PayURL
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -850,21 +504,16 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
||||||
return nil, constant.SystemErr
|
return nil, constant.SystemErr
|
||||||
}
|
}
|
||||||
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
if markErr != nil {
|
|
||||||
return nil, markErr
|
|
||||||
}
|
|
||||||
if !updated {
|
|
||||||
return nil, constant.OrderStatusConflict
|
|
||||||
}
|
|
||||||
_ = data.MarkOrderSelected(existing.TradeId)
|
_ = data.MarkOrderSelected(existing.TradeId)
|
||||||
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||||
existing.IsSelected = true
|
existing.IsSelected = true
|
||||||
resp := buildCheckoutResponse(existing)
|
resp := buildCheckoutResponse(existing)
|
||||||
resp.PaymentUrl = providerRow.PayURL
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := data.CountSubOrders(parent.TradeId)
|
count, err := data.CountActiveSubOrders(parent.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -939,35 +588,16 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
return nil, constant.PaymentProviderCreateErr
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeTx := dao.Mdb.Begin()
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
updated, err := data.MarkProviderSwitchParentSelectedWithTransaction(finalizeTx, parent.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !updated {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
||||||
return nil, constant.OrderStatusConflict
|
|
||||||
}
|
|
||||||
if err = finalizeTx.Commit().Error; err != nil {
|
|
||||||
finalizeTx.Rollback()
|
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := buildCheckoutResponse(subOrder)
|
resp := buildCheckoutResponse(subOrder)
|
||||||
resp.PaymentUrl = okpayOrder.PayURL
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -19,7 +17,6 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||||
@@ -29,7 +26,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
|||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
Network: "tron",
|
Network: "tron",
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
NotifyUrl: "https://93.184.216.34/callback",
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,31 +36,6 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return f(req)
|
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) {
|
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -78,94 +50,6 @@ 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 TestCreateTransactionCreatesWaitSelectPlaceholderWithoutTokenNetwork(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_wait_select_1", 12.34)
|
|
||||||
req.Token = ""
|
|
||||||
req.Network = ""
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create placeholder transaction: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Status != mdb.StatusWaitSelect {
|
|
||||||
t.Fatalf("response status = %d, want %d", resp.Status, mdb.StatusWaitSelect)
|
|
||||||
}
|
|
||||||
if resp.Token != "" || resp.ReceiveAddress != "" || resp.ActualAmount != 0 {
|
|
||||||
t.Fatalf("placeholder chain fields = token %q address %q actual %.4f", resp.Token, resp.ReceiveAddress, resp.ActualAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload placeholder order: %v", err)
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusWaitSelect {
|
|
||||||
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusWaitSelect)
|
|
||||||
}
|
|
||||||
if order.PaymentType != mdb.PaymentTypeGmpay {
|
|
||||||
t.Fatalf("placeholder payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
|
|
||||||
}
|
|
||||||
if order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
|
|
||||||
t.Fatalf("placeholder order has chain fields: %+v", order)
|
|
||||||
}
|
|
||||||
|
|
||||||
var locks int64
|
|
||||||
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).Where("trade_id = ?", resp.TradeId).Count(&locks).Error; err != nil {
|
|
||||||
t.Fatalf("count transaction locks: %v", err)
|
|
||||||
}
|
|
||||||
if locks != 0 {
|
|
||||||
t.Fatalf("placeholder lock count = %d, want 0", locks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionRejectsPartialTokenNetwork(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_partial_token_network_1", 1)
|
|
||||||
req.Token = ""
|
|
||||||
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
|
|
||||||
t.Fatalf("missing token error = %v, want %v", err, constant.ParamsMarshalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = newCreateTransactionRequest("order_partial_token_network_2", 1)
|
|
||||||
req.Network = ""
|
|
||||||
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
|
|
||||||
t.Fatalf("missing network error = %v, want %v", err, constant.ParamsMarshalErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionNormalizesEpayPaymentTypeCase(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_epay_payment_type_1", 1)
|
|
||||||
req.Token = ""
|
|
||||||
req.Network = ""
|
|
||||||
req.PaymentType = "epay"
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create epay-compatible transaction: %v", err)
|
|
||||||
}
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if order.PaymentType != mdb.PaymentTypeEpay {
|
|
||||||
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -283,8 +167,8 @@ func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
|
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||||
t.Fatalf("set rate.forced_rate_list: %v", err)
|
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||||
}
|
}
|
||||||
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
||||||
t.Fatalf("set rate.api_url: %v", err)
|
t.Fatalf("set rate.api_url: %v", err)
|
||||||
@@ -306,8 +190,8 @@ func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositi
|
|||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
|
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||||
t.Fatalf("set rate.forced_rate_list: %v", err)
|
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||||
}
|
}
|
||||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||||
t.Fatalf("clear rate.api_url: %v", err)
|
t.Fatalf("clear rate.api_url: %v", err)
|
||||||
@@ -362,564 +246,6 @@ func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTransactionCreatesTonOrderAndRawLock(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_ton_native_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON transaction: %v", err)
|
|
||||||
}
|
|
||||||
if resp.ReceiveAddress != wallet.Address {
|
|
||||||
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, wallet.Address)
|
|
||||||
}
|
|
||||||
if resp.Token != "TON" {
|
|
||||||
t.Fatalf("token = %q, want TON", resp.Token)
|
|
||||||
}
|
|
||||||
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "5.00" {
|
|
||||||
t.Fatalf("actual amount = %s, want 5.00", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup ton runtime lock: %v", err)
|
|
||||||
}
|
|
||||||
if tradeID != resp.TradeId {
|
|
||||||
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionCreatesTonOrderWithConfiguredPrecision(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
|
||||||
t.Fatalf("set amount precision: %v", err)
|
|
||||||
}
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.12345}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_ton_precision_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON transaction: %v", err)
|
|
||||||
}
|
|
||||||
if got := fmt.Sprintf("%.4f", resp.ActualAmount); got != "1.2345" {
|
|
||||||
t.Fatalf("actual amount = %s, want 1.2345", got)
|
|
||||||
}
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.Bounce(true).String(), "TON", 1.2345)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup ton runtime lock: %v", err)
|
|
||||||
}
|
|
||||||
if tradeID != resp.TradeId {
|
|
||||||
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionRejectsUnsupportedTonToken(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_ton_gram_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "GRAM"
|
|
||||||
|
|
||||||
_, err := CreateTransaction(req, nil)
|
|
||||||
if err != constant.SupportedAssetNotFound {
|
|
||||||
t.Fatalf("create unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionRejectsDisabledTonTokenBeforeRate(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
|
||||||
Where("network = ? AND symbol = ?", mdb.NetworkTon, "TON").
|
|
||||||
Update("enabled", false).Error; err != nil {
|
|
||||||
t.Fatalf("disable TON token: %v", err)
|
|
||||||
}
|
|
||||||
req := newCreateTransactionRequest("order_ton_disabled_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
|
|
||||||
_, err := CreateTransaction(req, nil)
|
|
||||||
if err != constant.SupportedAssetNotFound {
|
|
||||||
t.Fatalf("create disabled TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
|
||||||
t.Fatalf("clear rate api url: %v", err)
|
|
||||||
}
|
|
||||||
req := newCreateTransactionRequest("order_ton_missing_rate_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
|
|
||||||
_, err := CreateTransaction(req, nil)
|
|
||||||
if err != constant.RateAmountErr {
|
|
||||||
t.Fatalf("create TON without rate error = %v, want %v", err, constant.RateAmountErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
|
||||||
t.Fatalf("add tron wallet: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentReq := newCreateTransactionRequest("order_switch_ton_1", 10)
|
|
||||||
parentReq.Network = mdb.NetworkTron
|
|
||||||
parentResp, err := CreateTransaction(parentReq, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create parent transaction: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "TON",
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("switch to TON: %v", err)
|
|
||||||
}
|
|
||||||
if subResp.Network != mdb.NetworkTon || subResp.Token != "TON" {
|
|
||||||
t.Fatalf("sub order network/token = %s/%s, want ton/TON", subResp.Network, subResp.Token)
|
|
||||||
}
|
|
||||||
if subResp.ReceiveAddress != wallet.Address {
|
|
||||||
t.Fatalf("sub order receive address = %q, want %q", subResp.ReceiveAddress, wallet.Address)
|
|
||||||
}
|
|
||||||
if got := fmt.Sprintf("%.2f", subResp.ActualAmount); got != "5.00" {
|
|
||||||
t.Fatalf("sub order actual amount = %s, want 5.00", got)
|
|
||||||
}
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup TON sub-order lock: %v", err)
|
|
||||||
}
|
|
||||||
if tradeID != subResp.TradeId {
|
|
||||||
t.Fatalf("TON sub-order lock trade_id = %q, want %q", tradeID, subResp.TradeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSwitchNetworkCompletesWaitSelectPlaceholderInPlace(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddress("TWaitSelectAddress001"); err != nil {
|
|
||||||
t.Fatalf("add tron wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_wait_select_switch_1", 10)
|
|
||||||
req.Token = ""
|
|
||||||
req.Network = ""
|
|
||||||
parentResp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create placeholder: %v", err)
|
|
||||||
}
|
|
||||||
if parentResp.Status != mdb.StatusWaitSelect {
|
|
||||||
t.Fatalf("parent response status = %d, want %d", parentResp.Status, mdb.StatusWaitSelect)
|
|
||||||
}
|
|
||||||
|
|
||||||
switched, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("switch placeholder to tron/usdt: %v", err)
|
|
||||||
}
|
|
||||||
if switched.TradeId != parentResp.TradeId {
|
|
||||||
t.Fatalf("switch returned trade_id = %q, want parent %q", switched.TradeId, parentResp.TradeId)
|
|
||||||
}
|
|
||||||
if switched.Status != mdb.StatusWaitPay {
|
|
||||||
t.Fatalf("switch status = %d, want %d", switched.Status, mdb.StatusWaitPay)
|
|
||||||
}
|
|
||||||
if switched.IsSelected {
|
|
||||||
t.Fatal("switch is_selected = true, want false")
|
|
||||||
}
|
|
||||||
if switched.PaymentUrl != "" {
|
|
||||||
t.Fatalf("switch payment_url = %q, want empty for unselected chain parent", switched.PaymentUrl)
|
|
||||||
}
|
|
||||||
if switched.Token != "USDT" || switched.Network != mdb.NetworkTron || switched.ReceiveAddress != "TWaitSelectAddress001" {
|
|
||||||
t.Fatalf("switch chain fields = token %q network %q address %q", switched.Token, switched.Network, switched.ReceiveAddress)
|
|
||||||
}
|
|
||||||
if got := fmt.Sprintf("%.2f", switched.ActualAmount); got != "1.00" {
|
|
||||||
t.Fatalf("switch actual amount = %s, want 1.00", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload parent: %v", err)
|
|
||||||
}
|
|
||||||
if parent.Status != mdb.StatusWaitPay || parent.ParentTradeId != "" {
|
|
||||||
t.Fatalf("parent status/parent_trade_id = %d/%q", parent.Status, parent.ParentTradeId)
|
|
||||||
}
|
|
||||||
if parent.IsSelected {
|
|
||||||
t.Fatal("parent is_selected = true, want false")
|
|
||||||
}
|
|
||||||
if parent.PaymentType != mdb.PaymentTypeGmpay {
|
|
||||||
t.Fatalf("parent payment_type = %q, want %q", parent.PaymentType, mdb.PaymentTypeGmpay)
|
|
||||||
}
|
|
||||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count sub orders: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("active sub-order count = %d, want 0", count)
|
|
||||||
}
|
|
||||||
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parent.ReceiveAddress, parent.Token, parent.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup parent lock: %v", err)
|
|
||||||
}
|
|
||||||
if lockTradeID != parent.TradeId {
|
|
||||||
t.Fatalf("parent lock trade_id = %q, want %q", lockTradeID, parent.TradeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSwitchNetworkAfterWaitSelectCompletionUsesExistingSubOrderFlow(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddress("TWaitSelectAddress002"); err != nil {
|
|
||||||
t.Fatalf("add tron wallet: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455668"); err != nil {
|
|
||||||
t.Fatalf("add ethereum wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := newCreateTransactionRequest("order_wait_select_reswitch_1", 10)
|
|
||||||
req.Token = ""
|
|
||||||
req.Network = ""
|
|
||||||
parentResp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create placeholder: %v", err)
|
|
||||||
}
|
|
||||||
first, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first switch: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
second, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkEthereum,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second switch: %v", err)
|
|
||||||
}
|
|
||||||
if second.TradeId == first.TradeId {
|
|
||||||
t.Fatalf("second switch trade_id = parent %q, want sub-order", second.TradeId)
|
|
||||||
}
|
|
||||||
if second.Network != mdb.NetworkEthereum || second.Token != "USDT" {
|
|
||||||
t.Fatalf("second switch token/network = %s/%s", second.Token, second.Network)
|
|
||||||
}
|
|
||||||
count, err := data.CountActiveSubOrders(parentResp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count active sub orders: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("active sub-order count = %d, want 1", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkBsc,
|
|
||||||
})
|
|
||||||
if err != constant.SubOrderLimitExceeded {
|
|
||||||
t.Fatalf("third switch error = %v, want %v", err, constant.SubOrderLimitExceeded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddress("TTestTronAddress002"); err != nil {
|
|
||||||
t.Fatalf("add tron wallet: %v", err)
|
|
||||||
}
|
|
||||||
parentResp, err := CreateTransaction(newCreateTransactionRequest("order_switch_gram_1", 10), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create parent transaction: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "GRAM",
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
})
|
|
||||||
if err != constant.SupportedAssetNotFound {
|
|
||||||
t.Fatalf("switch unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryProcessTonTransferMarksOrderPaidAndReleasesRawLock(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
req := newCreateTransactionRequest("order_ton_process_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON transaction: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transfer := &TonObservedTransfer{
|
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
|
||||||
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
|
||||||
RawAmount: big.NewInt(5_000_000_000),
|
|
||||||
Amount: resp.ActualAmount,
|
|
||||||
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
|
||||||
LT: 100,
|
|
||||||
TxHashHex: strings.Repeat("1", 64),
|
|
||||||
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 100, strings.Repeat("1", 64)),
|
|
||||||
}
|
|
||||||
TryProcessTonTransfer(transfer)
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
|
||||||
}
|
|
||||||
if order.BlockTransactionId != transfer.BlockID {
|
|
||||||
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID)
|
|
||||||
}
|
|
||||||
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup ton lock: %v", err)
|
|
||||||
}
|
|
||||||
if lock != "" {
|
|
||||||
t.Fatalf("TON lock still exists after payment: %s", lock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryProcessTonJettonTransferMarksUSDTOrderPaid(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
req := newCreateTransactionRequest("order_ton_usdt_process_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "USDT"
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON USDT transaction: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transfer := &TonObservedTransfer{
|
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
|
||||||
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true},
|
|
||||||
RawAmount: big.NewInt(1_000_000),
|
|
||||||
Amount: resp.ActualAmount,
|
|
||||||
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
|
||||||
LT: 110,
|
|
||||||
TxHashHex: strings.Repeat("4", 64),
|
|
||||||
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 110, strings.Repeat("4", 64)),
|
|
||||||
}
|
|
||||||
TryProcessTonTransfer(transfer)
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
|
||||||
}
|
|
||||||
if order.BlockTransactionId != transfer.BlockID {
|
|
||||||
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID)
|
|
||||||
}
|
|
||||||
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "USDT", resp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup TON USDT lock: %v", err)
|
|
||||||
}
|
|
||||||
if lock != "" {
|
|
||||||
t.Fatalf("TON USDT lock still exists after payment: %s", lock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryProcessTonTransferSkipsTransfersBeforeOrderCreation(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
req := newCreateTransactionRequest("order_ton_old_transfer_1", 10)
|
|
||||||
req.Network = mdb.NetworkTon
|
|
||||||
req.Token = "TON"
|
|
||||||
resp, err := CreateTransaction(req, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON transaction: %v", err)
|
|
||||||
}
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load order: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
TryProcessTonTransfer(&TonObservedTransfer{
|
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
|
||||||
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
|
||||||
RawAmount: big.NewInt(5_000_000_000),
|
|
||||||
Amount: resp.ActualAmount,
|
|
||||||
BlockTimeMs: order.CreatedAt.TimestampMilli() - 1,
|
|
||||||
LT: 101,
|
|
||||||
TxHashHex: strings.Repeat("2", 64),
|
|
||||||
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 101, strings.Repeat("2", 64)),
|
|
||||||
})
|
|
||||||
|
|
||||||
order, err = data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusWaitPay {
|
|
||||||
t.Fatalf("order status = %d, want wait-pay", order.Status)
|
|
||||||
}
|
|
||||||
if order.BlockTransactionId != "" {
|
|
||||||
t.Fatalf("old transfer set block transaction id = %q", order.BlockTransactionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryProcessTonSubOrderPaysParentAndReleasesLocks(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := data.AddWalletAddress("TTestTronAddressForTonSub"); err != nil {
|
|
||||||
t.Fatalf("add tron wallet: %v", err)
|
|
||||||
}
|
|
||||||
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentReq := newCreateTransactionRequest("order_ton_sub_parent_1", 10)
|
|
||||||
parentReq.Network = mdb.NetworkTron
|
|
||||||
parentResp, err := CreateTransaction(parentReq, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create parent order: %v", err)
|
|
||||||
}
|
|
||||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
|
||||||
TradeId: parentResp.TradeId,
|
|
||||||
Token: "TON",
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("switch to TON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
TryProcessTonTransfer(&TonObservedTransfer{
|
|
||||||
ReceiveAddress: subResp.ReceiveAddress,
|
|
||||||
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
|
||||||
RawAmount: big.NewInt(5_000_000_000),
|
|
||||||
Amount: subResp.ActualAmount,
|
|
||||||
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
|
||||||
LT: 102,
|
|
||||||
TxHashHex: strings.Repeat("3", 64),
|
|
||||||
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 102, strings.Repeat("3", 64)),
|
|
||||||
})
|
|
||||||
|
|
||||||
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload parent order: %v", err)
|
|
||||||
}
|
|
||||||
if parent.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("parent status = %d, want paid", parent.Status)
|
|
||||||
}
|
|
||||||
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload sub order: %v", err)
|
|
||||||
}
|
|
||||||
if sub.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("sub status = %d, want paid", sub.Status)
|
|
||||||
}
|
|
||||||
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup parent lock: %v", err)
|
|
||||||
}
|
|
||||||
if parentLock != "" {
|
|
||||||
t.Fatalf("parent lock still exists: %s", parentLock)
|
|
||||||
}
|
|
||||||
subLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup sub lock: %v", err)
|
|
||||||
}
|
|
||||||
if subLock != "" {
|
|
||||||
t.Fatalf("TON sub-order lock still exists: %s", subLock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -1438,6 +764,7 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
t.Fatalf("create parent order: %v", err)
|
t.Fatalf("create parent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create two sub-orders on different networks.
|
||||||
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
TradeId: parentResp.TradeId,
|
TradeId: parentResp.TradeId,
|
||||||
Token: "usdt",
|
Token: "usdt",
|
||||||
@@ -1447,29 +774,13 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
t.Fatalf("switch to ethereum sub-order: %v", err)
|
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bscTradeID := GenerateCode()
|
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
bscAddress := strings.ToLower("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
TradeId: parentResp.TradeId,
|
||||||
subBsc := &mdb.Orders{
|
Token: "usdt",
|
||||||
TradeId: bscTradeID,
|
Network: mdb.NetworkBsc,
|
||||||
OrderId: bscTradeID,
|
})
|
||||||
ParentTradeId: parentResp.TradeId,
|
if err != nil {
|
||||||
Amount: parentResp.Amount,
|
t.Fatalf("switch to bsc sub-order: %v", err)
|
||||||
Currency: parentResp.Currency,
|
|
||||||
ActualAmount: subEthResp.ActualAmount,
|
|
||||||
ReceiveAddress: bscAddress,
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkBsc,
|
|
||||||
Status: mdb.StatusWaitPay,
|
|
||||||
IsSelected: true,
|
|
||||||
CallBackConfirm: mdb.CallBackConfirmOk,
|
|
||||||
PaymentType: mdb.PaymentTypeGmpay,
|
|
||||||
PayProvider: mdb.PaymentProviderOnChain,
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(subBsc).Error; err != nil {
|
|
||||||
t.Fatalf("seed bsc sibling sub-order: %v", err)
|
|
||||||
}
|
|
||||||
if err := data.LockTransaction(mdb.NetworkBsc, bscAddress, "USDT", bscTradeID, subBsc.ActualAmount, time.Hour); err != nil {
|
|
||||||
t.Fatalf("lock bsc sibling amount: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pay the Ethereum sub-order.
|
// Pay the Ethereum sub-order.
|
||||||
@@ -1486,12 +797,12 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BSC sibling must be expired.
|
// BSC sibling must be expired.
|
||||||
reloadedBsc, err := data.GetOrderInfoByTradeId(subBsc.TradeId)
|
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload bsc sub-order: %v", err)
|
t.Fatalf("reload bsc sub-order: %v", err)
|
||||||
}
|
}
|
||||||
if reloadedBsc.Status != mdb.StatusExpired {
|
if subBsc.Status != mdb.StatusExpired {
|
||||||
t.Fatalf("bsc sibling status = %d, want %d (expired)", reloadedBsc.Status, mdb.StatusExpired)
|
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent runtime lock must be released.
|
// Parent runtime lock must be released.
|
||||||
@@ -1506,7 +817,7 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
|
|
||||||
// BSC sibling runtime lock must be released.
|
// BSC sibling runtime lock must be released.
|
||||||
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||||
mdb.NetworkBsc, subBsc.ReceiveAddress, subBsc.Token, subBsc.ActualAmount)
|
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"errors"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrOrder is returned when checkout initialization cannot find the trade id.
|
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
||||||
var ErrOrder = constant.OrderNotExists
|
|
||||||
|
|
||||||
// GetCheckoutCounterByTradeId returns checkout initialization data for an existing order.
|
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
|
||||||
// It does not decide the payment state; callers should use CheckStatus for that.
|
|
||||||
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
||||||
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if orderInfo.ID <= 0 {
|
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
|
||||||
return nil, ErrOrder
|
return nil, ErrOrder
|
||||||
}
|
}
|
||||||
paymentType := mdb.PaymentTypeGmpay
|
|
||||||
if strings.EqualFold(orderInfo.PaymentType, mdb.PaymentTypeEpay) {
|
|
||||||
paymentType = mdb.PaymentTypeEpay
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &response.CheckoutCounterResponse{
|
resp := &response.CheckoutCounterResponse{
|
||||||
TradeId: orderInfo.TradeId,
|
TradeId: orderInfo.TradeId,
|
||||||
@@ -36,21 +29,10 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
|||||||
Currency: orderInfo.Currency,
|
Currency: orderInfo.Currency,
|
||||||
ReceiveAddress: orderInfo.ReceiveAddress,
|
ReceiveAddress: orderInfo.ReceiveAddress,
|
||||||
Network: orderInfo.Network,
|
Network: orderInfo.Network,
|
||||||
Status: orderInfo.Status,
|
|
||||||
PaymentType: strings.ToLower(paymentType),
|
|
||||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: orderInfo.RedirectUrl,
|
RedirectUrl: orderInfo.RedirectUrl,
|
||||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||||
IsSelected: orderInfo.IsSelected,
|
IsSelected: orderInfo.IsSelected,
|
||||||
}
|
}
|
||||||
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
|
|
||||||
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
|
|
||||||
if rowErr != nil {
|
|
||||||
return nil, rowErr
|
|
||||||
}
|
|
||||||
if providerRow.ID > 0 {
|
|
||||||
resp.PaymentUrl = providerRow.PayURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-157
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -27,14 +26,6 @@ import (
|
|||||||
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
||||||
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||||
|
|
||||||
var processSolanaOrder = OrderProcessing
|
|
||||||
|
|
||||||
var (
|
|
||||||
solRPCRetryCount = 5
|
|
||||||
solRPCRetryWait = 2 * time.Second
|
|
||||||
solRPCRetryMaxWait = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
type TransferInfo struct {
|
type TransferInfo struct {
|
||||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||||
@@ -159,7 +150,6 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
// Process each transaction signature
|
// Process each transaction signature
|
||||||
for sigIdx, txSig := range result {
|
for sigIdx, txSig := range result {
|
||||||
sig := txSig.Signature
|
sig := txSig.Signature
|
||||||
retrySignature := false
|
|
||||||
|
|
||||||
// Skip failed transactions
|
// Skip failed transactions
|
||||||
if txSig.Err != nil {
|
if txSig.Err != nil {
|
||||||
@@ -223,7 +213,6 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
||||||
retrySignature = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tradeID == "" {
|
if tradeID == "" {
|
||||||
@@ -237,7 +226,6 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||||
retrySignature = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
||||||
@@ -264,14 +252,13 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||||
address, tradeID, sig, token, amount)
|
address, tradeID, sig, token, amount)
|
||||||
err = processSolanaOrder(req)
|
err = OrderProcessing(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||||
retrySignature = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,11 +268,6 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
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())
|
gProcessedSignatures.Store(sig, time.Now().Unix())
|
||||||
}
|
}
|
||||||
@@ -300,82 +282,26 @@ type solSignatureResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveSolanaRpcURL() (string, error) {
|
func resolveSolanaRpcURL() (string, error) {
|
||||||
node, err := resolveSolanaRpcNode()
|
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
if node == nil || node.ID == 0 {
|
||||||
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(node.Url)
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
}
|
}
|
||||||
node.Url = rpcURL
|
return rpcURL, nil
|
||||||
return node, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||||
func SolRetryClient(method string, params []interface{}) (body []byte, err error) {
|
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkSolana)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkSolana)
|
|
||||||
}()
|
|
||||||
|
|
||||||
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 := resty.New()
|
||||||
client.SetRetryCount(solRPCRetryCount)
|
client.SetRetryCount(5)
|
||||||
client.SetRetryWaitTime(solRPCRetryWait)
|
client.SetRetryWaitTime(2 * time.Second)
|
||||||
client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
|
client.SetRetryMaxWaitTime(10 * time.Second)
|
||||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
@@ -386,35 +312,25 @@ func solRetryClientWithURLHeaders(rpcUrl string, method string, params []interfa
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
req := client.R().SetHeader("Content-Type", "application/json")
|
rpcUrl, err := resolveSolanaRpcURL()
|
||||||
for key, values := range headers {
|
if err != nil {
|
||||||
for _, value := range values {
|
return nil, err
|
||||||
req.SetHeader(key, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := req.SetBody(map[string]interface{}{
|
resp, err := client.R().
|
||||||
"jsonrpc": "2.0",
|
SetHeader("Content-Type", "application/json").
|
||||||
"id": 1,
|
SetBody(map[string]interface{}{
|
||||||
"method": method,
|
"jsonrpc": "2.0",
|
||||||
"params": params,
|
"id": 1,
|
||||||
}).
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}).
|
||||||
Post(rpcUrl)
|
Post(rpcUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode() >= http.StatusBadRequest {
|
|
||||||
return nil, fmt.Errorf("solana rpc HTTP %d", resp.StatusCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
respBody := resp.Body()
|
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
|
return respBody, nil
|
||||||
}
|
}
|
||||||
@@ -451,32 +367,6 @@ func SolGetSignaturesForAddress(address string, limit int, untilSig string, befo
|
|||||||
return bodyData, nil
|
return bodyData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SolGetBlockHeight() (height int64, err error) {
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkSolana)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkSolana)
|
|
||||||
}()
|
|
||||||
|
|
||||||
rpcURL, err := resolveSolanaRpcURL()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
bodyData, err := solRetryClientWithURL(rpcURL, "getBlockHeight", []interface{}{
|
|
||||||
map[string]interface{}{"commitment": "finalized"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
height = gjson.GetBytes(bodyData, "result").Int()
|
|
||||||
if height <= 0 {
|
|
||||||
return 0, fmt.Errorf("unexpected getBlockHeight response: %s", string(bodyData))
|
|
||||||
}
|
|
||||||
return height, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SolGetTransaction(sig string) ([]byte, error) {
|
func SolGetTransaction(sig string) ([]byte, error) {
|
||||||
txData, err := SolRetryClient("getTransaction", []interface{}{
|
txData, err := SolRetryClient("getTransaction", []interface{}{
|
||||||
sig,
|
sig,
|
||||||
@@ -500,33 +390,6 @@ func SolGetTransaction(sig string) ([]byte, error) {
|
|||||||
return txData, nil
|
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 判断转账目标是否为指定钱包地址
|
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||||
// Native SOL transfer - check destination directly
|
// Native SOL transfer - check destination directly
|
||||||
|
|||||||
@@ -2,19 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +21,6 @@ func setupSolanaRPCNode(t *testing.T, url string) func() {
|
|||||||
Type: mdb.RpcNodeTypeHttp,
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
Weight: 1,
|
Weight: 1,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Purpose: mdb.RpcNodePurposeGeneral,
|
|
||||||
Status: mdb.RpcNodeStatusOk,
|
Status: mdb.RpcNodeStatusOk,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
@@ -60,443 +52,6 @@ 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 TestSolGetBlockHeightDoesNotAffectRpcNodeFailover(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
data.ResetRpcFailoverForTest()
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
oldRetryCount := solRPCRetryCount
|
|
||||||
solRPCRetryCount = 0
|
|
||||||
t.Cleanup(func() {
|
|
||||||
solRPCRetryCount = oldRetryCount
|
|
||||||
})
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "temporary", http.StatusBadGateway)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
node := &mdb.RpcNode{
|
|
||||||
Network: mdb.NetworkSolana,
|
|
||||||
Url: server.URL,
|
|
||||||
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 solana rpc_node: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < data.RpcFailoverThreshold+1; i++ {
|
|
||||||
if _, err := SolGetBlockHeight(); err == nil {
|
|
||||||
t.Fatalf("SolGetBlockHeight attempt %d unexpectedly succeeded", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.IsRpcNodeCoolingDown(node.ID) {
|
|
||||||
t.Fatal("SolGetBlockHeight failures should not affect rpc_node failover cooldown")
|
|
||||||
}
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
if got := stats[mdb.NetworkSolana].FailureCount; got != int64(data.RpcFailoverThreshold+1) {
|
|
||||||
t.Fatalf("solana runtime failure_count = %d, want %d", got, data.RpcFailoverThreshold+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestSolClientHealthy(t *testing.T) {
|
||||||
requireSolanaIntegration(t)
|
requireSolanaIntegration(t)
|
||||||
|
|
||||||
@@ -523,13 +78,6 @@ func TestSolClientHealthy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearProcessedSignatures() {
|
|
||||||
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
|
||||||
gProcessedSignatures.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||||
requireSolanaIntegration(t)
|
requireSolanaIntegration(t)
|
||||||
|
|
||||||
|
|||||||
@@ -18,28 +18,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func resolveTronNode() (string, string, error) {
|
func resolveTronNode() (string, string, error) {
|
||||||
node, err := ResolveTronRpcNode()
|
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
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 {
|
if node == nil || node.ID == 0 {
|
||||||
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
}
|
}
|
||||||
node.Url = rpcURL
|
return rpcURL, node.ApiKey, nil
|
||||||
return node, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveTronNode() (string, string, error) {
|
func ResolveTronNode() (string, string, error) {
|
||||||
@@ -193,6 +183,8 @@ func evmChainLogLabel(chainNetwork string) string {
|
|||||||
return "POLYGON"
|
return "POLYGON"
|
||||||
case mdb.NetworkPlasma:
|
case mdb.NetworkPlasma:
|
||||||
return "PLASMA"
|
return "PLASMA"
|
||||||
|
case mdb.NetworkArc:
|
||||||
|
return "ARC-CHAIN"
|
||||||
default:
|
default:
|
||||||
return "EVM"
|
return "EVM"
|
||||||
}
|
}
|
||||||
@@ -215,9 +207,24 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tryProcessConfiguredEvmERC20Transfer(chainNetwork, *tokenConfig, toAddr, rawValue, txHash, blockTsMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Sugar.Errorf("[%s-WS] TryProcessConfiguredEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tryProcessConfiguredEvmERC20Transfer(chainNetwork, tokenConfig, toAddr, rawValue, txHash, blockTsMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
|
net := evmChainLogLabel(chainNetwork)
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
|
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
|
||||||
if tokenSym == "" {
|
if tokenSym == "" {
|
||||||
log.Sugar.Warnf("[%s-WS] skip contract %s with empty token symbol", net, contract.Hex())
|
log.Sugar.Warnf("[%s-WS] skip transfer with empty token symbol")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
walletAddr := strings.ToLower(toAddr.Hex())
|
walletAddr := strings.ToLower(toAddr.Hex())
|
||||||
@@ -350,6 +357,8 @@ func networkDisplay(n string) string {
|
|||||||
return "Polygon"
|
return "Polygon"
|
||||||
case mdb.NetworkPlasma:
|
case mdb.NetworkPlasma:
|
||||||
return "Plasma"
|
return "Plasma"
|
||||||
|
case mdb.NetworkArc:
|
||||||
|
return "Arc-Chain"
|
||||||
default:
|
default:
|
||||||
if n == "" {
|
if n == "" {
|
||||||
return "Tron"
|
return "Tron"
|
||||||
|
|||||||
@@ -12,24 +12,8 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func upsertTaskServiceTestChainToken(t *testing.T, token mdb.ChainToken) {
|
|
||||||
t.Helper()
|
|
||||||
if err := dao.Mdb.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"contract_address",
|
|
||||||
"decimals",
|
|
||||||
"enabled",
|
|
||||||
"min_amount",
|
|
||||||
}),
|
|
||||||
}).Create(&token).Error; err != nil {
|
|
||||||
t.Fatalf("seed chain token: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -160,66 +144,3 @@ func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
|
|||||||
t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID)
|
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")
|
|
||||||
|
|
||||||
upsertTaskServiceTestChainToken(t, mdb.ChainToken{
|
|
||||||
Network: mdb.NetworkEthereum,
|
|
||||||
Symbol: tokenSym,
|
|
||||||
ContractAddress: contract.Hex(),
|
|
||||||
Decimals: 6,
|
|
||||||
Enabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
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,49 +51,6 @@ 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.
|
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/xssnick/tonutils-go/liteclient"
|
|
||||||
"github.com/xssnick/tonutils-go/ton"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConnectTonLiteAPI builds a tonutils-go API client from a liteserver global
|
|
||||||
// config URL. Scanner and manual verification use this same connection setup.
|
|
||||||
func ConnectTonLiteAPI(ctx context.Context, configURL string, requestTimeout time.Duration, reconnectLimit int) (ton.APIClientWrapped, func(), error) {
|
|
||||||
configURL = strings.TrimSpace(configURL)
|
|
||||||
if configURL == "" {
|
|
||||||
return nil, func() {}, fmt.Errorf("ton lite config url is empty")
|
|
||||||
}
|
|
||||||
if reconnectLimit <= 0 {
|
|
||||||
reconnectLimit = 3
|
|
||||||
}
|
|
||||||
cfg, err := liteclient.GetConfigFromUrl(ctx, configURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, func() {}, err
|
|
||||||
}
|
|
||||||
pool := liteclient.NewConnectionPool()
|
|
||||||
pool.SetOnDisconnect(pool.DefaultReconnect(3*time.Second, reconnectLimit))
|
|
||||||
if err = pool.AddConnectionsFromConfig(ctx, cfg); err != nil {
|
|
||||||
pool.Stop()
|
|
||||||
return nil, func() {}, err
|
|
||||||
}
|
|
||||||
base := ton.NewAPIClient(pool, ton.ProofCheckPolicyFast)
|
|
||||||
base.SetTrustedBlockFromConfig(cfg)
|
|
||||||
api := base.WithRetry(2).WithTimeout(requestTimeout).WithLSInfoInErrors()
|
|
||||||
return api, pool.Stop, nil
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/tlb"
|
|
||||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
|
||||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TonNativeSymbol = "TON"
|
|
||||||
TonTransferNotificationOpcode = 0x7362d09c
|
|
||||||
)
|
|
||||||
|
|
||||||
type TonObservedTransfer struct {
|
|
||||||
ReceiveAddress string
|
|
||||||
Token mdb.ChainToken
|
|
||||||
RawAmount *big.Int
|
|
||||||
Amount float64
|
|
||||||
BlockTimeMs int64
|
|
||||||
LT uint64
|
|
||||||
TxHashHex string
|
|
||||||
BlockID string
|
|
||||||
SenderAddress string
|
|
||||||
JettonWallet string
|
|
||||||
}
|
|
||||||
|
|
||||||
func TonCanonicalBlockTransactionID(receiveRaw string, lt uint64, txHashHex string) string {
|
|
||||||
return fmt.Sprintf("ton:%s:%d:%s", strings.ToLower(strings.TrimSpace(receiveRaw)), lt, strings.ToLower(strings.TrimSpace(txHashHex)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseTonInboundTransfer(tx *tlb.Transaction, receive *address.Address, nativeToken *mdb.ChainToken, jettonWalletTokens map[string]mdb.ChainToken) (*TonObservedTransfer, error) {
|
|
||||||
if tx == nil || receive == nil || tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if isTonTransactionBounced(tx) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
in := tx.IO.In.AsInternal()
|
|
||||||
if in == nil || in.DstAddr == nil || !in.DstAddr.Equals(receive) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
receiveRaw := addressutil.TonRawAddressObjectKey(receive)
|
|
||||||
txHashHex := hex.EncodeToString(tx.Hash)
|
|
||||||
blockID := TonCanonicalBlockTransactionID(receiveRaw, tx.LT, txHashHex)
|
|
||||||
blockTimeMs := int64(tx.Now) * 1000
|
|
||||||
if in.SrcAddr != nil {
|
|
||||||
srcRaw := addressutil.TonRawAddressObjectKey(in.SrcAddr)
|
|
||||||
if token, ok := jettonWalletTokens[srcRaw]; ok {
|
|
||||||
transfer, err := parseTonJettonTransferNotification(in)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
amount := tonRawAmountToFloat(transfer.Amount.Nano(), token.Decimals)
|
|
||||||
if amount <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if token.MinAmount > 0 && amount < token.MinAmount {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &TonObservedTransfer{
|
|
||||||
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
|
||||||
Token: token,
|
|
||||||
RawAmount: transfer.Amount.Nano(),
|
|
||||||
Amount: amount,
|
|
||||||
BlockTimeMs: blockTimeMs,
|
|
||||||
LT: tx.LT,
|
|
||||||
TxHashHex: txHashHex,
|
|
||||||
BlockID: blockID,
|
|
||||||
SenderAddress: addressutil.NormalizeTonAddressObject(transfer.Sender),
|
|
||||||
JettonWallet: addressutil.NormalizeTonAddressObject(in.SrcAddr),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tonPayloadIsTransferNotification(in.Body) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if nativeToken == nil || nativeToken.ID == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
rawNano := in.Amount.Nano()
|
|
||||||
if rawNano == nil || rawNano.Sign() <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
amount := tonRawAmountToFloat(rawNano, nativeToken.Decimals)
|
|
||||||
if amount <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if nativeToken.MinAmount > 0 && amount < nativeToken.MinAmount {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &TonObservedTransfer{
|
|
||||||
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
|
||||||
Token: *nativeToken,
|
|
||||||
RawAmount: rawNano,
|
|
||||||
Amount: amount,
|
|
||||||
BlockTimeMs: blockTimeMs,
|
|
||||||
LT: tx.LT,
|
|
||||||
TxHashHex: txHashHex,
|
|
||||||
BlockID: blockID,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TryProcessTonTransfer(transfer *TonObservedTransfer) {
|
|
||||||
if transfer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(transfer.Token.Symbol))
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
log.Sugar.Errorf("[TON-%s][%s] TryProcessTonTransfer panic: %v", tokenSym, transfer.ReceiveAddress, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
receive := strings.TrimSpace(transfer.ReceiveAddress)
|
|
||||||
if tokenSym == "" || receive == "" || transfer.Amount <= 0 || transfer.BlockID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TON-%s][%s] observed transfer tx=%s lt=%d amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.LT, transfer.Amount)
|
|
||||||
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive, tokenSym, transfer.Amount)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] lock lookup: %v", tokenSym, receive, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tradeID == "" {
|
|
||||||
log.Sugar.Infof("[TON-%s][%s] skip unmatched transfer tx=%s amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.Amount)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] load order: %v", tokenSym, receive, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s network=%q", tokenSym, receive, tradeID, order.Network)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, receive, tradeID, order.Token)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !tonOrderAddressMatches(order.ReceiveAddress, receive) {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s receive address mismatch order=%s", tokenSym, receive, tradeID, order.ReceiveAddress)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if transfer.BlockTimeMs > 0 && transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
|
||||||
log.Sugar.Warnf("[TON-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, receive, transfer.BlockID, transfer.BlockTimeMs, order.CreatedAt.TimestampMilli())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: tokenSym,
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
TradeId: tradeID,
|
|
||||||
Amount: transfer.Amount,
|
|
||||||
BlockTransactionId: transfer.BlockID,
|
|
||||||
}
|
|
||||||
err = OrderProcessing(req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
|
||||||
log.Sugar.Infof("[TON-%s][%s] skip resolved transfer trade_id=%s tx=%s err=%v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Errorf("[TON-%s][%s] OrderProcessing trade_id=%s tx=%s: %v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
|
||||||
log.Sugar.Infof("[TON-%s][%s] payment processed trade_id=%s tx=%s", tokenSym, receive, tradeID, transfer.BlockID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonOrderAddressMatches(a, b string) bool {
|
|
||||||
ar, errA := addressutil.TonRawAddressKey(a)
|
|
||||||
br, errB := addressutil.TonRawAddressKey(b)
|
|
||||||
return errA == nil && errB == nil && ar == br
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonRawAmountToFloat(rawAmount *big.Int, decimals int) float64 {
|
|
||||||
if rawAmount == nil || rawAmount.Sign() <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if decimals < 0 {
|
|
||||||
decimals = 0
|
|
||||||
}
|
|
||||||
amount := decimal.NewFromBigInt(rawAmount, -int32(decimals))
|
|
||||||
return math.MustParsePrecFloat64(amount.InexactFloat64(), data.MaxAmountPrecision)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTonJettonTransferNotification(in *tlb.InternalMessage) (*jetton.TransferNotification, error) {
|
|
||||||
if in == nil || in.Body == nil {
|
|
||||||
return nil, fmt.Errorf("missing jetton transfer notification body")
|
|
||||||
}
|
|
||||||
var transfer jetton.TransferNotification
|
|
||||||
if err := tlb.LoadFromCell(&transfer, in.Body.BeginParse()); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &transfer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonPayloadIsTransferNotification(body *cell.Cell) bool {
|
|
||||||
if body == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
op, err := body.BeginParse().LoadUInt(32)
|
|
||||||
return err == nil && op == TonTransferNotificationOpcode
|
|
||||||
}
|
|
||||||
|
|
||||||
func isTonTransactionBounced(tx *tlb.Transaction) bool {
|
|
||||||
if tx == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if dsc, ok := tx.Description.(tlb.TransactionDescriptionOrdinary); ok && dsc.BouncePhase != nil {
|
|
||||||
if _, ok = dsc.BouncePhase.Phase.(tlb.BouncePhaseOk); ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func EnsureTonTransferMatchesOrder(order *mdb.Orders, transfer *TonObservedTransfer) error {
|
|
||||||
if order == nil || order.ID == 0 {
|
|
||||||
return fmt.Errorf("order not found")
|
|
||||||
}
|
|
||||||
if transfer == nil {
|
|
||||||
return fmt.Errorf("matching ton transfer to order address not found")
|
|
||||||
}
|
|
||||||
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
|
||||||
return fmt.Errorf("order network mismatch")
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(order.Token, transfer.Token.Symbol) {
|
|
||||||
return fmt.Errorf("transaction token mismatch")
|
|
||||||
}
|
|
||||||
if !tonOrderAddressMatches(order.ReceiveAddress, transfer.ReceiveAddress) {
|
|
||||||
return fmt.Errorf("transaction recipient mismatch")
|
|
||||||
}
|
|
||||||
if transfer.BlockTimeMs <= 0 || transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
|
||||||
return fmt.Errorf("transaction predates the order")
|
|
||||||
}
|
|
||||||
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Token.Decimals) {
|
|
||||||
return fmt.Errorf("transaction amount mismatch")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/tlb"
|
|
||||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
|
||||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
|
||||||
)
|
|
||||||
|
|
||||||
func tonTestInboundTx(t *testing.T, src, dst *address.Address, amount tlb.Coins, body *cell.Cell) *tlb.Transaction {
|
|
||||||
t.Helper()
|
|
||||||
return &tlb.Transaction{
|
|
||||||
LT: 123,
|
|
||||||
Now: uint32(time.Now().Unix()),
|
|
||||||
Hash: bytes.Repeat([]byte{0x11}, 32),
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: src,
|
|
||||||
DstAddr: dst,
|
|
||||||
Amount: amount,
|
|
||||||
Body: body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTonInboundTransferNativeTON(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x22}, 32)).Bounce(false).Testnet(false)
|
|
||||||
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
|
||||||
|
|
||||||
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), cell.BeginCell().EndCell())
|
|
||||||
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
|
||||||
}
|
|
||||||
if transfer == nil {
|
|
||||||
t.Fatal("expected native TON transfer")
|
|
||||||
}
|
|
||||||
if transfer.Token.Symbol != "TON" {
|
|
||||||
t.Fatalf("token = %q, want TON", transfer.Token.Symbol)
|
|
||||||
}
|
|
||||||
if transfer.Amount != 1.23 {
|
|
||||||
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
|
||||||
}
|
|
||||||
if transfer.ReceiveAddress != addressutil.NormalizeTonAddressObject(receive) {
|
|
||||||
t.Fatalf("receive address = %q", transfer.ReceiveAddress)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(transfer.BlockID, "ton:"+addressutil.TonRawAddressObjectKey(receive)+":123:") {
|
|
||||||
t.Fatalf("block id = %q, want canonical TON prefix", transfer.BlockID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTonInboundTransferJettonNotification(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
|
||||||
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
|
||||||
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
|
||||||
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
|
||||||
|
|
||||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
|
||||||
QueryID: 7,
|
|
||||||
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
|
||||||
Sender: sender,
|
|
||||||
ForwardPayload: cell.BeginCell().EndCell(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build jetton notification body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
|
||||||
transfer, err := ParseTonInboundTransfer(tx, receive, native, map[string]mdb.ChainToken{
|
|
||||||
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
|
||||||
}
|
|
||||||
if transfer == nil {
|
|
||||||
t.Fatal("expected USDT jetton transfer")
|
|
||||||
}
|
|
||||||
if transfer.Token.Symbol != "USDT" {
|
|
||||||
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
|
||||||
}
|
|
||||||
if transfer.Amount != 1.23 {
|
|
||||||
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
|
||||||
}
|
|
||||||
if transfer.SenderAddress != addressutil.NormalizeTonAddressObject(sender) {
|
|
||||||
t.Fatalf("sender address = %q", transfer.SenderAddress)
|
|
||||||
}
|
|
||||||
if transfer.JettonWallet != addressutil.NormalizeTonAddressObject(jettonWallet) {
|
|
||||||
t.Fatalf("jetton wallet = %q", transfer.JettonWallet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTonInboundTransferJettonNotificationWithNoGasComputeSkip(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
|
||||||
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
|
||||||
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
|
||||||
|
|
||||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
|
||||||
QueryID: 7,
|
|
||||||
Amount: tlb.MustFromNano(big.NewInt(150000), 6),
|
|
||||||
Sender: sender,
|
|
||||||
ForwardPayload: cell.BeginCell().EndCell(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build jetton notification body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
|
||||||
tx.Description = tlb.TransactionDescriptionOrdinary{
|
|
||||||
ComputePhase: tlb.ComputePhase{Phase: tlb.ComputePhaseSkipped{
|
|
||||||
Reason: tlb.ComputeSkipReason{Type: tlb.ComputeSkipReasonNoGas},
|
|
||||||
}},
|
|
||||||
Aborted: true,
|
|
||||||
}
|
|
||||||
transfer, err := ParseTonInboundTransfer(tx, receive, nil, map[string]mdb.ChainToken{
|
|
||||||
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
|
||||||
}
|
|
||||||
if transfer == nil {
|
|
||||||
t.Fatal("expected no-gas jetton notification to be accepted")
|
|
||||||
}
|
|
||||||
if transfer.Token.Symbol != "USDT" {
|
|
||||||
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
|
||||||
}
|
|
||||||
if transfer.Amount != 0.15 {
|
|
||||||
t.Fatalf("amount = %v, want 0.15", transfer.Amount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTonInboundTransferDoesNotCountUntrustedJettonNotificationAsTON(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
|
||||||
untrustedJettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x55}, 32)).Bounce(false).Testnet(false)
|
|
||||||
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
|
||||||
|
|
||||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
|
||||||
QueryID: 7,
|
|
||||||
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
|
||||||
Sender: sender,
|
|
||||||
ForwardPayload: cell.BeginCell().EndCell(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build jetton notification body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx := tonTestInboundTx(t, untrustedJettonWallet, receive, tlb.FromNanoTONU(1), body)
|
|
||||||
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
|
||||||
}
|
|
||||||
if transfer != nil {
|
|
||||||
t.Fatalf("untrusted jetton notification produced transfer: %#v", transfer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-17
@@ -48,12 +48,10 @@ const sqliteBusyRetryAttempts = 3
|
|||||||
type expirableOrder struct {
|
type expirableOrder struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
TradeId string `gorm:"column:trade_id"`
|
TradeId string `gorm:"column:trade_id"`
|
||||||
Status int `gorm:"column:status"`
|
|
||||||
Network string `gorm:"column:network"`
|
Network string `gorm:"column:network"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address"`
|
ReceiveAddress string `gorm:"column:receive_address"`
|
||||||
Token string `gorm:"column:token"`
|
Token string `gorm:"column:token"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||||
PayProvider string `gorm:"column:pay_provider"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOrderExpirationLoop() {
|
func runOrderExpirationLoop() {
|
||||||
@@ -93,8 +91,8 @@ func processExpiredOrders() {
|
|||||||
var orders []expirableOrder
|
var orders []expirableOrder
|
||||||
err := withSQLiteBusyRetry(func() error {
|
err := withSQLiteBusyRetry(func() error {
|
||||||
return dao.Mdb.Model(&mdb.Orders{}).
|
return dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Select("id", "trade_id", "status", "network", "receive_address", "token", "actual_amount", "pay_provider").
|
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
|
||||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
Where("status = ?", mdb.StatusWaitPay).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Order("id asc").
|
Order("id asc").
|
||||||
Limit(batchSize).
|
Limit(batchSize).
|
||||||
@@ -117,17 +115,6 @@ func processExpiredOrders() {
|
|||||||
if !expired {
|
if !expired {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// StatusWaitSelect placeholders have not reserved a wallet or
|
|
||||||
// amount yet, so there is no transaction lock to release.
|
|
||||||
if order.Status == mdb.StatusWaitSelect {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(order.PayProvider) != "" && order.PayProvider != mdb.PaymentProviderOnChain {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(order.Network) == "" || strings.TrimSpace(order.ReceiveAddress) == "" || strings.TrimSpace(order.Token) == "" || order.ActualAmount <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||||
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
}
|
}
|
||||||
@@ -205,8 +192,8 @@ func sendOrderCallback(order *mdb.Orders) error {
|
|||||||
return errors.New("no api key row available for callback")
|
return errors.New("no api key row available for callback")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch order.PaymentType {
|
||||||
case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
|
case mdb.PaymentTypeEpay:
|
||||||
// EPAY uses pid (integer) and secret_key as "key".
|
// EPAY uses pid (integer) and secret_key as "key".
|
||||||
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
||||||
if convErr != nil {
|
if convErr != nil {
|
||||||
|
|||||||
+3
-26
@@ -62,21 +62,6 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
t.Fatalf("lock recent order: %v", err)
|
t.Fatalf("lock recent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
placeholder := &mdb.Orders{
|
|
||||||
TradeId: "trade_wait_select_expired",
|
|
||||||
OrderId: "order_wait_select_expired",
|
|
||||||
Amount: 1,
|
|
||||||
Currency: "CNY",
|
|
||||||
Status: mdb.StatusWaitSelect,
|
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(placeholder).Error; err != nil {
|
|
||||||
t.Fatalf("create placeholder order: %v", err)
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Model(placeholder).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
|
|
||||||
t.Fatalf("age placeholder order: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
processExpiredOrders()
|
processExpiredOrders()
|
||||||
|
|
||||||
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
@@ -108,14 +93,6 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if lockTradeID != recentOrder.TradeId {
|
if lockTradeID != recentOrder.TradeId {
|
||||||
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
expiredPlaceholder, err := data.GetOrderInfoByTradeId(placeholder.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload expired placeholder: %v", err)
|
|
||||||
}
|
|
||||||
if expiredPlaceholder.Status != mdb.StatusExpired {
|
|
||||||
t.Fatalf("placeholder status = %d, want %d", expiredPlaceholder.Status, mdb.StatusExpired)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
||||||
@@ -318,7 +295,7 @@ func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_fail",
|
BlockTransactionId: "block_callback_epay_fail",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: "epay",
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -372,7 +349,7 @@ func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_ok",
|
BlockTransactionId: "block_callback_epay_ok",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: "epay",
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -573,7 +550,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
|
|||||||
Status: mdb.StatusPaySuccess,
|
Status: mdb.StatusPaySuccess,
|
||||||
NotifyUrl: server.URL,
|
NotifyUrl: server.URL,
|
||||||
BlockTransactionId: "block_epay_sign",
|
BlockTransactionId: "block_epay_sign",
|
||||||
PaymentType: "epay",
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
ApiKeyID: key.ID,
|
ApiKeyID: key.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+7
-27
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/controller/admin"
|
"github.com/GMWalletApp/epusdt/controller/admin"
|
||||||
"github.com/GMWalletApp/epusdt/controller/comm"
|
"github.com/GMWalletApp/epusdt/controller/comm"
|
||||||
@@ -38,15 +37,12 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
|
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
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)
|
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||||
|
|
||||||
// payment routes
|
// payment routes
|
||||||
paymentRoute := e.Group("/payments")
|
paymentRoute := e.Group("/payments")
|
||||||
|
|
||||||
// gmpay v1 routes. CreateTransaction accepts token+network together
|
// gmpay v1 routes
|
||||||
// for a concrete chain order, or omits both for a status=4 placeholder
|
|
||||||
// that is completed later by /pay/switch-network or routed to OkPay.
|
|
||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||||
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
|
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
|
||||||
@@ -78,7 +74,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
formParams, err := ctx.FormParams()
|
formParams, err := ctx.FormParams()
|
||||||
if err != nil && ctx.Request().Method == http.MethodPost {
|
if err != nil && ctx.Request().Method == http.MethodPost {
|
||||||
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
|
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
copyParams(formParams)
|
copyParams(formParams)
|
||||||
@@ -132,31 +128,16 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
notifyURL := getString(params, "notify_url")
|
notifyURL := getString(params, "notify_url")
|
||||||
outTradeNo := getString(params, "out_trade_no")
|
outTradeNo := getString(params, "out_trade_no")
|
||||||
returnURL := getString(params, "return_url")
|
returnURL := getString(params, "return_url")
|
||||||
token := strings.TrimSpace(getString(params, "token"))
|
|
||||||
if token == "" {
|
|
||||||
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
|
|
||||||
}
|
|
||||||
network := strings.TrimSpace(getString(params, "network"))
|
|
||||||
if network == "" {
|
|
||||||
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
|
|
||||||
}
|
|
||||||
currency := strings.TrimSpace(getString(params, "currency"))
|
|
||||||
if currency == "" {
|
|
||||||
currency = data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "")
|
|
||||||
}
|
|
||||||
if currency == "" {
|
|
||||||
currency = "cny"
|
|
||||||
}
|
|
||||||
|
|
||||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
|
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"token": token,
|
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
||||||
"currency": currency,
|
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||||
"network": network,
|
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||||
"amount": amountFloat,
|
"amount": amountFloat,
|
||||||
"notify_url": notifyURL,
|
"notify_url": notifyURL,
|
||||||
"order_id": outTradeNo,
|
"order_id": outTradeNo,
|
||||||
@@ -172,7 +153,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
jsonBytes, err := json.Marshal(body)
|
jsonBytes, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
|
return comm.Ctrl.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||||
@@ -287,7 +268,6 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
||||||
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
||||||
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
||||||
authed.GET("/dashboard/rpc-stats", admin.Ctrl.RpcStats)
|
|
||||||
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|||||||
+44
-1129
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,8 +13,8 @@ func Start() {
|
|||||||
go StartBscWebSocketListener()
|
go StartBscWebSocketListener()
|
||||||
go StartPolygonWebSocketListener()
|
go StartPolygonWebSocketListener()
|
||||||
go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
|
go StartArcWebSocketListener()
|
||||||
go StartTronBlockScannerListener()
|
go StartTronBlockScannerListener()
|
||||||
go StartTonBlockScannerListener()
|
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is the testnet for Arc USDC/EURC network.
|
||||||
|
const ArcEURCAddress = "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a"
|
||||||
|
const ArcEURCDecimals = 6
|
||||||
|
const ArcUSDCERC20Address = "0x3600000000000000000000000000000000000000"
|
||||||
|
const ArcUSDCSystemAddress = "0x1800000000000000000000000000000000000000"
|
||||||
|
const ArcNativeUSDCDecimals = 18
|
||||||
|
|
||||||
|
var ArcWs = "wss://rpc.testnet.arc.network"
|
||||||
|
|
||||||
|
const ArcChainID = "5042002"
|
||||||
|
|
||||||
|
type arcRecipientSnapshot struct {
|
||||||
|
addrs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var arcWatchedRecipients atomic.Pointer[arcRecipientSnapshot]
|
||||||
|
|
||||||
|
var arcERC20Tokens = []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkArc, Symbol: "EURC", ContractAddress: ArcEURCAddress, Decimals: ArcEURCDecimals, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var arcNativeUSDCTransferEventHash = common.HexToHash("0x62f084c00a442dcf51cdbb51beed2839bf42a268da8474b0e98f38edb7db5a22")
|
||||||
|
|
||||||
|
func StartArcWebSocketListener() {
|
||||||
|
for {
|
||||||
|
runArcListener()
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runArcListener() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[ARC-CHAIN-WS] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storeArcRecipientsFromWallets(wallets)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[ARC-CHAIN-WS] refresh wallet addresses: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storeArcRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
contracts := make([]common.Address, 0, len(arcERC20Tokens)+1)
|
||||||
|
tokenByContract := make(map[string]mdb.ChainToken, len(arcERC20Tokens)+1)
|
||||||
|
for _, token := range arcERC20Tokens {
|
||||||
|
contract := common.HexToAddress(token.ContractAddress)
|
||||||
|
contracts = append(contracts, contract)
|
||||||
|
tokenByContract[strings.ToLower(contract.Hex())] = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arc USDC is a native gas token. Its single source of truth for balance
|
||||||
|
// movement logs is the native-token system contract, not the ERC-20 facade.
|
||||||
|
usdcSystemContract := common.HexToAddress(ArcUSDCSystemAddress)
|
||||||
|
contracts = append(contracts, usdcSystemContract)
|
||||||
|
tokenByContract[strings.ToLower(usdcSystemContract.Hex())] = mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkArc,
|
||||||
|
Symbol: "USDC",
|
||||||
|
ContractAddress: ArcUSDCERC20Address,
|
||||||
|
Decimals: ArcNativeUSDCDecimals,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARC_WSS env var overrides the default testnet WebSocket URL.
|
||||||
|
if arcws := os.Getenv("ARC_WSS"); arcws != "" {
|
||||||
|
ArcWs = arcws
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Sugar.Infof("[ARC-CHAIN-WS] connecting to %s chain_id=%s watching %d contract(s)", ArcWs, ArcChainID, len(contracts))
|
||||||
|
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: contracts,
|
||||||
|
Topics: [][]common.Hash{{transferEventHash, arcNativeUSDCTransferEventHash}},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener(ctx, "[ARC-CHAIN-WS]", ArcWs, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
handleArcTransferLog(client, tokenByContract, "[ARC-CHAIN-WS]", vLog)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleArcTransferLog(client *ethclient.Client, tokenByContract map[string]mdb.ChainToken, logPrefix string, vLog types.Log) {
|
||||||
|
if vLog.Removed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isArcTransferEvent(vLog) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
if !isWatchedArcRecipient(toAddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := tokenByContract[strings.ToLower(vLog.Address.Hex())]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
log.Sugar.Infof("%s matched %s transfer contract=%s to=%s raw_amount=%s tx_hash=%s block=%d", logPrefix, token.Symbol, vLog.Address.Hex(), toAddr.Hex(), amount.String(), vLog.TxHash.Hex(), vLog.BlockNumber)
|
||||||
|
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(vLog.BlockNumber))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("%s HeaderByNumber block=%d: %v, using local time", logPrefix, vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessConfiguredEvmERC20Transfer(mdb.NetworkArc, token, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isArcTransferEvent(vLog types.Log) bool {
|
||||||
|
if len(vLog.Topics) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(vLog.Address.Hex(), ArcUSDCSystemAddress) {
|
||||||
|
return vLog.Topics[0] == arcNativeUSDCTransferEventHash
|
||||||
|
}
|
||||||
|
if vLog.Topics[0] == transferEventHash {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeArcRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
a := strings.TrimSpace(w.Address)
|
||||||
|
if !common.IsHexAddress(a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
||||||
|
}
|
||||||
|
arcWatchedRecipients.Store(&arcRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedArcRecipient(to common.Address) bool {
|
||||||
|
snap := arcWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -66,18 +66,18 @@ func runBscListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsNode, ok := resolveChainWsNode(mdb.NetworkBsc, "[BSC-WS]")
|
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[BSC-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: contracts,
|
Addresses: contracts,
|
||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, mdb.NetworkBsc, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,11 +98,9 @@ func runBscListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkBsc)
|
|
||||||
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
data.RecordRpcSuccess(mdb.NetworkBsc)
|
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,28 +93,19 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
|
|||||||
// given network. If no enabled node is configured, the caller skips the
|
// given network. If no enabled node is configured, the caller skips the
|
||||||
// current listener run so admin-side disabled/deleted rows are respected.
|
// current listener run so admin-side disabled/deleted rows are respected.
|
||||||
func resolveChainWsURL(network, logPrefix string) (string, bool) {
|
func resolveChainWsURL(network, logPrefix string) (string, bool) {
|
||||||
node, ok := resolveChainWsNode(network, logPrefix)
|
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
||||||
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 {
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
rpcURL := strings.TrimSpace(node.Url)
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
if rpcURL != "" {
|
if rpcURL != "" {
|
||||||
node.Url = rpcURL
|
return rpcURL, true
|
||||||
return *node, true
|
|
||||||
}
|
}
|
||||||
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
|
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
|
||||||
return mdb.RpcNode{}, false
|
return "", false
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
|
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
|
||||||
} else {
|
} else {
|
||||||
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
|
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
|
||||||
}
|
}
|
||||||
return mdb.RpcNode{}, false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/big"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
epLog "github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
|
||||||
epLog.Sugar = zap.NewNop().Sugar()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -52,143 +42,6 @@ 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 TestEvmWsNodeFailureRecordsRuntimeStats(t *testing.T) {
|
|
||||||
data.ResetRpcFailoverForTest()
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
recordEvmWsNodeFailure("[TEST]", mdb.NetworkEthereum, mdb.RpcNode{
|
|
||||||
BaseModel: mdb.BaseModel{ID: 123},
|
|
||||||
Url: "wss://ethereum.example.com",
|
|
||||||
Type: mdb.RpcNodeTypeWs,
|
|
||||||
}, "dial")
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
eth := stats[mdb.NetworkEthereum]
|
|
||||||
if eth.SuccessCount != 0 {
|
|
||||||
t.Fatalf("ethereum success_count = %d, want 0", eth.SuccessCount)
|
|
||||||
}
|
|
||||||
if eth.FailureCount != 1 {
|
|
||||||
t.Fatalf("ethereum failure_count = %d, want 1", eth.FailureCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvmLogBlockHeightRecordsRuntimeHeight(t *testing.T) {
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123456)
|
|
||||||
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123455)
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
eth := stats[mdb.NetworkEthereum]
|
|
||||||
if eth.LatestBlockHeight != 123456 {
|
|
||||||
t.Fatalf("ethereum latest_block_height = %d, want 123456", eth.LatestBlockHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvmHeaderBlockHeightRecordsRuntimeHeight(t *testing.T) {
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654321)})
|
|
||||||
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654320)})
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
polygon := stats[mdb.NetworkPolygon]
|
|
||||||
if polygon.LatestBlockHeight != 7654321 {
|
|
||||||
t.Fatalf("polygon latest_block_height = %d, want 7654321", polygon.LatestBlockHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldRefreshEvmLatestHeaderAfterIdleInterval(t *testing.T) {
|
|
||||||
lastUpdate := time.Now()
|
|
||||||
|
|
||||||
if shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval-time.Second)) {
|
|
||||||
t.Fatal("should not refresh latest header before idle interval")
|
|
||||||
}
|
|
||||||
if !shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval)) {
|
|
||||||
t.Fatal("should refresh latest header at idle interval")
|
|
||||||
}
|
|
||||||
if shouldRefreshEvmLatestHeader(lastUpdate, true, lastUpdate.Add(evmStatsIdleInterval+time.Second)) {
|
|
||||||
t.Fatal("should not refresh latest header while request is in flight")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -71,18 +71,18 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsNode, ok := resolveChainWsNode(mdb.NetworkEthereum, "[ETH-WS]")
|
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[ETH-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: contracts,
|
Addresses: contracts,
|
||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, mdb.NetworkEthereum, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, "[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,11 +100,9 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkEthereum)
|
|
||||||
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
data.RecordRpcSuccess(mdb.NetworkEthereum)
|
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-134
@@ -2,11 +2,8 @@ package task
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
@@ -14,26 +11,18 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs,
|
||||||
evmStatsIdleInterval = 30 * time.Second
|
|
||||||
evmStatsHeaderTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// runEvmWsLogListener connects to node.Url, subscribes to Transfer logs,
|
|
||||||
// and dispatches each log to handleLog. It retries on transient errors
|
// and dispatches each log to handleLog. It retries on transient errors
|
||||||
// with exponential backoff until the node reaches the failure threshold.
|
// with exponential backoff. The ctx lets the caller trigger a clean
|
||||||
// The ctx lets the caller trigger a clean exit — e.g. when admin disables
|
// exit — e.g. when admin disables the chain, the caller cancels the
|
||||||
// the chain, the caller cancels the context and the function returns.
|
// context and the function returns instead of reconnecting forever.
|
||||||
func runEvmWsLogListener(ctx context.Context, network string, logPrefix string, node mdb.RpcNode, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
const (
|
const (
|
||||||
minBackoff = 2 * time.Second
|
minBackoff = 2 * time.Second
|
||||||
maxBackoff = 60 * time.Second
|
maxBackoff = 60 * time.Second
|
||||||
rejoinWait = 3 * time.Second
|
rejoinWait = 3 * time.Second
|
||||||
stableResetAfter = 60 * time.Second
|
|
||||||
)
|
)
|
||||||
failWait := minBackoff
|
failWait := minBackoff
|
||||||
wsURL := node.Url
|
|
||||||
nodeLabel := data.RpcNodeLogLabel(node)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -42,13 +31,7 @@ func runEvmWsLogListener(ctx context.Context, network string, logPrefix string,
|
|||||||
|
|
||||||
client, err := ethclient.Dial(wsURL)
|
client, err := ethclient.Dial(wsURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
||||||
if recordEvmWsNodeFailure(logPrefix, network, node, "dial") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !sleepOrDone(ctx, failWait) {
|
if !sleepOrDone(ctx, failWait) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,13 +43,7 @@ func runEvmWsLogListener(ctx context.Context, network string, logPrefix string,
|
|||||||
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
|
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
client.Close()
|
client.Close()
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
||||||
if recordEvmWsNodeFailure(logPrefix, network, node, "subscribe") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !sleepOrDone(ctx, failWait) {
|
if !sleepOrDone(ctx, failWait) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,49 +51,22 @@ func runEvmWsLogListener(ctx context.Context, network string, logPrefix string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
failWait = minBackoff
|
failWait = minBackoff
|
||||||
data.RecordRpcSuccess(network)
|
|
||||||
|
|
||||||
log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
|
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix)
|
||||||
|
|
||||||
connectedAt := time.Now()
|
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
|
||||||
recvErr := recvLoop(ctx, client, sub, logsCh, network, logPrefix, handleLog)
|
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if time.Since(connectedAt) >= stableResetAfter {
|
|
||||||
if recvErr != nil {
|
|
||||||
data.RecordRpcFailure(network)
|
|
||||||
}
|
|
||||||
data.RecordRpcNodeSuccess(node.ID)
|
|
||||||
} else if recvErr != nil && recordEvmWsNodeFailure(logPrefix, network, node, recvErr.Error()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !sleepOrDone(ctx, rejoinWait) {
|
if !sleepOrDone(ctx, rejoinWait) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, network string, logPrefix string, handleLog func(*ethclient.Client, types.Log)) error {
|
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
statsCtx, cancelStats := context.WithCancel(ctx)
|
|
||||||
statsTicker := time.NewTicker(evmStatsIdleInterval)
|
|
||||||
headerStatsDone := make(chan bool, 1)
|
|
||||||
lastStatsUpdateAt := time.Now()
|
|
||||||
headerStatsInFlight := false
|
|
||||||
requestLatestHeader := func() {
|
|
||||||
if headerStatsInFlight {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
headerStatsInFlight = true
|
|
||||||
go func() {
|
|
||||||
headerStatsDone <- recordEvmLatestHeader(statsCtx, network, logPrefix, client)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
cancelStats()
|
|
||||||
statsTicker.Stop()
|
|
||||||
sub.Unsubscribe()
|
sub.Unsubscribe()
|
||||||
client.Close()
|
client.Close()
|
||||||
}()
|
}()
|
||||||
@@ -125,94 +75,24 @@ func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscr
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
||||||
return nil
|
return
|
||||||
case <-statsTicker.C:
|
|
||||||
if shouldRefreshEvmLatestHeader(lastStatsUpdateAt, headerStatsInFlight, time.Now()) {
|
|
||||||
requestLatestHeader()
|
|
||||||
}
|
|
||||||
case updated := <-headerStatsDone:
|
|
||||||
headerStatsInFlight = false
|
|
||||||
if updated {
|
|
||||||
lastStatsUpdateAt = time.Now()
|
|
||||||
}
|
|
||||||
case err := <-sub.Err():
|
case err := <-sub.Err():
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
||||||
return fmt.Errorf("subscription error")
|
|
||||||
} else {
|
} else {
|
||||||
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
|
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
|
||||||
return fmt.Errorf("subscription closed")
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
case vLog, ok := <-logsCh:
|
case vLog, ok := <-logsCh:
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
||||||
return fmt.Errorf("log channel closed")
|
return
|
||||||
}
|
|
||||||
if recordEvmLogBlockHeight(network, logPrefix, vLog.BlockNumber) {
|
|
||||||
lastStatsUpdateAt = time.Now()
|
|
||||||
}
|
}
|
||||||
handleLog(client, vLog)
|
handleLog(client, vLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldRefreshEvmLatestHeader(lastStatsUpdateAt time.Time, inFlight bool, now time.Time) bool {
|
|
||||||
return !inFlight && now.Sub(lastStatsUpdateAt) >= evmStatsIdleInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordEvmLogBlockHeight(network string, logPrefix string, blockNumber uint64) bool {
|
|
||||||
if blockNumber == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if blockNumber > uint64(1<<63-1) {
|
|
||||||
log.Sugar.Warnf("%s log block number exceeds int64 range: %d", logPrefix, blockNumber)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
data.RecordRpcBlockHeight(network, int64(blockNumber))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordEvmLatestHeader(ctx context.Context, network string, logPrefix string, client *ethclient.Client) bool {
|
|
||||||
headerCtx, cancel := context.WithTimeout(ctx, evmStatsHeaderTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
header, err := client.HeaderByNumber(headerCtx, nil)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
data.RecordRpcFailure(network)
|
|
||||||
log.Sugar.Warnf("%s latest header stats failed: %v", logPrefix, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
data.RecordRpcSuccess(network)
|
|
||||||
return recordEvmHeaderBlockHeight(network, logPrefix, header)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordEvmHeaderBlockHeight(network string, logPrefix string, header *types.Header) bool {
|
|
||||||
if header == nil || header.Number == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !header.Number.IsInt64() {
|
|
||||||
log.Sugar.Warnf("%s latest header block number exceeds int64 range: %s", logPrefix, header.Number.String())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
data.RecordRpcBlockHeight(network, header.Number.Int64())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordEvmWsNodeFailure(logPrefix string, network string, node mdb.RpcNode, reason string) bool {
|
|
||||||
data.RecordRpcFailure(network)
|
|
||||||
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
|
// sleepOrDone waits for d or for ctx cancellation, whichever comes
|
||||||
// first. Returns true if the sleep completed normally, false if ctx
|
// first. Returns true if the sleep completed normally, false if ctx
|
||||||
// was cancelled (caller should exit).
|
// was cancelled (caller should exit).
|
||||||
|
|||||||
@@ -65,18 +65,18 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsNode, ok := resolveChainWsNode(mdb.NetworkPlasma, "[PLASMA-WS]")
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[PLASMA-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: contracts,
|
Addresses: contracts,
|
||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, mdb.NetworkPlasma, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,11 +97,9 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkPlasma)
|
|
||||||
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
data.RecordRpcSuccess(mdb.NetworkPlasma)
|
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,18 +65,18 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsNode, ok := resolveChainWsNode(mdb.NetworkPolygon, "[POLYGON-WS]")
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[POLYGON-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
|
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: contracts,
|
Addresses: contracts,
|
||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, mdb.NetworkPolygon, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,11 +97,9 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkPolygon)
|
|
||||||
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
data.RecordRpcSuccess(mdb.NetworkPolygon)
|
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-49
@@ -12,59 +12,31 @@ import (
|
|||||||
type ListenSolJob struct{}
|
type ListenSolJob struct{}
|
||||||
|
|
||||||
var gListenSolJobLock sync.Mutex
|
var gListenSolJobLock sync.Mutex
|
||||||
var gSolanaBlockHeightLock = make(chan struct{}, 1)
|
|
||||||
|
|
||||||
func (r ListenSolJob) Run() {
|
func (r ListenSolJob) Run() {
|
||||||
shouldRecordHeight := false
|
gListenSolJobLock.Lock()
|
||||||
func() {
|
defer gListenSolJobLock.Unlock()
|
||||||
gListenSolJobLock.Lock()
|
log.Sugar.Debug("[ListenSolJob] Job triggered")
|
||||||
defer gListenSolJobLock.Unlock()
|
if !data.IsChainEnabled(mdb.NetworkSolana) {
|
||||||
|
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
|
||||||
log.Sugar.Debug("[ListenSolJob] Job triggered")
|
|
||||||
if !data.IsChainEnabled(mdb.NetworkSolana) {
|
|
||||||
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shouldRecordHeight = true
|
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(walletAddress) <= 0 {
|
|
||||||
log.Sugar.Debug("[ListenSolJob] No available wallet addresses")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[ListenSolJob] Found %d wallet addresses to monitor", len(walletAddress))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, address := range walletAddress {
|
|
||||||
log.Sugar.Infof("[ListenSolJob] Listening to address: %s", address.Address)
|
|
||||||
wg.Add(1)
|
|
||||||
go service.SolCallBack(address.Address, &wg)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
log.Sugar.Debug("[ListenSolJob] Job completed")
|
|
||||||
}()
|
|
||||||
|
|
||||||
if shouldRecordHeight {
|
|
||||||
recordSolanaLatestBlockHeight()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordSolanaLatestBlockHeight() {
|
|
||||||
select {
|
|
||||||
case gSolanaBlockHeightLock <- struct{}{}:
|
|
||||||
defer func() { <-gSolanaBlockHeightLock }()
|
|
||||||
default:
|
|
||||||
log.Sugar.Debug("[ListenSolJob] previous Solana block height stats still running, skipping")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
|
||||||
height, err := service.SolGetBlockHeight()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("[ListenSolJob] get Solana block height failed: %v", err)
|
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
|
||||||
} else {
|
return
|
||||||
data.RecordRpcBlockHeight(mdb.NetworkSolana, height)
|
|
||||||
}
|
}
|
||||||
|
if len(walletAddress) <= 0 {
|
||||||
|
log.Sugar.Debug("[ListenSolJob] No available wallet addresses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[ListenSolJob] Found %d wallet addresses to monitor", len(walletAddress))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, address := range walletAddress {
|
||||||
|
log.Sugar.Infof("[ListenSolJob] Listening to address: %s", address.Address)
|
||||||
|
wg.Add(1)
|
||||||
|
go service.SolCallBack(address.Address, &wg)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
log.Sugar.Debug("[ListenSolJob] Job completed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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 TestListenSolJobRecordsRuntimeBlockHeight(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
methodCalls := make(map[string]int)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
methodCalls[req.Method]++
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if req.Method == "getBlockHeight" {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"result": int64(654321),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"result": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"signature": "solana-test-signature",
|
|
||||||
"slot": uint64(123456),
|
|
||||||
"err": map[string]interface{}{
|
|
||||||
"InstructionError": []interface{}{0, "Custom"},
|
|
||||||
},
|
|
||||||
"blockTime": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
|
||||||
Network: mdb.NetworkSolana,
|
|
||||||
Url: server.URL,
|
|
||||||
Type: mdb.RpcNodeTypeHttp,
|
|
||||||
Weight: 1,
|
|
||||||
Enabled: true,
|
|
||||||
Purpose: mdb.RpcNodePurposeGeneral,
|
|
||||||
Status: mdb.RpcNodeStatusOk,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("seed solana rpc_node: %v", err)
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
|
||||||
Network: mdb.NetworkSolana,
|
|
||||||
Address: "SolTestAddress001",
|
|
||||||
Status: mdb.TokenStatusEnable,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("seed solana wallet: %v", err)
|
|
||||||
}
|
|
||||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
|
||||||
Network: mdb.NetworkSolana,
|
|
||||||
Symbol: "SOL",
|
|
||||||
ContractAddress: "",
|
|
||||||
Decimals: 9,
|
|
||||||
Enabled: true,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("seed solana chain_token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ListenSolJob{}.Run()
|
|
||||||
|
|
||||||
if methodCalls["getSignaturesForAddress"] != 1 {
|
|
||||||
t.Fatalf("getSignaturesForAddress calls = %d, want 1", methodCalls["getSignaturesForAddress"])
|
|
||||||
}
|
|
||||||
if methodCalls["getBlockHeight"] != 1 {
|
|
||||||
t.Fatalf("getBlockHeight calls = %d, want 1", methodCalls["getBlockHeight"])
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
solana := stats[mdb.NetworkSolana]
|
|
||||||
if solana.SuccessCount != 2 {
|
|
||||||
t.Fatalf("solana success_count = %d, want 2", solana.SuccessCount)
|
|
||||||
}
|
|
||||||
if solana.FailureCount != 0 {
|
|
||||||
t.Fatalf("solana failure_count = %d, want 0", solana.FailureCount)
|
|
||||||
}
|
|
||||||
if solana.LatestBlockHeight != 654321 {
|
|
||||||
t.Fatalf("solana latest_block_height = %d, want 654321", solana.LatestBlockHeight)
|
|
||||||
}
|
|
||||||
if solana.LastSyncAt.IsZero() {
|
|
||||||
t.Fatal("solana last_sync_at is zero")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordSolanaLatestBlockHeightSkipsWhenPreviousStatsRunning(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
gSolanaBlockHeightLock <- struct{}{}
|
|
||||||
defer func() { <-gSolanaBlockHeightLock }()
|
|
||||||
|
|
||||||
recordSolanaLatestBlockHeight()
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
if _, ok := stats[mdb.NetworkSolana]; ok {
|
|
||||||
t.Fatalf("solana stats = %#v, want no stats when height reporter is already running", stats[mdb.NetworkSolana])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/ton"
|
|
||||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tonMasterShard = int64(-0x8000000000000000)
|
|
||||||
tonScannerConnectTimeout = 20 * time.Second
|
|
||||||
tonScannerRequestTimeout = 15 * time.Second
|
|
||||||
tonScannerRetryDelay = 3 * time.Second
|
|
||||||
tonCatchupYield = 250 * time.Millisecond
|
|
||||||
tonMaxMasterBlocksTick = uint32(50)
|
|
||||||
tonBlockTxPageSize = uint32(256)
|
|
||||||
)
|
|
||||||
|
|
||||||
type tonWatchState struct {
|
|
||||||
wallets map[string]*address.Address
|
|
||||||
nativeToken *mdb.ChainToken
|
|
||||||
jettonTokens []mdb.ChainToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tonWatchState) hasWalletInWorkchain(workchain int32) bool {
|
|
||||||
if s == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, wallet := range s.wallets {
|
|
||||||
if wallet != nil && wallet.Workchain() == workchain {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type tonRuntimeCursor struct {
|
|
||||||
initialized bool
|
|
||||||
lastMasterSeqNo uint32
|
|
||||||
shardSeqNo map[string]uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *tonRuntimeCursor) ensureShardSeqNo() {
|
|
||||||
if c.shardSeqNo == nil {
|
|
||||||
c.shardSeqNo = make(map[string]uint32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type tonJettonWalletCache struct {
|
|
||||||
sync.Mutex
|
|
||||||
walletByOwnerToken map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTonJettonWalletCache() *tonJettonWalletCache {
|
|
||||||
return &tonJettonWalletCache{walletByOwnerToken: make(map[string]string)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTonBlockScannerListener runs a forward-only TON masterchain scanner.
|
|
||||||
func StartTonBlockScannerListener() {
|
|
||||||
cache := newTonJettonWalletCache()
|
|
||||||
cursor := &tonRuntimeCursor{}
|
|
||||||
for {
|
|
||||||
chain, interval := tonChainConfig()
|
|
||||||
if chain == nil || !chain.Enabled {
|
|
||||||
sleepOrDone(context.Background(), interval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
node, ok := resolveTonLiteNode()
|
|
||||||
if !ok {
|
|
||||||
sleepOrDone(context.Background(), interval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TON] connecting using lite node %s", data.RpcNodeLogLabel(node))
|
|
||||||
api, closeFn, err := connectTonLiteAPI(node)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON] connect lite node %s failed: %v", data.RpcNodeLogLabel(node), err)
|
|
||||||
recordTonLiteNodeFailure(node, "connect")
|
|
||||||
sleepOrDone(context.Background(), tonScannerRetryDelay)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TON] connected using lite node %s", data.RpcNodeLogLabel(node))
|
|
||||||
err = runTonScanner(context.Background(), api, cache, cursor)
|
|
||||||
closeFn()
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON] scanner stopped: %v", err)
|
|
||||||
recordTonLiteNodeFailure(node, err.Error())
|
|
||||||
sleepOrDone(context.Background(), tonScannerRetryDelay)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTonScanner(ctx context.Context, api ton.APIClientWrapped, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
|
||||||
if cursor == nil {
|
|
||||||
cursor = &tonRuntimeCursor{}
|
|
||||||
}
|
|
||||||
watchSignature := ""
|
|
||||||
for {
|
|
||||||
chain, interval := tonChainConfig()
|
|
||||||
if chain == nil || !chain.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
state, err := loadTonWatchState()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(state.wallets) == 0 || (state.nativeToken == nil && len(state.jettonTokens) == 0) {
|
|
||||||
log.Sugar.Debug("[TON] no enabled wallets or tokens, idling")
|
|
||||||
if !sleepOrDone(ctx, interval) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sig := tonWatchStateSignature(state); sig != watchSignature {
|
|
||||||
watchSignature = sig
|
|
||||||
log.Sugar.Infof(
|
|
||||||
"[TON] watching wallets=%d native_ton=%t jetton_tokens=%d wallet_workchains=%s",
|
|
||||||
len(state.wallets),
|
|
||||||
state.nativeToken != nil,
|
|
||||||
len(state.jettonTokens),
|
|
||||||
tonWalletWorkchainSummary(state),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
head, err := api.CurrentMasterchainInfo(ctx)
|
|
||||||
if err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkTon)
|
|
||||||
return fmt.Errorf("current masterchain info: %w", err)
|
|
||||||
}
|
|
||||||
confirmedHeadSeqNo := tonConfirmedMasterSeqNo(head.SeqNo, chain.MinConfirmations)
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkTon)
|
|
||||||
data.RecordRpcBlockHeight(mdb.NetworkTon, int64(head.SeqNo))
|
|
||||||
|
|
||||||
if !cursor.initialized {
|
|
||||||
if err = initializeTonRuntimeCursor(ctx, api, head, confirmedHeadSeqNo, cursor); err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkTon)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Sugar.Infof(
|
|
||||||
"[TON] current masterchain head=%d confirmed=%d root=%s file=%s",
|
|
||||||
head.SeqNo,
|
|
||||||
confirmedHeadSeqNo,
|
|
||||||
shortTonHash(head.RootHash),
|
|
||||||
shortTonHash(head.FileHash),
|
|
||||||
)
|
|
||||||
log.Sugar.Infof("[TON] initialized runtime cursor at masterchain seqno=%d head=%d", cursor.lastMasterSeqNo, head.SeqNo)
|
|
||||||
if !sleepOrDone(ctx, interval) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo >= confirmedHeadSeqNo {
|
|
||||||
if !sleepOrDone(ctx, interval) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
target := cursor.lastMasterSeqNo + tonMaxMasterBlocksTick
|
|
||||||
if target > confirmedHeadSeqNo {
|
|
||||||
target = confirmedHeadSeqNo
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TON] processing masterchain blocks from=%d to=%d head=%d confirmed=%d", cursor.lastMasterSeqNo+1, target, head.SeqNo, confirmedHeadSeqNo)
|
|
||||||
for seq := cursor.lastMasterSeqNo + 1; seq <= target; seq++ {
|
|
||||||
master, err := api.WaitForBlock(seq).LookupBlock(ctx, address.MasterchainID, tonMasterShard, seq)
|
|
||||||
if err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkTon)
|
|
||||||
return fmt.Errorf("lookup masterchain seqno=%d: %w", seq, err)
|
|
||||||
}
|
|
||||||
if err = processTonMasterBlockWithCursor(ctx, api, master, state, cache, cursor); err != nil {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkTon)
|
|
||||||
return fmt.Errorf("process masterchain seqno=%d: %w", seq, err)
|
|
||||||
}
|
|
||||||
cursor.lastMasterSeqNo = master.SeqNo
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkTon)
|
|
||||||
data.RecordRpcBlockHeight(mdb.NetworkTon, int64(seq))
|
|
||||||
}
|
|
||||||
if target < confirmedHeadSeqNo {
|
|
||||||
if !sleepOrDone(ctx, tonCatchupYield) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonConfirmedMasterSeqNo(headSeqNo uint32, minConfirmations int) uint32 {
|
|
||||||
if minConfirmations <= 1 {
|
|
||||||
return headSeqNo
|
|
||||||
}
|
|
||||||
conf := uint32(minConfirmations)
|
|
||||||
if headSeqNo < conf-1 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return headSeqNo - conf + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeTonRuntimeCursor(ctx context.Context, api ton.APIClientWrapped, head *ton.BlockIDExt, confirmedHeadSeqNo uint32, cursor *tonRuntimeCursor) error {
|
|
||||||
if cursor == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if head == nil {
|
|
||||||
return fmt.Errorf("current masterchain head is nil")
|
|
||||||
}
|
|
||||||
initMaster := head
|
|
||||||
if confirmedHeadSeqNo > 0 && confirmedHeadSeqNo != head.SeqNo {
|
|
||||||
var err error
|
|
||||||
initMaster, err = api.WaitForBlock(confirmedHeadSeqNo).LookupBlock(ctx, address.MasterchainID, tonMasterShard, confirmedHeadSeqNo)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("lookup initial confirmed masterchain seqno=%d: %w", confirmedHeadSeqNo, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if confirmedHeadSeqNo > 0 {
|
|
||||||
if err := initializeTonShardCursors(ctx, api, initMaster, cursor); err != nil {
|
|
||||||
return fmt.Errorf("initialize shard cursors masterchain seqno=%d: %w", initMaster.SeqNo, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor.initialized = true
|
|
||||||
cursor.lastMasterSeqNo = confirmedHeadSeqNo
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func processTonMasterBlock(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error {
|
|
||||||
return processTonMasterBlockWithCursor(ctx, api, master, state, cache, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func processTonMasterBlockWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
|
||||||
if state.hasWalletInWorkchain(address.MasterchainID) {
|
|
||||||
if err := processTonShardBlock(ctx, api, master, master, state, cache); err != nil {
|
|
||||||
return fmt.Errorf("process masterchain transactions: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Sugar.Debugf(
|
|
||||||
"[TON] masterchain seqno=%d root=%s file=%s shard_blocks=%d workchains=%s",
|
|
||||||
master.SeqNo,
|
|
||||||
shortTonHash(master.RootHash),
|
|
||||||
shortTonHash(master.FileHash),
|
|
||||||
len(shards),
|
|
||||||
tonShardWorkchainSummary(shards),
|
|
||||||
)
|
|
||||||
if cursor != nil {
|
|
||||||
return processTonShardBlocksWithCursor(ctx, api, master, shards, state, cache, cursor)
|
|
||||||
}
|
|
||||||
for _, shard := range shards {
|
|
||||||
if err = processTonShardBlock(ctx, api, master, shard, state, cache); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeTonShardCursors(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, cursor *tonRuntimeCursor) error {
|
|
||||||
if cursor == nil || master == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cursor.ensureShardSeqNo()
|
|
||||||
for _, shard := range shards {
|
|
||||||
if shard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cursor.shardSeqNo[tonShardCursorKey(shard)] = shard.SeqNo
|
|
||||||
}
|
|
||||||
log.Sugar.Infof(
|
|
||||||
"[TON] initialized shard cursors masterchain seqno=%d shard_blocks=%d workchains=%s",
|
|
||||||
master.SeqNo,
|
|
||||||
len(shards),
|
|
||||||
tonShardWorkchainSummary(shards),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func processTonShardBlocksWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, shards []*ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
|
||||||
if cursor == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cursor.ensureShardSeqNo()
|
|
||||||
for _, shard := range shards {
|
|
||||||
if shard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := tonShardCursorKey(shard)
|
|
||||||
lastSeqNo, ok := cursor.shardSeqNo[key]
|
|
||||||
if !state.hasWalletInWorkchain(shard.Workchain) {
|
|
||||||
cursor.shardSeqNo[key] = shard.SeqNo
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
start := shard.SeqNo
|
|
||||||
if ok {
|
|
||||||
if shard.SeqNo <= lastSeqNo {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
start = lastSeqNo + 1
|
|
||||||
}
|
|
||||||
if shard.SeqNo > start {
|
|
||||||
log.Sugar.Infof(
|
|
||||||
"[TON] processing shard blocks master_seqno=%d workchain=%d shard=%x from=%d to=%d",
|
|
||||||
master.SeqNo,
|
|
||||||
shard.Workchain,
|
|
||||||
uint64(shard.Shard),
|
|
||||||
start,
|
|
||||||
shard.SeqNo,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for seq := start; seq <= shard.SeqNo; seq++ {
|
|
||||||
shardBlock := shard
|
|
||||||
if seq != shard.SeqNo {
|
|
||||||
var err error
|
|
||||||
shardBlock, err = api.WaitForBlock(master.SeqNo).LookupBlock(ctx, shard.Workchain, shard.Shard, seq)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("lookup shard block workchain=%d shard=%x seqno=%d: %w", shard.Workchain, uint64(shard.Shard), seq, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := processTonShardBlock(ctx, api, master, shardBlock, state, cache); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cursor.shardSeqNo[key] = seq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func processTonShardBlock(ctx context.Context, api ton.APIClientWrapped, master, shard *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error {
|
|
||||||
log.Sugar.Debugf("[TON] scanning shard master_seqno=%d workchain=%d shard=%d shard_seqno=%d", master.SeqNo, shard.Workchain, shard.Shard, shard.SeqNo)
|
|
||||||
var after *ton.TransactionID3
|
|
||||||
for {
|
|
||||||
shorts, incomplete, err := api.WaitForBlock(master.SeqNo).GetBlockTransactionsV2(ctx, shard, tonBlockTxPageSize, after)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := range shorts {
|
|
||||||
account := addressutil.TonAddressFromShortAccount(shard.Workchain, shorts[i].Account)
|
|
||||||
if account == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
receive, ok := state.wallets[addressutil.TonRawAddressObjectKey(account)]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Debugf("[TON] watched account tx candidate master_seqno=%d workchain=%d shard=%d lt=%d account=%s", master.SeqNo, shard.Workchain, shard.Shard, shorts[i].LT, addressutil.TonRawAddressObjectKey(account))
|
|
||||||
tx, err := api.WaitForBlock(master.SeqNo).GetTransaction(ctx, shard, account, shorts[i].LT)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tx == nil {
|
|
||||||
return fmt.Errorf("missing full transaction workchain=%d shard=%d seqno=%d lt=%d", shard.Workchain, shard.Shard, shard.SeqNo, shorts[i].LT)
|
|
||||||
}
|
|
||||||
jettonWallets, err := cache.jettonWalletTokens(ctx, api, master, receive, state.jettonTokens)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
transfer, err := service.ParseTonInboundTransfer(tx, receive, state.nativeToken, jettonWallets)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON] parse inbound tx lt=%d hash=%s: %v", tx.LT, hex.EncodeToString(tx.Hash), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if transfer == nil {
|
|
||||||
log.Sugar.Debugf(
|
|
||||||
"[TON] unsupported watched tx master_seqno=%d workchain=%d shard=%d lt=%d hash=%s account=%s",
|
|
||||||
master.SeqNo,
|
|
||||||
shard.Workchain,
|
|
||||||
shard.Shard,
|
|
||||||
tx.LT,
|
|
||||||
hex.EncodeToString(tx.Hash),
|
|
||||||
addressutil.TonRawAddressObjectKey(account),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
service.TryProcessTonTransfer(transfer)
|
|
||||||
}
|
|
||||||
if !incomplete {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(shorts) == 0 {
|
|
||||||
return fmt.Errorf("incomplete transaction page without cursor progress workchain=%d shard=%d seqno=%d", shard.Workchain, shard.Shard, shard.SeqNo)
|
|
||||||
}
|
|
||||||
after = shorts[len(shorts)-1].ID3()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *tonJettonWalletCache) jettonWalletTokens(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, owner *address.Address, tokens []mdb.ChainToken) (map[string]mdb.ChainToken, error) {
|
|
||||||
out := make(map[string]mdb.ChainToken, len(tokens))
|
|
||||||
ownerRaw := addressutil.TonRawAddressObjectKey(owner)
|
|
||||||
for _, token := range tokens {
|
|
||||||
masterParsed, err := addressutil.ParseTonMainnetAddress(token.ContractAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid jetton master symbol=%s address=%s: %w", token.Symbol, token.ContractAddress, err)
|
|
||||||
}
|
|
||||||
cacheKey := masterParsed.Raw + "|" + ownerRaw
|
|
||||||
c.Lock()
|
|
||||||
walletRaw, ok := c.walletByOwnerToken[cacheKey]
|
|
||||||
c.Unlock()
|
|
||||||
if !ok {
|
|
||||||
wallet, err := jetton.NewJettonMasterClient(api, masterParsed.Address).GetJettonWalletAtBlock(ctx, owner, master)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("derive jetton wallet symbol=%s owner=%s: %w", token.Symbol, ownerRaw, err)
|
|
||||||
}
|
|
||||||
walletRaw = addressutil.TonRawAddressObjectKey(wallet.Address())
|
|
||||||
c.Lock()
|
|
||||||
c.walletByOwnerToken[cacheKey] = walletRaw
|
|
||||||
c.Unlock()
|
|
||||||
}
|
|
||||||
out[walletRaw] = token
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadTonWatchState() (*tonWatchState, error) {
|
|
||||||
walletRows, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTon)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
state := &tonWatchState{wallets: make(map[string]*address.Address)}
|
|
||||||
for _, row := range walletRows {
|
|
||||||
parsed, err := addressutil.ParseTonMainnetAddress(row.Address)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[TON] skip invalid wallet address=%s err=%v", row.Address, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state.wallets[parsed.Raw] = parsed.Address
|
|
||||||
}
|
|
||||||
for i := range tokens {
|
|
||||||
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
|
||||||
contract := strings.TrimSpace(tokens[i].ContractAddress)
|
|
||||||
switch {
|
|
||||||
case sym == service.TonNativeSymbol && contract == "":
|
|
||||||
token := tokens[i]
|
|
||||||
state.nativeToken = &token
|
|
||||||
case contract != "":
|
|
||||||
state.jettonTokens = append(state.jettonTokens, tokens[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return state, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonChainConfig() (*mdb.Chain, time.Duration) {
|
|
||||||
row, err := data.GetChainByNetwork(mdb.NetworkTon)
|
|
||||||
if err != nil || row.ID == 0 {
|
|
||||||
return nil, 5 * time.Second
|
|
||||||
}
|
|
||||||
interval := time.Duration(row.ScanIntervalSec) * time.Second
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = 5 * time.Second
|
|
||||||
}
|
|
||||||
return row, interval
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonWatchStateSignature(state *tonWatchState) string {
|
|
||||||
if state == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d|%t|%d|%s", len(state.wallets), state.nativeToken != nil, len(state.jettonTokens), tonWalletWorkchainSummary(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonWalletWorkchainSummary(state *tonWatchState) string {
|
|
||||||
if state == nil || len(state.wallets) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
counts := make(map[int32]int)
|
|
||||||
for _, wallet := range state.wallets {
|
|
||||||
if wallet == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
counts[wallet.Workchain()]++
|
|
||||||
}
|
|
||||||
return formatTonWorkchainCounts(counts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonShardWorkchainSummary(shards []*ton.BlockIDExt) string {
|
|
||||||
if len(shards) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
counts := make(map[int32]int)
|
|
||||||
for _, shard := range shards {
|
|
||||||
if shard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
counts[shard.Workchain]++
|
|
||||||
}
|
|
||||||
return formatTonWorkchainCounts(counts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tonShardCursorKey(shard *ton.BlockIDExt) string {
|
|
||||||
if shard == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d:%d", shard.Workchain, shard.Shard)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTonWorkchainCounts(counts map[int32]int) string {
|
|
||||||
if len(counts) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
workchains := make([]int, 0, len(counts))
|
|
||||||
for workchain := range counts {
|
|
||||||
workchains = append(workchains, int(workchain))
|
|
||||||
}
|
|
||||||
sort.Ints(workchains)
|
|
||||||
parts := make([]string, 0, len(workchains))
|
|
||||||
for _, workchain := range workchains {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d:%d", workchain, counts[int32(workchain)]))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortTonHash(raw []byte) string {
|
|
||||||
hash := hex.EncodeToString(raw)
|
|
||||||
if len(hash) > 16 {
|
|
||||||
return hash[:16]
|
|
||||||
}
|
|
||||||
if hash == "" {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return hash
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveTonLiteNode(excludeIDs ...uint64) (mdb.RpcNode, bool) {
|
|
||||||
node, err := data.SelectGeneralRpcNode(mdb.NetworkTon, mdb.RpcNodeTypeLite, excludeIDs...)
|
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
|
||||||
node.Url = strings.TrimSpace(node.Url)
|
|
||||||
if node.Url != "" {
|
|
||||||
return *node, true
|
|
||||||
}
|
|
||||||
log.Sugar.Errorf("[TON] rpc_nodes id=%d has empty url", node.ID)
|
|
||||||
return mdb.RpcNode{}, false
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TON] resolve lite rpc_nodes err=%v", err)
|
|
||||||
} else {
|
|
||||||
log.Sugar.Warn("[TON] no enabled lite RPC node configured in rpc_nodes")
|
|
||||||
}
|
|
||||||
return mdb.RpcNode{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectTonLiteAPI(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), tonScannerConnectTimeout)
|
|
||||||
defer cancel()
|
|
||||||
return service.ConnectTonLiteAPI(ctx, node.Url, tonScannerRequestTimeout, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordTonLiteNodeFailure(node mdb.RpcNode, reason string) {
|
|
||||||
data.RecordRpcFailure(mdb.NetworkTon)
|
|
||||||
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
|
||||||
nodeLabel := data.RpcNodeLogLabel(node)
|
|
||||||
if cooling {
|
|
||||||
log.Sugar.Warnf("[TON] lite node reached fail threshold (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Warnf("[TON] lite node failed (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold)
|
|
||||||
}
|
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"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/GMWalletApp/epusdt/model/service"
|
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/tlb"
|
|
||||||
"github.com/xssnick/tonutils-go/ton"
|
|
||||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
|
||||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeTonShardAPI struct {
|
|
||||||
ton.APIClientWrapped
|
|
||||||
shorts []ton.TransactionShortInfo
|
|
||||||
shortsByShardSeq map[uint32][]ton.TransactionShortInfo
|
|
||||||
incomplete bool
|
|
||||||
shards []*ton.BlockIDExt
|
|
||||||
shardsErr error
|
|
||||||
lookupBlocks map[uint32]*ton.BlockIDExt
|
|
||||||
lookupSeqs []uint32
|
|
||||||
txByLT map[uint64]*tlb.Transaction
|
|
||||||
fetchedLTs []uint64
|
|
||||||
fetchedAddrs []string
|
|
||||||
fetchedShardSeqNos []uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeTonScannerAPI struct {
|
|
||||||
ton.APIClientWrapped
|
|
||||||
head *ton.BlockIDExt
|
|
||||||
shards []*ton.BlockIDExt
|
|
||||||
shardsErr error
|
|
||||||
lookups []uint32
|
|
||||||
cancel context.CancelFunc
|
|
||||||
cancelAtLookupSeq uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonScannerAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
|
||||||
return f.head, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonScannerAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonScannerAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) {
|
|
||||||
f.lookups = append(f.lookups, seqno)
|
|
||||||
if f.cancel != nil && f.cancelAtLookupSeq == seqno {
|
|
||||||
f.cancel()
|
|
||||||
}
|
|
||||||
return &ton.BlockIDExt{
|
|
||||||
Workchain: workchain,
|
|
||||||
Shard: shard,
|
|
||||||
SeqNo: seqno,
|
|
||||||
RootHash: bytes.Repeat([]byte{byte(seqno)}, 32),
|
|
||||||
FileHash: bytes.Repeat([]byte{byte(seqno + 1)}, 32),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonScannerAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) {
|
|
||||||
if f.shardsErr != nil {
|
|
||||||
return nil, f.shardsErr
|
|
||||||
}
|
|
||||||
return f.shards, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) GetBlockTransactionsV2(_ context.Context, block *ton.BlockIDExt, _ uint32, _ ...*ton.TransactionID3) ([]ton.TransactionShortInfo, bool, error) {
|
|
||||||
if block != nil {
|
|
||||||
f.fetchedShardSeqNos = append(f.fetchedShardSeqNos, block.SeqNo)
|
|
||||||
}
|
|
||||||
return f.blockShorts(block), f.incomplete, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) blockShorts(block *ton.BlockIDExt) []ton.TransactionShortInfo {
|
|
||||||
if block != nil && f.shortsByShardSeq != nil {
|
|
||||||
if shorts, ok := f.shortsByShardSeq[block.SeqNo]; ok {
|
|
||||||
return shorts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f.shorts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) {
|
|
||||||
if f.shardsErr != nil {
|
|
||||||
return nil, f.shardsErr
|
|
||||||
}
|
|
||||||
return f.shards, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) {
|
|
||||||
f.lookupSeqs = append(f.lookupSeqs, seqno)
|
|
||||||
if f.lookupBlocks != nil {
|
|
||||||
if block, ok := f.lookupBlocks[seqno]; ok {
|
|
||||||
return block, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ton.BlockIDExt{Workchain: workchain, Shard: shard, SeqNo: seqno}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTonShardAPI) GetTransaction(_ context.Context, _ *ton.BlockIDExt, addr *address.Address, lt uint64) (*tlb.Transaction, error) {
|
|
||||||
f.fetchedLTs = append(f.fetchedLTs, lt)
|
|
||||||
f.fetchedAddrs = append(f.fetchedAddrs, addressutil.TonRawAddressObjectKey(addr))
|
|
||||||
tx := f.txByLT[lt]
|
|
||||||
if tx == nil {
|
|
||||||
return nil, fmt.Errorf("missing tx lt=%d", lt)
|
|
||||||
}
|
|
||||||
return tx, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedTonScannerWallet(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"); err != nil {
|
|
||||||
t.Fatalf("seed TON wallet: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveTonLiteNodeSkipsCoolingPrimary(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
data.ResetRpcFailoverForTest()
|
|
||||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
|
||||||
|
|
||||||
rows := []mdb.RpcNode{
|
|
||||||
{Network: mdb.NetworkTon, Url: " https://primary-ton.example.com/global.config.json ", Type: mdb.RpcNodeTypeLite, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
|
||||||
{Network: mdb.NetworkTon, Url: "https://backup-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
|
||||||
{Network: mdb.NetworkTon, Url: "https://manual-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1000, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
|
||||||
{Network: mdb.NetworkTon, Url: "https://http-ton.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 2000, 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 := resolveTonLiteNode()
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("resolveTonLiteNode() ok=false, want true")
|
|
||||||
}
|
|
||||||
if primary.Url != "https://primary-ton.example.com/global.config.json" {
|
|
||||||
t.Fatalf("primary node = %#v, want trimmed primary lite node", primary)
|
|
||||||
}
|
|
||||||
for i := 0; i < data.RpcFailoverThreshold; i++ {
|
|
||||||
data.RecordRpcNodeFailure(primary.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, ok := resolveTonLiteNode()
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("resolveTonLiteNode() after cooldown ok=false, want true")
|
|
||||||
}
|
|
||||||
if got.Url != "https://backup-ton.example.com/global.config.json" {
|
|
||||||
t.Fatalf("resolveTonLiteNode() after cooldown = %#v, want backup lite node", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerInitializesCursorAtCurrentHead(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
api := &fakeTonScannerAPI{head: &ton.BlockIDExt{
|
|
||||||
Workchain: address.MasterchainID,
|
|
||||||
Shard: tonMasterShard,
|
|
||||||
SeqNo: 77,
|
|
||||||
RootHash: bytes.Repeat([]byte{0x77}, 32),
|
|
||||||
FileHash: bytes.Repeat([]byte{0x78}, 32),
|
|
||||||
}}
|
|
||||||
cursor := &tonRuntimeCursor{}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner(): %v", err)
|
|
||||||
}
|
|
||||||
if !cursor.initialized {
|
|
||||||
t.Fatal("cursor not initialized")
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 77 {
|
|
||||||
t.Fatalf("cursor seqno = %d, want 77", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
if len(api.lookups) != 0 {
|
|
||||||
t.Fatalf("initial cursor should not backfill, lookups=%#v", api.lookups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerIdleStatesPreserveInitializedCursor(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func(t *testing.T)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "chain disabled",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"enabled": false}); err != nil {
|
|
||||||
t.Fatalf("disable TON chain: %v", err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no enabled wallets",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
tt.setup(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner(): %v", err)
|
|
||||||
}
|
|
||||||
if !cursor.initialized || cursor.lastMasterSeqNo != 10 {
|
|
||||||
t.Fatalf("cursor changed during idle state: initialized=%v seqno=%d, want initialized=true seqno=10", cursor.initialized, cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerDoesNotAdvanceCursorOnMasterProcessingFailure(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
|
||||||
shardsErr: errors.New("shard fetch failed"),
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
|
||||||
err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected scanner failure")
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 10 {
|
|
||||||
t.Fatalf("cursor advanced after failed block: %d, want 10", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
api = &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
|
||||||
shards: nil,
|
|
||||||
}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner retry: %v", err)
|
|
||||||
}
|
|
||||||
if len(api.lookups) != 1 || api.lookups[0] != 11 {
|
|
||||||
t.Fatalf("retry lookups = %#v, want [11]", api.lookups)
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 11 {
|
|
||||||
t.Fatalf("cursor seqno after retry = %d, want 11", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerDoesNotMarkCursorInitializedOnInitialShardFailure(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
|
||||||
shardsErr: errors.New("initial shard fetch failed"),
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{}
|
|
||||||
err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected scanner failure")
|
|
||||||
}
|
|
||||||
if cursor.initialized {
|
|
||||||
t.Fatal("cursor was marked initialized after failed initial shard cursor setup")
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 0 {
|
|
||||||
t.Fatalf("cursor master seqno = %d, want 0", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerRespectsMinConfirmations(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"min_confirmations": 3}); err != nil {
|
|
||||||
t.Fatalf("set TON min confirmations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 12},
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.lookups) != 0 {
|
|
||||||
t.Fatalf("scanner processed unconfirmed blocks, lookups=%#v", api.lookups)
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 10 {
|
|
||||||
t.Fatalf("cursor seqno = %d, want 10", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerCatchupProcessesAtMostFiftyMasterBlocksPerTick(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 100},
|
|
||||||
shards: nil,
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.lookups) != int(tonMaxMasterBlocksTick) {
|
|
||||||
t.Fatalf("lookup count = %d, want %d", len(api.lookups), tonMaxMasterBlocksTick)
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 51 {
|
|
||||||
t.Fatalf("cursor seqno = %d, want 51", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunTonScannerCatchupContinuesUntilConfirmedHead(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
seedTonScannerWallet(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
api := &fakeTonScannerAPI{
|
|
||||||
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 60},
|
|
||||||
shards: nil,
|
|
||||||
cancel: cancel,
|
|
||||||
cancelAtLookupSeq: 60,
|
|
||||||
}
|
|
||||||
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1}
|
|
||||||
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
|
||||||
t.Fatalf("runTonScanner(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.lookups) != 59 {
|
|
||||||
t.Fatalf("lookup count = %d, want 59", len(api.lookups))
|
|
||||||
}
|
|
||||||
if api.lookups[0] != 2 || api.lookups[len(api.lookups)-1] != 60 {
|
|
||||||
t.Fatalf("lookups = %#v, want continuous 2..60", api.lookups)
|
|
||||||
}
|
|
||||||
for i, seq := range api.lookups {
|
|
||||||
want := uint32(i + 2)
|
|
||||||
if seq != want {
|
|
||||||
t.Fatalf("lookup[%d] = %d, want %d", i, seq, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cursor.lastMasterSeqNo != 60 {
|
|
||||||
t.Fatalf("cursor seqno = %d, want 60", cursor.lastMasterSeqNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonShardBlockFetchesOnlyWatchedWalletTransactions(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
unwatched := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x77}, 32)).Bounce(false).Testnet(false)
|
|
||||||
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
|
||||||
|
|
||||||
tx := &tlb.Transaction{
|
|
||||||
LT: 2,
|
|
||||||
Now: uint32(time.Now().Unix()),
|
|
||||||
Hash: bytes.Repeat([]byte{0x99}, 32),
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: sender,
|
|
||||||
DstAddr: receive,
|
|
||||||
Amount: tlb.MustFromTON("1"),
|
|
||||||
Body: cell.BeginCell().EndCell(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
api := &fakeTonShardAPI{
|
|
||||||
shorts: []ton.TransactionShortInfo{
|
|
||||||
{Account: unwatched.Data(), LT: 1, Hash: bytes.Repeat([]byte{0x01}, 32)},
|
|
||||||
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
|
||||||
},
|
|
||||||
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
|
||||||
}
|
|
||||||
state := &tonWatchState{
|
|
||||||
wallets: map[string]*address.Address{
|
|
||||||
addressutil.TonRawAddressObjectKey(receive): receive,
|
|
||||||
},
|
|
||||||
nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
|
||||||
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("processTonShardBlock(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
|
||||||
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
|
||||||
}
|
|
||||||
if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) {
|
|
||||||
t.Fatalf("fetched addresses = %#v, want watched receive address", api.fetchedAddrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonMasterBlockWithCursorScansSkippedShardBlocks(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
|
||||||
txHash := bytes.Repeat([]byte{0x42}, 32)
|
|
||||||
tx := &tlb.Transaction{
|
|
||||||
LT: 88,
|
|
||||||
Now: uint32(time.Now().Unix()),
|
|
||||||
Hash: txHash,
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: sender,
|
|
||||||
DstAddr: receive,
|
|
||||||
Amount: tlb.MustFromTON("1"),
|
|
||||||
Body: cell.BeginCell().EndCell(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
currentShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 89}
|
|
||||||
skippedShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 88}
|
|
||||||
api := &fakeTonShardAPI{
|
|
||||||
shards: []*ton.BlockIDExt{currentShard},
|
|
||||||
lookupBlocks: map[uint32]*ton.BlockIDExt{88: skippedShard},
|
|
||||||
shortsByShardSeq: map[uint32][]ton.TransactionShortInfo{
|
|
||||||
88: {{Account: receive.Data(), LT: tx.LT, Hash: txHash}},
|
|
||||||
89: nil,
|
|
||||||
},
|
|
||||||
txByLT: map[uint64]*tlb.Transaction{tx.LT: tx},
|
|
||||||
}
|
|
||||||
state := &tonWatchState{
|
|
||||||
wallets: map[string]*address.Address{
|
|
||||||
addressutil.TonRawAddressObjectKey(receive): receive,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cursorKey := tonShardCursorKey(currentShard)
|
|
||||||
cursor := &tonRuntimeCursor{
|
|
||||||
initialized: true,
|
|
||||||
lastMasterSeqNo: 10,
|
|
||||||
shardSeqNo: map[string]uint32{cursorKey: 87},
|
|
||||||
}
|
|
||||||
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
err := processTonMasterBlockWithCursor(context.Background(), api, master, state, newTonJettonWalletCache(), cursor)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("processTonMasterBlockWithCursor(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.lookupSeqs) != 1 || api.lookupSeqs[0] != 88 {
|
|
||||||
t.Fatalf("lookup shard seqs = %#v, want [88]", api.lookupSeqs)
|
|
||||||
}
|
|
||||||
if len(api.fetchedShardSeqNos) != 2 || api.fetchedShardSeqNos[0] != 88 || api.fetchedShardSeqNos[1] != 89 {
|
|
||||||
t.Fatalf("fetched shard seqs = %#v, want [88 89]", api.fetchedShardSeqNos)
|
|
||||||
}
|
|
||||||
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != tx.LT {
|
|
||||||
t.Fatalf("fetched LTs = %#v, want [%d]", api.fetchedLTs, tx.LT)
|
|
||||||
}
|
|
||||||
if cursor.shardSeqNo[cursorKey] != 89 {
|
|
||||||
t.Fatalf("shard cursor = %d, want 89", cursor.shardSeqNo[cursorKey])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonShardBlockMatchesJettonUSDTOrder(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
|
||||||
t.Fatalf("set forced rate: %v", err)
|
|
||||||
}
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, receive.StringRaw()); err != nil {
|
|
||||||
t.Fatalf("add ton wallet: %v", err)
|
|
||||||
}
|
|
||||||
resp, err := service.CreateTransaction(&request.CreateTransactionRequest{
|
|
||||||
OrderId: "order_ton_usdt_shard_1",
|
|
||||||
Currency: "CNY",
|
|
||||||
Token: "USDT",
|
|
||||||
Network: mdb.NetworkTon,
|
|
||||||
Amount: 10,
|
|
||||||
NotifyUrl: "https://93.184.216.34/callback",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create TON USDT order: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x31}, 32)).Bounce(false).Testnet(false)
|
|
||||||
jettonWallet := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
|
||||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
|
||||||
QueryID: 7,
|
|
||||||
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
|
||||||
Sender: sender,
|
|
||||||
ForwardPayload: cell.BeginCell().EndCell(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build jetton notification body: %v", err)
|
|
||||||
}
|
|
||||||
tx := &tlb.Transaction{
|
|
||||||
LT: 22,
|
|
||||||
Now: uint32(time.Now().Add(time.Second).Unix()),
|
|
||||||
Hash: bytes.Repeat([]byte{0x42}, 32),
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: jettonWallet,
|
|
||||||
DstAddr: receive,
|
|
||||||
Amount: tlb.FromNanoTONU(1),
|
|
||||||
Body: body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
usdtMaster, err := addressutil.ParseTonMainnetAddress("0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse USDT jetton master: %v", err)
|
|
||||||
}
|
|
||||||
cache := newTonJettonWalletCache()
|
|
||||||
ownerRaw := addressutil.TonRawAddressObjectKey(receive)
|
|
||||||
cache.walletByOwnerToken[usdtMaster.Raw+"|"+ownerRaw] = addressutil.TonRawAddressObjectKey(jettonWallet)
|
|
||||||
|
|
||||||
api := &fakeTonShardAPI{
|
|
||||||
shorts: []ton.TransactionShortInfo{
|
|
||||||
{Account: receive.Data(), LT: tx.LT, Hash: tx.Hash},
|
|
||||||
},
|
|
||||||
txByLT: map[uint64]*tlb.Transaction{tx.LT: tx},
|
|
||||||
}
|
|
||||||
state := &tonWatchState{
|
|
||||||
wallets: map[string]*address.Address{
|
|
||||||
ownerRaw: receive,
|
|
||||||
},
|
|
||||||
jettonTokens: []mdb.ChainToken{
|
|
||||||
{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: usdtMaster.Raw, Decimals: 6, Enabled: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
|
||||||
if err := processTonShardBlock(context.Background(), api, master, shard, state, cache); err != nil {
|
|
||||||
t.Fatalf("processTonShardBlock(): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusPaySuccess {
|
|
||||||
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
|
||||||
}
|
|
||||||
wantBlockID := service.TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
|
||||||
if order.BlockTransactionId != wantBlockID {
|
|
||||||
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, wantBlockID)
|
|
||||||
}
|
|
||||||
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive.StringRaw(), "USDT", resp.ActualAmount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lookup TON USDT lock: %v", err)
|
|
||||||
}
|
|
||||||
if lock != "" {
|
|
||||||
t.Fatalf("TON USDT lock still exists after shard processing: %s", lock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonMasterBlockScansMasterchainWalletTransactions(t *testing.T) {
|
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
masterchainID := address.MasterchainID
|
|
||||||
masterchainByte := byte(int8(masterchainID))
|
|
||||||
receive := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
|
||||||
sender := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
|
||||||
tx := &tlb.Transaction{
|
|
||||||
LT: 2,
|
|
||||||
Now: uint32(time.Now().Unix()),
|
|
||||||
Hash: bytes.Repeat([]byte{0x99}, 32),
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: sender,
|
|
||||||
DstAddr: receive,
|
|
||||||
Amount: tlb.MustFromTON("1"),
|
|
||||||
Body: cell.BeginCell().EndCell(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
api := &fakeTonShardAPI{
|
|
||||||
shorts: []ton.TransactionShortInfo{
|
|
||||||
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
|
||||||
},
|
|
||||||
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
|
||||||
}
|
|
||||||
state := &tonWatchState{
|
|
||||||
wallets: map[string]*address.Address{
|
|
||||||
addressutil.TonRawAddressObjectKey(receive): receive,
|
|
||||||
},
|
|
||||||
nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
if err := processTonMasterBlock(context.Background(), api, master, state, newTonJettonWalletCache()); err != nil {
|
|
||||||
t.Fatalf("processTonMasterBlock(): %v", err)
|
|
||||||
}
|
|
||||||
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
|
||||||
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
|
||||||
}
|
|
||||||
if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) {
|
|
||||||
t.Fatalf("fetched addresses = %#v, want masterchain receive address", api.fetchedAddrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonShardBlockFailsOnIncompleteEmptyPage(t *testing.T) {
|
|
||||||
api := &fakeTonShardAPI{incomplete: true}
|
|
||||||
state := &tonWatchState{wallets: map[string]*address.Address{}}
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
shard := &ton.BlockIDExt{Workchain: 0, Shard: 0, SeqNo: 9}
|
|
||||||
|
|
||||||
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected incomplete empty page error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTonShardBlockFailsWhenJettonWalletsCannotBeResolved(t *testing.T) {
|
|
||||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
|
||||||
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
|
||||||
tx := &tlb.Transaction{
|
|
||||||
LT: 2,
|
|
||||||
Now: uint32(time.Now().Unix()),
|
|
||||||
Hash: bytes.Repeat([]byte{0x99}, 32),
|
|
||||||
IO: struct {
|
|
||||||
In *tlb.Message `tlb:"maybe ^"`
|
|
||||||
Out *tlb.MessagesList `tlb:"maybe ^"`
|
|
||||||
}{
|
|
||||||
In: &tlb.Message{
|
|
||||||
MsgType: tlb.MsgTypeInternal,
|
|
||||||
Msg: &tlb.InternalMessage{
|
|
||||||
SrcAddr: sender,
|
|
||||||
DstAddr: receive,
|
|
||||||
Amount: tlb.MustFromTON("1"),
|
|
||||||
Body: cell.BeginCell().EndCell(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
api := &fakeTonShardAPI{
|
|
||||||
shorts: []ton.TransactionShortInfo{
|
|
||||||
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
|
||||||
},
|
|
||||||
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
|
||||||
}
|
|
||||||
state := &tonWatchState{
|
|
||||||
wallets: map[string]*address.Address{
|
|
||||||
addressutil.TonRawAddressObjectKey(receive): receive,
|
|
||||||
},
|
|
||||||
jettonTokens: []mdb.ChainToken{
|
|
||||||
{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "not-a-ton-address", Decimals: 6, Enabled: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
|
||||||
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
|
||||||
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected jetton wallet resolution failure")
|
|
||||||
}
|
|
||||||
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
|
||||||
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,32 +167,19 @@ func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
|
|||||||
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
|
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
|
||||||
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
|
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkTron)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var block Block
|
var block Block
|
||||||
if err := json.Unmarshal(b, &block); err != nil {
|
return &block, json.Unmarshal(b, &block)
|
||||||
data.RecordRpcFailure(mdb.NetworkTron)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkTron)
|
|
||||||
data.RecordRpcBlockHeight(mdb.NetworkTron, block.BlockHeader.RawData.Number)
|
|
||||||
return &block, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
|
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
|
||||||
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
|
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.RecordRpcFailure(mdb.NetworkTron)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var block Block
|
var block Block
|
||||||
if err := json.Unmarshal(b, &block); err != nil {
|
return &block, json.Unmarshal(b, &block)
|
||||||
data.RecordRpcFailure(mdb.NetworkTron)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data.RecordRpcSuccess(mdb.NetworkTron)
|
|
||||||
return &block, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTronTRC20TokenMap() map[string]mdb.ChainToken {
|
func loadTronTRC20TokenMap() map[string]mdb.ChainToken {
|
||||||
@@ -350,8 +337,6 @@ func processBlock(block *Block, tokenMap map[string]mdb.ChainToken) {
|
|||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
nodeID uint64
|
|
||||||
nodeLabel string
|
|
||||||
lastBlock int64
|
lastBlock int64
|
||||||
// 统计
|
// 统计
|
||||||
totalBlocks int64
|
totalBlocks int64
|
||||||
@@ -364,16 +349,16 @@ func NewScanner() *Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) Init() error {
|
func (s *Scanner) Init() error {
|
||||||
node, err := service.ResolveTronRpcNode()
|
baseURL, apiKey, err := service.ResolveTronNode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve tron node: %w", err)
|
return fmt.Errorf("resolve tron node: %w", err)
|
||||||
}
|
}
|
||||||
s.useRpcNode(node)
|
s.baseURL = baseURL
|
||||||
|
s.apiKey = apiKey
|
||||||
|
|
||||||
log.Sugar.Infof("[TRON-BLOCK] using RPC node %s", s.nodeLabel)
|
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
|
||||||
block, err := GetNowBlock(s.baseURL, s.apiKey)
|
block, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.recordRpcFailure("initial get latest block")
|
|
||||||
return fmt.Errorf("获取初始块失败: %w", err)
|
return fmt.Errorf("获取初始块失败: %w", err)
|
||||||
}
|
}
|
||||||
s.lastBlock = block.BlockHeader.RawData.Number
|
s.lastBlock = block.BlockHeader.RawData.Number
|
||||||
@@ -381,13 +366,6 @@ func (s *Scanner) Init() error {
|
|||||||
return nil
|
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() {
|
func (s *Scanner) Run() {
|
||||||
log.Sugar.Info("[TRON-BLOCK] start scanning (TRC20 + TRX)")
|
log.Sugar.Info("[TRON-BLOCK] start scanning (TRC20 + TRX)")
|
||||||
ticker := time.NewTicker(PollInterval)
|
ticker := time.NewTicker(PollInterval)
|
||||||
@@ -410,17 +388,14 @@ func (s *Scanner) poll() {
|
|||||||
latest, err := GetNowBlock(s.baseURL, s.apiKey)
|
latest, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
|
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
|
||||||
s.recordRpcFailure("get latest block")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
latestNum := latest.BlockHeader.RawData.Number
|
latestNum := latest.BlockHeader.RawData.Number
|
||||||
if latestNum <= s.lastBlock {
|
if latestNum <= s.lastBlock {
|
||||||
data.RecordRpcNodeSuccess(s.nodeID)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenMap := loadTronTRC20TokenMap()
|
tokenMap := loadTronTRC20TokenMap()
|
||||||
hadBlockFetchError := false
|
|
||||||
for num := s.lastBlock + 1; num <= latestNum; num++ {
|
for num := s.lastBlock + 1; num <= latestNum; num++ {
|
||||||
var block *Block
|
var block *Block
|
||||||
if num == latestNum {
|
if num == latestNum {
|
||||||
@@ -429,8 +404,7 @@ func (s *Scanner) poll() {
|
|||||||
block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
|
block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
|
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
|
||||||
hadBlockFetchError = true
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
}
|
}
|
||||||
@@ -446,29 +420,6 @@ 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() {
|
func StartTronBlockScannerListener() {
|
||||||
|
|||||||
@@ -1,176 +1,23 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
func TestTronAddressToHexConvertsBase58Contract(t *testing.T) {
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
got, err := TronAddressToHex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
if err != nil {
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
t.Fatalf("TronAddressToHex(): %v", err)
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := NewScanner()
|
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
scanner.useRpcNode(primary)
|
if got != want {
|
||||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
t.Fatalf("TronAddressToHex() = %q, want %q", got, want)
|
||||||
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 TestTronScannerStopsOnHistoricalBlockFetchError(t *testing.T) {
|
func TestNormalizeTronHexAddress(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
got := normalizeTronHexAddress("0X41A614F803B6FD780986A42C78EC9C7F77E6DED13C")
|
||||||
defer cleanup()
|
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
data.ResetRpcFailoverForTest()
|
if got != want {
|
||||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
t.Fatalf("normalizeTronHexAddress() = %q, want %q", got, want)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTronRPCRecordsRuntimeStats(t *testing.T) {
|
|
||||||
data.ResetRpcRuntimeStatsForTest()
|
|
||||||
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
||||||
|
|
||||||
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": 10,
|
|
||||||
"timestamp": int64(1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"transactions": []interface{}{},
|
|
||||||
})
|
|
||||||
case "/wallet/getblockbynum":
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"block_header": map[string]interface{}{
|
|
||||||
"raw_data": map[string]interface{}{
|
|
||||||
"number": 9,
|
|
||||||
"timestamp": int64(1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"transactions": []interface{}{},
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
if _, err := GetNowBlock(server.URL, ""); err != nil {
|
|
||||||
t.Fatalf("GetNowBlock(): %v", err)
|
|
||||||
}
|
|
||||||
if _, err := GetBlockByNum(server.URL, "", 9); err != nil {
|
|
||||||
t.Fatalf("GetBlockByNum(): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "temporary", http.StatusBadGateway)
|
|
||||||
}))
|
|
||||||
defer failing.Close()
|
|
||||||
if _, err := GetBlockByNum(failing.URL, "", 8); err == nil {
|
|
||||||
t.Fatal("GetBlockByNum() error = nil, want failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := data.SnapshotRpcRuntimeStats()
|
|
||||||
tron := stats[mdb.NetworkTron]
|
|
||||||
if tron.SuccessCount != 2 {
|
|
||||||
t.Fatalf("tron success_count = %d, want 2", tron.SuccessCount)
|
|
||||||
}
|
|
||||||
if tron.FailureCount != 1 {
|
|
||||||
t.Fatalf("tron failure_count = %d, want 1", tron.FailureCount)
|
|
||||||
}
|
|
||||||
if tron.LatestBlockHeight != 10 {
|
|
||||||
t.Fatalf("tron latest_block_height = %d, want 10", tron.LatestBlockHeight)
|
|
||||||
}
|
|
||||||
if tron.LastSyncAt.IsZero() {
|
|
||||||
t.Fatal("tron last_sync_at is zero")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
|
|
||||||
const rpcProbeTimeout = 5 * time.Second
|
const rpcProbeTimeout = 5 * time.Second
|
||||||
|
|
||||||
// RpcHealthJob periodically probes enabled general/both rpc_nodes rows.
|
// RpcHealthJob periodically probes every enabled rpc_nodes row and
|
||||||
// Manual verification nodes are left for on-demand admin checks so paid
|
// writes status/last_latency_ms. Results drive SelectRpcNode weighted
|
||||||
// endpoints are not consumed by the scheduler.
|
// picking at runtime.
|
||||||
type RpcHealthJob struct{}
|
type RpcHealthJob struct{}
|
||||||
|
|
||||||
var gRpcHealthJobLock sync.Mutex
|
var gRpcHealthJobLock sync.Mutex
|
||||||
@@ -25,7 +25,7 @@ func (r RpcHealthJob) Run() {
|
|||||||
gRpcHealthJobLock.Lock()
|
gRpcHealthJobLock.Lock()
|
||||||
defer gRpcHealthJobLock.Unlock()
|
defer gRpcHealthJobLock.Unlock()
|
||||||
|
|
||||||
nodes, err := data.ListRpcNodesForHealth()
|
nodes, err := data.ListRpcNodes("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
|
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
"github.com/gagliardetto/solana-go"
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
@@ -54,9 +53,6 @@ func isValidAddressByNetwork(network, addr string) bool {
|
|||||||
return isValidTronAddress(addr)
|
return isValidTronAddress(addr)
|
||||||
case mdb.NetworkSolana:
|
case mdb.NetworkSolana:
|
||||||
return isValidSolanaAddress(addr)
|
return isValidSolanaAddress(addr)
|
||||||
case mdb.NetworkTon:
|
|
||||||
_, err := addressutil.ParseTonMainnetAddress(addr)
|
|
||||||
return err == nil
|
|
||||||
default:
|
default:
|
||||||
// 其余 EVM 链统一使用 0x 地址校验
|
// 其余 EVM 链统一使用 0x 地址校验
|
||||||
return isValidEthereumAddress(addr)
|
return isValidEthereumAddress(addr)
|
||||||
@@ -68,11 +64,6 @@ func normalizeWalletAddressByNetwork(network, addr string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
case mdb.NetworkTron, mdb.NetworkSolana:
|
case mdb.NetworkTron, mdb.NetworkSolana:
|
||||||
return addr
|
return addr
|
||||||
case mdb.NetworkTon:
|
|
||||||
if normalized, err := addressutil.NormalizeTonAddress(addr); err == nil {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
default:
|
default:
|
||||||
return strings.ToLower(addr)
|
return strings.ToLower(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
package addressutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
"github.com/xssnick/tonutils-go/crc16"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TonParsedAddress carries the canonical display and raw forms used by epusdt.
|
|
||||||
type TonParsedAddress struct {
|
|
||||||
Address *address.Address
|
|
||||||
Display string
|
|
||||||
Raw string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseTonMainnetAddress accepts raw and user-friendly TON addresses and rejects
|
|
||||||
// user-friendly testnet-only addresses. Raw addresses do not contain a testnet
|
|
||||||
// flag, so they are treated as mainnet addresses by definition.
|
|
||||||
func ParseTonMainnetAddress(input string) (*TonParsedAddress, error) {
|
|
||||||
raw := strings.TrimSpace(input)
|
|
||||||
if raw == "" {
|
|
||||||
return nil, fmt.Errorf("ton address is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
addr *address.Address
|
|
||||||
err error
|
|
||||||
checkFlags bool
|
|
||||||
)
|
|
||||||
if strings.Contains(raw, ":") {
|
|
||||||
addr, err = address.ParseRawAddr(raw)
|
|
||||||
} else {
|
|
||||||
addr, err = parseUserFriendly(raw)
|
|
||||||
checkFlags = true
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if addr == nil || addr.Type() != address.StdAddress || addr.IsAddrNone() {
|
|
||||||
return nil, fmt.Errorf("unsupported ton address type")
|
|
||||||
}
|
|
||||||
if checkFlags && addr.IsTestnetOnly() {
|
|
||||||
return nil, fmt.Errorf("testnet-only ton address is not supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
mainnet := addr.Bounce(false).Testnet(false)
|
|
||||||
return &TonParsedAddress{
|
|
||||||
Address: mainnet,
|
|
||||||
Display: mainnet.String(),
|
|
||||||
Raw: strings.ToLower(mainnet.StringRaw()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeTonAddress returns the canonical user-friendly address stored in DB.
|
|
||||||
func NormalizeTonAddress(input string) (string, error) {
|
|
||||||
parsed, err := ParseTonMainnetAddress(input)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return parsed.Display, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TonRawAddressKey returns the workchain:hash key used for comparisons and locks.
|
|
||||||
func TonRawAddressKey(input string) (string, error) {
|
|
||||||
parsed, err := ParseTonMainnetAddress(input)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return parsed.Raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeTonAddressObject returns the canonical user-friendly form for a parsed
|
|
||||||
// address object.
|
|
||||||
func NormalizeTonAddressObject(addr *address.Address) string {
|
|
||||||
if addr == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return addr.Bounce(false).Testnet(false).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TonRawAddressObjectKey returns the raw workchain:hash key for a parsed address.
|
|
||||||
func TonRawAddressObjectKey(addr *address.Address) string {
|
|
||||||
if addr == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.ToLower(addr.Bounce(false).Testnet(false).StringRaw())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TonAddressFromShortAccount builds a standard TON address from a shard
|
|
||||||
// transaction account id returned by liteserver block transaction lists.
|
|
||||||
func TonAddressFromShortAccount(workchain int32, account []byte) *address.Address {
|
|
||||||
if len(account) != 32 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return address.NewAddress(0, byte(workchain), append([]byte(nil), account...)).Bounce(false).Testnet(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUserFriendly(input string) (*address.Address, error) {
|
|
||||||
if addr, err := address.ParseAddr(input); err == nil {
|
|
||||||
return addr, nil
|
|
||||||
}
|
|
||||||
for _, enc := range []*base64.Encoding{
|
|
||||||
base64.RawStdEncoding,
|
|
||||||
base64.StdEncoding,
|
|
||||||
base64.URLEncoding,
|
|
||||||
} {
|
|
||||||
data, err := enc.DecodeString(input)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr, err := addressFromFriendlyBytes(data)
|
|
||||||
if err == nil {
|
|
||||||
return addr, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("invalid ton address")
|
|
||||||
}
|
|
||||||
|
|
||||||
func addressFromFriendlyBytes(data []byte) (*address.Address, error) {
|
|
||||||
if len(data) != 36 {
|
|
||||||
return nil, fmt.Errorf("incorrect ton address data length")
|
|
||||||
}
|
|
||||||
want := binary.BigEndian.Uint16(data[34:])
|
|
||||||
if crc16.ChecksumXMODEM(data[:34]) != want {
|
|
||||||
return nil, fmt.Errorf("invalid ton address checksum")
|
|
||||||
}
|
|
||||||
if len(data[2:34]) != 32 {
|
|
||||||
return nil, fmt.Errorf("incorrect ton address hash length")
|
|
||||||
}
|
|
||||||
return address.NewAddress(data[0], data[1], append([]byte(nil), data[2:34]...)), nil
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package addressutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/xssnick/tonutils-go/address"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testTonBounceable = "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
|
||||||
|
|
||||||
func TestParseTonMainnetAddressNormalizesSupportedFormats(t *testing.T) {
|
|
||||||
base := address.MustParseAddr(testTonBounceable)
|
|
||||||
raw := base.StringRaw()
|
|
||||||
nonBounce := base.Bounce(false).String()
|
|
||||||
rawBytes, err := base64.RawURLEncoding.DecodeString(testTonBounceable)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decode fixture: %v", err)
|
|
||||||
}
|
|
||||||
stdBase64 := base64.RawStdEncoding.EncodeToString(rawBytes)
|
|
||||||
|
|
||||||
for _, input := range []string{testTonBounceable, nonBounce, raw, stdBase64} {
|
|
||||||
parsed, err := ParseTonMainnetAddress(input)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTonMainnetAddress(%q): %v", input, err)
|
|
||||||
}
|
|
||||||
if parsed.Display != nonBounce {
|
|
||||||
t.Fatalf("display for %q = %q, want %q", input, parsed.Display, nonBounce)
|
|
||||||
}
|
|
||||||
if parsed.Raw != strings.ToLower(raw) {
|
|
||||||
t.Fatalf("raw for %q = %q, want %q", input, parsed.Raw, strings.ToLower(raw))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTonMainnetAddressRejectsTestnetOnly(t *testing.T) {
|
|
||||||
testnet := address.MustParseAddr(testTonBounceable).Testnet(true).String()
|
|
||||||
if _, err := ParseTonMainnetAddress(testnet); err == nil {
|
|
||||||
t.Fatal("expected testnet-only address to be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTonAddressFromShortAccount(t *testing.T) {
|
|
||||||
base := address.MustParseAddr(testTonBounceable)
|
|
||||||
got := TonAddressFromShortAccount(base.Workchain(), base.Data())
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("TonAddressFromShortAccount() returned nil")
|
|
||||||
}
|
|
||||||
if TonRawAddressObjectKey(got) != TonRawAddressObjectKey(base) {
|
|
||||||
t.Fatalf("raw address = %q, want %q", TonRawAddressObjectKey(got), TonRawAddressObjectKey(base))
|
|
||||||
}
|
|
||||||
if got = TonAddressFromShortAccount(base.Workchain(), base.Data()[:31]); got != nil {
|
|
||||||
t.Fatalf("TonAddressFromShortAccount() with short account = %#v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package constant
|
package constant
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
var Errno = map[int]string{
|
var Errno = map[int]string{
|
||||||
400: "system error",
|
400: "system error",
|
||||||
401: "signature verification failed",
|
401: "signature verification failed",
|
||||||
@@ -23,30 +21,6 @@ var Errno = map[int]string{
|
|||||||
10017: "payment provider is not enabled",
|
10017: "payment provider is not enabled",
|
||||||
10018: "payment provider configuration is incomplete",
|
10018: "payment provider configuration is incomplete",
|
||||||
10019: "payment provider does not support this token or network",
|
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 (
|
var (
|
||||||
@@ -70,30 +44,6 @@ var (
|
|||||||
PaymentProviderNotEnabled = Err(10017)
|
PaymentProviderNotEnabled = Err(10017)
|
||||||
PaymentProviderConfigErr = Err(10018)
|
PaymentProviderConfigErr = Err(10018)
|
||||||
PaymentProviderNotSupport = Err(10019)
|
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 {
|
type RspError struct {
|
||||||
@@ -117,14 +67,6 @@ func (re *RspError) Render() (code int, msg string) {
|
|||||||
return re.Code, re.Msg
|
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
|
// HttpStatus maps a RspError code to the HTTP status the handler
|
||||||
// should use on the wire. Small codes (< 1000) are treated as real
|
// should use on the wire. Small codes (< 1000) are treated as real
|
||||||
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
|
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/page"
|
"github.com/GMWalletApp/epusdt/util/page"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -66,12 +65,13 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
|
|||||||
func (r *Resp) FailJson(e echo.Context, err error) error {
|
func (r *Resp) FailJson(e echo.Context, err error) error {
|
||||||
rr := new(Response)
|
rr := new(Response)
|
||||||
httpStatus := http.StatusBadRequest
|
httpStatus := http.StatusBadRequest
|
||||||
var rspErr *constant.RspError
|
switch t := err.(type) {
|
||||||
if errors.As(err, &rspErr) {
|
case *constant.RspError:
|
||||||
rr.StatusCode, rr.Message = rspErr.Render()
|
rr.StatusCode, rr.Message = t.Render()
|
||||||
httpStatus = rspErr.HttpStatus()
|
httpStatus = t.HttpStatus()
|
||||||
} else {
|
default:
|
||||||
rr.StatusCode, rr.Message = constant.ResolveErrno(err)
|
rr.StatusCode = http.StatusBadRequest
|
||||||
|
rr.Message = err.Error()
|
||||||
}
|
}
|
||||||
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
||||||
return r.Json(e, httpStatus, &rr)
|
return r.Json(e, httpStatus, &rr)
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-80
@@ -2,9 +2,6 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/natefinch/lumberjack"
|
"github.com/natefinch/lumberjack"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -16,16 +13,11 @@ import (
|
|||||||
|
|
||||||
var Sugar *zap.SugaredLogger
|
var Sugar *zap.SugaredLogger
|
||||||
|
|
||||||
var (
|
|
||||||
atomicLevel = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
|
||||||
levelMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
atomicLevel.SetLevel(getLogLevel())
|
level := getLogLevel()
|
||||||
core := zapcore.NewTee(
|
core := zapcore.NewTee(
|
||||||
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), atomicLevel),
|
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
|
||||||
zapcore.NewCore(getFileEncoder(), getFileWriter(), atomicLevel),
|
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
|
||||||
)
|
)
|
||||||
logger := zap.New(core, zap.AddCaller())
|
logger := zap.New(core, zap.AddCaller())
|
||||||
Sugar = logger.Sugar()
|
Sugar = logger.Sugar()
|
||||||
@@ -64,75 +56,14 @@ func getConsoleWriter() zapcore.WriteSyncer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getLogLevel() zapcore.Level {
|
func getLogLevel() zapcore.Level {
|
||||||
level, _, err := parseLevel(config.LogLevel)
|
switch config.LogLevel {
|
||||||
if err != nil {
|
case "debug":
|
||||||
|
return zapcore.DebugLevel
|
||||||
|
case "warn":
|
||||||
|
return zapcore.WarnLevel
|
||||||
|
case "error":
|
||||||
|
return zapcore.ErrorLevel
|
||||||
|
default:
|
||||||
return zapcore.InfoLevel
|
return zapcore.InfoLevel
|
||||||
}
|
}
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetLevel(level string) error {
|
|
||||||
nextLevel, normalized, err := parseLevel(level)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
levelMu.Lock()
|
|
||||||
defer levelMu.Unlock()
|
|
||||||
|
|
||||||
currentLevel := atomicLevel.Level()
|
|
||||||
current := levelToString(currentLevel)
|
|
||||||
if current == normalized {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
message := fmt.Sprintf("[log] level changed: %s -> %s", current, normalized)
|
|
||||||
if Sugar != nil && currentLevel <= zapcore.WarnLevel {
|
|
||||||
Sugar.Warn(message)
|
|
||||||
}
|
|
||||||
atomicLevel.SetLevel(nextLevel)
|
|
||||||
if Sugar != nil && currentLevel > zapcore.WarnLevel {
|
|
||||||
Sugar.Warn(message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CurrentLevel() string {
|
|
||||||
return levelToString(atomicLevel.Level())
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizeLevel(level string) (string, error) {
|
|
||||||
_, normalized, err := parseLevel(level)
|
|
||||||
return normalized, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLevel(level string) (zapcore.Level, string, error) {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(level))
|
|
||||||
switch normalized {
|
|
||||||
case "debug":
|
|
||||||
return zapcore.DebugLevel, normalized, nil
|
|
||||||
case "info":
|
|
||||||
return zapcore.InfoLevel, normalized, nil
|
|
||||||
case "warn":
|
|
||||||
return zapcore.WarnLevel, normalized, nil
|
|
||||||
case "error":
|
|
||||||
return zapcore.ErrorLevel, normalized, nil
|
|
||||||
default:
|
|
||||||
return zapcore.InfoLevel, "", fmt.Errorf("unsupported log level %q", level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func levelToString(level zapcore.Level) string {
|
|
||||||
switch level {
|
|
||||||
case zapcore.DebugLevel:
|
|
||||||
return "debug"
|
|
||||||
case zapcore.InfoLevel:
|
|
||||||
return "info"
|
|
||||||
case zapcore.WarnLevel:
|
|
||||||
return "warn"
|
|
||||||
case zapcore.ErrorLevel:
|
|
||||||
return "error"
|
|
||||||
default:
|
|
||||||
return level.String()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"go.uber.org/zap/zapcore"
|
|
||||||
"go.uber.org/zap/zaptest/observer"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetLevelUpdatesAtomicLevelAndLogsChange(t *testing.T) {
|
|
||||||
oldSugar := Sugar
|
|
||||||
oldLevel := atomicLevel.Level()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
Sugar = oldSugar
|
|
||||||
atomicLevel.SetLevel(oldLevel)
|
|
||||||
})
|
|
||||||
|
|
||||||
core, observed := observer.New(zapcore.WarnLevel)
|
|
||||||
Sugar = zap.New(core).Sugar()
|
|
||||||
atomicLevel.SetLevel(zapcore.ErrorLevel)
|
|
||||||
|
|
||||||
if err := SetLevel(" DEBUG "); err != nil {
|
|
||||||
t.Fatalf("SetLevel(DEBUG): %v", err)
|
|
||||||
}
|
|
||||||
if got := CurrentLevel(); got != "debug" {
|
|
||||||
t.Fatalf("CurrentLevel = %q, want debug", got)
|
|
||||||
}
|
|
||||||
logs := observed.FilterMessage("[log] level changed: error -> debug").All()
|
|
||||||
if len(logs) != 1 {
|
|
||||||
t.Fatalf("level change logs = %d, want 1; all=%v", len(logs), observed.All())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SetLevel("debug"); err != nil {
|
|
||||||
t.Fatalf("SetLevel(debug no-op): %v", err)
|
|
||||||
}
|
|
||||||
logs = observed.FilterMessage("[log] level changed: error -> debug").All()
|
|
||||||
if len(logs) != 1 {
|
|
||||||
t.Fatalf("no-op level change emitted extra logs: %d", len(logs))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SetLevel("fatal"); err == nil {
|
|
||||||
t.Fatal("SetLevel(fatal) succeeded, want error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user