40 Commits

Author SHA1 Message Date
line-6000 6589ad10a6 feat(epctl): add upgrade restart controls and rollback-safe deploy 2026-06-29 22:55:36 +08:00
line-6000 33b39e9771 Merge pull request #92 from GMWalletApp/dev
Enhance ePay support and improve installation process
2026-06-29 18:09:33 +08:00
github-actions[bot] 642ea0ebe2 chore(www): sync frontend build 2026-06-29 09:35:39 +00:00
github-actions[bot] 3621a02414 chore(www): sync frontend build 2026-06-29 08:00:05 +00:00
github-actions[bot] 5e4dcfb539 chore(www): sync frontend build 2026-06-29 06:27:32 +00:00
line-6000 1cdb1edd86 feat: add epctl installer, docker validation script, and bilingual docs 2026-06-29 14:26:54 +08:00
line-6000 7d09adff20 feat: return initial admin password from install flow 2026-06-29 13:58:16 +08:00
line-6000 15de0e6940 auth: keep init password available until password change 2026-06-28 18:14:17 +08:00
line-6000 73aef80499 fix(epay): restrict submit type to alipay or supported selectors 2026-06-28 17:33:49 +08:00
line-6000 2dc538c0e1 feat(epay): support type selectors and preserve callback type 2026-06-28 11:56:58 +08:00
line-6000 b4d1a4d8d4 Merge pull request #91 from GMWalletApp/dev
Add Aptos USDT/USDC payment support and drive token RPC routing
2026-06-20 14:31:03 +08:00
line-6000 8216c91739 fix(aptos): drive token support and RPC routing from config 2026-06-20 14:17:42 +08:00
line-6000 de96a5d3e7 feat: add Aptos USDT/USDC payment support 2026-06-20 11:08:19 +08:00
line-6000 79ca1730cd Merge pull request #90 from GMWalletApp/dev
Add ePay return relay and simplify multi-architecture builds
2026-06-17 18:12:59 +08:00
github-actions[bot] ad7679b7fd chore(www): sync frontend build 2026-06-17 10:01:44 +00:00
line-6000 0215b2787e feat: add epay return relay and simplify multi-arch builds 2026-06-17 18:01:09 +08:00
line-6000 75dfeced01 Merge pull request #89 from GMWalletApp/dev
Support placeholder orders for GMPay and EPay with asset sync
2026-06-16 19:36:14 +08:00
github-actions[bot] bfdf453e3c chore(www): sync frontend build 2026-06-16 10:11:12 +00:00
line-6000 55cc6a9c61 ci: add manual workflow for syncing www assets 2026-06-16 18:10:33 +08:00
line-6000 3ab3659fe4 feat(payment): support placeholder orders for GMPay and EPay 2026-06-16 17:57:20 +08:00
line-6000 04c2b9b0b6 Merge pull request #87 from GMWalletApp/dev 2026-06-12 00:50:10 +08:00
line-6000 9ac9894dba feat: add runtime log level settings 2026-06-12 00:49:18 +08:00
line-6000 9b397bd45e feat: support TON and USDT Jetton payments
- add TON chain, token, RPC, address normalization, and order matching support
- add liteclient scanner with masterchain/shard catch-up and Jetton validation
- add TON manual payment verification and focused tests
2026-06-11 14:47:46 +08:00
line-6000 225cae2c24 Merge pull request #86 from GMWalletApp/dev
feat: add in-memory RPC runtime stats SSE endpoint
2026-06-10 00:19:38 +08:00
line-6000 08d409d5eb feat: add in-memory RPC runtime stats SSE endpoint 2026-06-10 00:18:24 +08:00
line-6000 54b83f8b16 Update README with security audit and screenshots
Added security audit section and program screenshots to README.
2026-06-03 21:43:17 +08:00
line-6000 3879853836 Add files 2026-06-03 21:28:24 +08:00
line-6000 2824687d9f Merge pull request #85 from GMWalletApp/dev
feat(www): enable RPC node create and edit actions
2026-06-03 21:01:05 +08:00
line-6000 3e85bf77c2 update www 2026-06-03 20:58:57 +08:00
line-6000 cd44c7c818 Merge pull request #82 from GMWalletApp/dev
Dev
2026-05-27 19:41:55 +08:00
line-6000 c69b0d6c0c feat: isolate manual verification RPC usage
- add rpc node purpose support for general/manual_verify/both
- keep scan, WSS and health checks away from manual_verify nodes
- fallback manual hash verification from general RPC to manual_verify RPC
- add public cashier hash submission path and errno responses
- update tests for RPC selection, failover and manual payment flows
2026-05-27 19:36:56 +08:00
line-6000 13c81ee560 feat(order): use crypto-random url-safe trade IDs and update docs examples 2026-05-27 02:59:25 +08:00
line-6000 c38f728e10 update www 2026-05-27 02:29:20 +08:00
line-6000 b70995f8e9 fix: aggregate dashboard trends by database dialect 2026-05-27 02:06:31 +08:00
line-6000 fca75a99ea feat(payment): allow cashier manual tx hash submission 2026-05-26 23:16:10 +08:00
line-6000 8bf2adad89 fix: reject private callback and rate API URLs 2026-05-26 20:54:41 +08:00
Astra3456 0d97237c71 Update supported systems in README 2026-05-26 00:20:53 +10:00
line-6000 c65b07cfe7 Merge pull request #81 from GMWalletApp/dev
docs: update payment API integration guide
2026-05-23 17:24:44 +08:00
line-6000 7fcb7b38fe docs: update payment API integration guide 2026-05-23 17:23:41 +08:00
line-6000 2a00a0332b Merge pull request #80 from GMWalletApp/dev
Dev
2026-05-22 23:14:35 +08:00
327 changed files with 17122 additions and 1286 deletions
+101
View File
@@ -0,0 +1,101 @@
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"
+10 -2
View File
@@ -10,7 +10,7 @@
<p align="center"> <p align="center">
<a href="./README.en.md">English</a> | <a href="./README.en.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a> <a href="./README.md">简体中文</a>
</p> </p>
<p align="center"> <p align="center">
@@ -45,6 +45,7 @@ Deploy it privately, integrate with the HTTP API, and start receiving crypto pay
| **Solana** | USDT, USDC | | **Solana** | USDT, USDC |
| **BEP20** (BSC) | USDT, USDC, BNB | | **BEP20** (BSC) | USDT, USDC, BNB |
| **Polygon** | USDT, USDC | | **Polygon** | USDT, USDC |
| **Aptos** | USDC, USDT |
| **More** | Expanding continuously | | **More** | Expanding continuously |
> Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com). > Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com).
@@ -68,7 +69,7 @@ No matter what kind of platform you operate, Epusdt can fit into existing paymen
## Core Features ## Core Features
- **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, and more - **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, Aptos, and more
- **Self-hosted** — full control of funds and wallet infrastructure - **Self-hosted** — full control of funds and wallet infrastructure
- **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis - **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis
- **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac - **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac
@@ -87,17 +88,24 @@ Quick-start links:
| Guide | Description | | Guide | Description |
|-------|-------------| |-------|-------------|
| [Repo-local: epctl installer](wiki/EPCTL.en.md) | Linux binary install, upgrade, status inspection, and Docker validation |
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup | | [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users | | [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control | | [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
| [Developer API Docs](https://epusdt.com) | Integration reference | | [Developer API Docs](https://epusdt.com) | Integration reference |
The repository also ships these top-level scripts:
- [`./epctl`](./epctl) for Linux binary install, upgrade, config inspection, service status, logs, and initial password retrieval
- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) for real Ubuntu + systemd installation validation inside local Docker
--- ---
## Project Structure ## Project Structure
```text ```text
Epusdt Epusdt
├── epctl Linux binary installation and operations script
├── src/ Core project source code ├── src/ Core project source code
└── wiki/ Documentation assets and knowledge base └── wiki/ Documentation assets and knowledge base
``` ```
+41 -2
View File
@@ -45,19 +45,26 @@
| **Solana** | USDT、USDC | | **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB | | **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC | | **Polygon** | USDT、USDC |
| **Aptos** | USDC、USDT |
| **更多** | 持续扩展中… | | **更多** | 持续扩展中… |
> 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 > 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
--- ---
## 安全审计
Epusdt 已完成第三方安全审计。
[查看安全审计报告](https://github.com/VectorBits/audit/blob/main/epusdt-secure-audit-report-2026-05-14.pdf)
---
## 广泛兼容,即插即用 ## 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景: 无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
| 领域 | 已支持系统 | | 领域 | 已支持系统 |
|------|-----------| |------|-----------|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) | | **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) | | **发卡系统** | [独角数卡(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/) |
@@ -68,7 +75,7 @@
## 核心特性 ## 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络 - **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon、Aptos 等主流网络
- **私有化部署** — 资金完全自主掌控 - **私有化部署** — 资金完全自主掌控
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis - **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac - **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
@@ -87,23 +94,55 @@
| 教程 | 说明 | | 教程 | 说明 |
|------|------| |------|------|
| [仓库内:epctl 安装脚本](wiki/EPCTL.md) | Linux 二进制安装、升级、状态查看与 Docker 验收脚本 |
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 | | [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 | | [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 | | [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 | | [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
仓库内还提供顶层脚本:
- [`./epctl`](./epctl) 用于 Linux 二进制安装、升级、查看配置、状态和初始化密码
- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) 用于在本机 Docker 里跑 Ubuntu + systemd 的真实安装验收
--- ---
## 项目结构 ## 项目结构
```text ```text
Epusdt Epusdt
├── epctl Linux 二进制安装与运维脚本
├── src/ 项目核心代码 ├── src/ 项目核心代码
└── wiki/ 文档与知识库 └── wiki/ 文档与知识库
``` ```
--- ---
## 程序截图
<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 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
Executable
+1684
View File
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly CONTAINER_NAME="epctl-systemd-test-$$"
readonly BASE_IMAGE="${EPCTL_DOCKER_IMAGE:-ubuntu:24.04}"
ACTIVE_LANG=""
POSITIONAL_ARGS=()
detect_default_language() {
local raw normalized
raw="${EPCTL_LANG:-}"
if [[ -z "${raw}" ]]; then
printf 'zh\n'
return
fi
normalized="${raw,,}"
case "${normalized}" in
zh|zh-*|zh_*|cn|chs|cht|中文)
printf 'zh\n'
;;
en|en-*|en_*|english)
printf 'en\n'
;;
*)
printf '%s\n' "${raw}"
;;
esac
}
set_language() {
local requested normalized fallback_lang
requested="${1:-$(detect_default_language)}"
normalized="${requested,,}"
fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}"
case "${normalized}" in
zh|zh-*|zh_*|cn|chs|cht|中文)
ACTIVE_LANG="zh"
;;
en|en-*|en_*|english)
ACTIVE_LANG="en"
;;
*)
ACTIVE_LANG="${fallback_lang}"
die "$(trf unsupported_language "${requested}")"
;;
esac
export EPCTL_LANG="${ACTIVE_LANG}"
}
trf() {
local key="$1"
shift || true
local template
case "${ACTIVE_LANG}:${key}" in
en:usage) template='Usage:' ;;
zh:usage) template='用法:' ;;
en:example) template='Example:' ;;
zh:example) template='示例:' ;;
en:missing_dependency) template='missing dependency: %s' ;;
zh:missing_dependency) template='缺少依赖:%s' ;;
en:arg_requires_value) template='%s requires a value' ;;
zh:arg_requires_value) template='%s 需要一个参数值' ;;
en:unsupported_language) template='unsupported language: %s' ;;
zh:unsupported_language) template='不支持的语言:%s' ;;
en:unsupported_arch) template='unsupported architecture: %s' ;;
zh:unsupported_arch) template='不支持的架构:%s' ;;
en:test_failed_collecting) template='test failed; collecting container diagnostics' ;;
zh:test_failed_collecting) template='测试失败,正在收集容器诊断信息' ;;
en:install_tag_invalid) template='install tag must be a real GitHub release tag such as v1.0.6' ;;
zh:install_tag_invalid) template='安装 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
en:upgrade_tag_invalid) template='upgrade tag must be a real GitHub release tag such as v1.0.6' ;;
zh:upgrade_tag_invalid) template='升级 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
en:base_image) template='base image: %s' ;;
zh:base_image) template='基础镜像:%s' ;;
en:starting_container) template='starting the Ubuntu systemd test container directly from %s' ;;
zh:starting_container) template='正在直接从 %s 启动 Ubuntu systemd 测试容器' ;;
en:systemd_not_ready) template='systemd did not become ready inside the container' ;;
zh:systemd_not_ready) template='容器内的 systemd 未能及时就绪' ;;
en:self_installing) template='installing epctl into PATH' ;;
zh:self_installing) template='正在将 epctl 安装到 PATH' ;;
en:downloading_release) template='downloading release %s' ;;
zh:downloading_release) template='正在下载 release %s' ;;
en:installing_release) template='installing epusdt from release %s' ;;
zh:installing_release) template='正在从 release %s 安装 epusdt' ;;
en:waiting_assets) template='waiting for epusdt to start and release www assets' ;;
zh:waiting_assets) template='正在等待 epusdt 启动并释放 www 静态文件' ;;
en:upgrading_release) template='upgrading epusdt from %s to %s' ;;
zh:upgrading_release) template='正在将 epusdt 从 %s 升级到 %s' ;;
en:verifying_upgrade_skip_restart) template='verifying that `upgrade --no-restart` skips the restart and keeps the current process running' ;;
zh:verifying_upgrade_skip_restart) template='正在验证 `upgrade --no-restart` 会跳过重启,并保持当前进程继续运行' ;;
en:verifying_upgrade_default_restart) template='verifying that a non-interactive upgrade restarts the service by default' ;;
zh:verifying_upgrade_default_restart) template='正在验证非交互升级默认会重启服务' ;;
en:verifying_upgrade_prompt_skip) template='verifying that `upgrade --prompt-restart` can skip the restart when `n` is entered' ;;
zh:verifying_upgrade_prompt_skip) template='正在验证 `upgrade --prompt-restart` 在输入 `n` 时会跳过重启' ;;
en:verifying_upgrade_prompt_restart) template='verifying that `upgrade --prompt-restart` restarts the service by default when Enter is pressed' ;;
zh:verifying_upgrade_prompt_restart) template='正在验证 `upgrade --prompt-restart` 在直接回车时默认会重启服务' ;;
en:checking_init_password) template='checking init-password behavior' ;;
zh:checking_init_password) template='正在检查 init-password 行为' ;;
en:expected_second_failure) template='expected init-password to fail after the password was changed' ;;
zh:expected_second_failure) template='密码修改后,init-password 本应失败,但它却成功了' ;;
en:verification_succeeded) template='docker verification succeeded for install=%s%s' ;;
zh:verification_succeeded) template='Docker 验证通过,install=%s%s' ;;
*)
template="missing translation: ${key}"
;;
esac
printf -- "${template}" "$@"
}
log() {
printf '[epctl-docker-test] %s\n' "$*" >&2
}
die() {
printf '[epctl-docker-test] error: %s\n' "$*" >&2
exit 1
}
usage() {
if [[ "${ACTIVE_LANG}" == "zh" ]]; then
cat <<'EOF'
用法:
./epctl-docker-test.sh [--lang zh|en] <install-tag> [upgrade-tag]
示例:
./epctl-docker-test.sh v1.0.6
./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8
EOF
else
cat <<'EOF'
Usage:
./epctl-docker-test.sh [--lang zh|en] <install-tag> [upgrade-tag]
Example:
./epctl-docker-test.sh v1.0.6
./epctl-docker-test.sh --lang en v1.0.6 v1.0.8
EOF
fi
}
parse_global_options() {
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--lang)
[[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")"
set_language "$2"
shift 2
;;
--lang=*)
set_language "${1#*=}"
shift
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(trf missing_dependency "$1")"
}
map_arch() {
case "$(uname -m)" in
x86_64|amd64)
printf 'amd64\n'
;;
aarch64|arm64)
printf 'arm64\n'
;;
*)
die "$(trf unsupported_arch "$(uname -m)")"
;;
esac
}
docker_bash() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "$1"
}
cleanup() {
local exit_code="$?"
if [[ "${exit_code}" -ne 0 ]]; then
log "$(trf test_failed_collecting)"
docker logs "${CONTAINER_NAME}" >/dev/null 2>&1 && docker logs "${CONTAINER_NAME}" >&2 || true
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl status epusdt --no-pager || true' || true
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'journalctl -u epusdt --no-pager -n 100 || true' || true
fi
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
}
wait_for_systemd() {
local attempt
for attempt in $(seq 1 120); do
if docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=Version --value >/dev/null 2>&1'; then
return 0
fi
sleep 1
done
return 1
}
wait_for_service_assets() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
for _ in $(seq 1 30); do
if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then
exit 0
fi
sleep 1
done
systemctl status epusdt --no-pager || true
journalctl -u epusdt --no-pager -n 100 || true
exit 1
'
}
service_main_pid() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=MainPID --value epusdt' | tr -d '\r\n'
}
set_language ""
parse_global_options "$@"
INSTALL_TAG="${POSITIONAL_ARGS[0]:-}"
UPGRADE_TAG="${POSITIONAL_ARGS[1]:-}"
[[ -n "${INSTALL_TAG}" ]] || {
usage
exit 1
}
[[ "${INSTALL_TAG}" == v* ]] || die "$(trf install_tag_invalid)"
if [[ -n "${UPGRADE_TAG}" && "${UPGRADE_TAG}" != v* ]]; then
die "$(trf upgrade_tag_invalid)"
fi
require_command docker
require_command curl
ASSET_ARCH="$(map_arch)"
ASSET_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/epusdt-${INSTALL_TAG#v}-linux-${ASSET_ARCH}.tar.gz"
SUMS_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/SHA256SUMS"
curl --fail --silent --show-error --head "${ASSET_URL}" >/dev/null
curl --fail --silent --show-error --head "${SUMS_URL}" >/dev/null
if [[ -n "${UPGRADE_TAG}" ]]; then
curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/epusdt-${UPGRADE_TAG#v}-linux-${ASSET_ARCH}.tar.gz" >/dev/null
curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/SHA256SUMS" >/dev/null
fi
trap cleanup EXIT
log "$(trf base_image "${BASE_IMAGE}")"
log "$(trf starting_container "${BASE_IMAGE}")"
docker run -d \
--name "${CONTAINER_NAME}" \
--privileged \
--cgroupns=host \
-e container=docker \
--tmpfs /run \
--tmpfs /run/lock \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
-v "${SCRIPT_DIR}:/work" \
-w /work \
"${BASE_IMAGE}" \
bash -euo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
ca-certificates \
curl \
expect \
jq \
sudo \
systemd \
systemd-sysv \
tar
apt-get clean
rm -rf /var/lib/apt/lists/*
if ! id -u tester >/dev/null 2>&1; then
useradd -m -s /bin/bash tester
fi
mkdir -p /etc/sudoers.d
echo "tester ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/tester
chmod 0440 /etc/sudoers.d/tester
exec /sbin/init
' >/dev/null
wait_for_systemd || die "$(trf systemd_not_ready)"
log "$(trf self_installing)"
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "su - tester -c 'cd /work && ./epctl self-install'"
docker_bash '[[ -x /usr/local/bin/epctl ]]'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""'
log "$(trf downloading_release "${INSTALL_TAG}")"
docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl download --tag ${TAG}"'
log "$(trf installing_release "${INSTALL_TAG}")"
docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"'
log "$(trf waiting_assets)"
wait_for_service_assets
docker_bash '[[ -x /opt/epusdt/epusdt ]]'
docker_bash '[[ -f /opt/epusdt/.env ]]'
docker_bash 'systemctl is-active --quiet epusdt'
docker_bash '[[ -f /opt/epusdt/www/index.html ]]'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl status >/tmp/epctl-status.out 2>/tmp/epctl-status.err"
! grep -q "systemd-journal" /tmp/epctl-status.err
'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl logs --lines 50 >/tmp/epctl-logs.out 2>/tmp/epctl-logs.err"
! grep -q "systemd-journal" /tmp/epctl-logs.err
'
if [[ -n "${UPGRADE_TAG}" ]]; then
log "$(trf upgrading_release "${INSTALL_TAG}" "${UPGRADE_TAG}")"
env_checksum_before="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')"
pid_before_skip_restart="$(service_main_pid)"
log "$(trf verifying_upgrade_skip_restart)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --no-restart" >"${output_file}" 2>&1
grep -q "systemctl restart epusdt" "${output_file}"
! grep -q "Restart .*\[Y/n\]" "${output_file}"
rm -f "${output_file}"
'
pid_after_skip_restart="$(service_main_pid)"
[[ "${pid_before_skip_restart}" == "${pid_after_skip_restart}" ]]
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '/opt/epusdt/epusdt version | grep -q "^version: ${TAG}$"'
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c 'cmp -s /opt/epusdt/.env.example "/tmp/epusdt/${TAG}/extract/.env.example"'
env_checksum_after_skip="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')"
[[ "${env_checksum_before}" == "${env_checksum_after_skip}" ]]
log "$(trf verifying_upgrade_default_restart)"
pid_before_default_restart="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG}" >"${output_file}" 2>&1
! grep -q "Restart .*\[Y/n\]" "${output_file}"
rm -f "${output_file}"
'
wait_for_service_assets
pid_after_default_restart="$(service_main_pid)"
[[ "${pid_before_default_restart}" != "${pid_after_default_restart}" ]]
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
if su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --prompt-restart" </dev/null >"${output_file}" 2>&1; then
cat "${output_file}" >&2
rm -f "${output_file}"
exit 1
fi
grep -q "interactive terminal" "${output_file}"
rm -f "${output_file}"
'
log "$(trf verifying_upgrade_prompt_skip)"
pid_before_prompt_skip="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
OUTPUT_FILE="${output_file}" expect <<'"'"'EOF'"'"'
set timeout 120
log_file -noappend $env(OUTPUT_FILE)
spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart"
expect {
-re {Restart .*\[Y/n\]} { send "n\r" }
timeout { exit 1 }
}
expect eof
EOF
grep -q "systemctl restart epusdt" "${output_file}"
rm -f "${output_file}"
'
pid_after_prompt_skip="$(service_main_pid)"
[[ "${pid_before_prompt_skip}" == "${pid_after_prompt_skip}" ]]
log "$(trf verifying_upgrade_prompt_restart)"
pid_before_prompt_restart="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
expect <<'"'"'EOF'"'"'
set timeout 120
spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart"
expect {
-re {Restart .*\[Y/n\]} { send "\r" }
timeout { exit 1 }
}
expect eof
EOF
'
wait_for_service_assets
pid_after_prompt_restart="$(service_main_pid)"
[[ "${pid_before_prompt_restart}" != "${pid_after_prompt_restart}" ]]
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl status >/tmp/epctl-status-upgrade.out 2>/tmp/epctl-status-upgrade.err"
! grep -q "systemd-journal" /tmp/epctl-status-upgrade.err
'
fi
log "$(trf checking_init_password)"
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
response="$(su - tester -c "epctl init-password")"
password="$(printf "%s\n" "${response}" | jq -r ".data.password")"
printf "%s\n" "${response}" | jq -e ".status_code == 200 and (.data.password | type == \"string\" and length > 0)" >/dev/null
login_payload="$(jq -nc --arg username admin --arg password "${password}" "{username:\$username,password:\$password}")"
token="$(curl --fail --silent --show-error \
-H "Content-Type: application/json" \
-d "${login_payload}" \
http://127.0.0.1:8000/admin/api/v1/auth/login | jq -r ".data.token")"
[[ -n "${token}" && "${token}" != "null" ]]
change_payload="$(jq -nc --arg old_password "${password}" --arg new_password "new-pass-789" "{old_password:\$old_password,new_password:\$new_password}")"
curl --fail --silent --show-error \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
-d "${change_payload}" \
http://127.0.0.1:8000/admin/api/v1/auth/password | jq -e ".status_code == 200" >/dev/null
failure_output="$(mktemp)"
if su - tester -c "epctl init-password" >"${failure_output}" 2>&1; then
cat "${failure_output}" >&2
rm -f "${failure_output}"
echo "'"$(trf expected_second_failure)"'" >&2
exit 1
fi
grep -Eq "\"status_code\"[[:space:]]*:[[:space:]]*10040" "${failure_output}"
rm -f "${failure_output}"
'
log "$(trf verification_succeeded "${INSTALL_TAG}" "${UPGRADE_TAG:+ upgrade=${UPGRADE_TAG}}")"
+1
View File
@@ -1,3 +1,4 @@
.DS_Store .DS_Store
dist/ dist/
*.log *.log
bin/
+41
View File
@@ -0,0 +1,41 @@
APP := epusdt
TARGET_DIR := bin
BUILD_TAG ?= 0.0.0-dev
VERSION_PKG := github.com/GMWalletApp/epusdt/config
LDFLAGS := -s -w -X $(VERSION_PKG).BuildVersion=$(BUILD_TAG)
.PHONY: build release build-all swagger \
linux-amd64 linux-arm64 linux-armv7 \
darwin-amd64 darwin-arm64 \
windows-amd64 windows-arm64
build:
go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP) .
release: build
linux-amd64:
GOOS=linux GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-amd64 .
linux-arm64:
GOOS=linux GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-arm64 .
linux-armv7:
GOOS=linux GOARCH=arm GOARM=7 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-armv7 .
darwin-amd64:
GOOS=darwin GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-amd64 .
darwin-arm64:
GOOS=darwin GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-arm64 .
windows-amd64:
GOOS=windows GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-amd64.exe .
windows-arm64:
GOOS=windows GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-arm64.exe .
build-all: linux-amd64 linux-arm64 linux-armv7 darwin-amd64 darwin-arm64 windows-amd64 windows-arm64
swagger:
swag init --outputTypes json,yaml --parseInternal --parseDependency -o docs
+8 -2
View File
@@ -6,6 +6,7 @@ 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"
@@ -21,6 +22,13 @@ 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 {
@@ -47,8 +55,6 @@ func InitApp() {
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║") color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
color.Yellow.Printf("║ Username: %-54s║\n", "admin") color.Yellow.Printf("║ Username: %-54s║\n", "admin")
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword) color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝") color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
} }
if _, err := appjwt.EnsureSecret(); err != nil { if _, err := appjwt.EnsureSecret(); err != nil {
+7 -1
View File
@@ -352,7 +352,13 @@ func GetRateForCoin(coin string, base string) float64 {
if coin == "usdt" && base == "usd" { if coin == "usdt" && base == "usd" {
return 1 return 1
} }
return getRateForCoinFromAPI(coin, base) if rate := getRateForCoinFromAPI(coin, base); rate > 0 {
return rate
}
if coin == "ton" {
return getRateForCoinFromAPI("toncoin", base)
}
return 0
} }
func getForcedRateForCoin(coin string, base string) float64 { func getForcedRateForCoin(coin string, base string) float64 {
+29
View File
@@ -329,6 +329,35 @@ func TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) {
} }
} }
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)
+12 -12
View File
@@ -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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateApiKeyRequest) req := new(UpdateApiKeyRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(ChangeApiKeyStatusRequest) req := new(ChangeApiKeyStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, errors.New("api key not found")) return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, errors.New("api key not found")) return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
} }
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey}) return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
} }
+9 -35
View File
@@ -1,10 +1,9 @@
package admin package admin
import ( import (
"errors"
"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"
) )
@@ -34,12 +33,6 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"` PasswordIsDefault bool `json:"password_is_default" example:"true"`
} }
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT. // Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login // @Summary Admin login
// @Description Verify credentials and return a signed JWT token // @Description Verify credentials and return a signed JWT token
@@ -53,7 +46,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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)
@@ -63,10 +56,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, errors.New("invalid username or password")) return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
} }
if user.Status != mdb.AdminUserStatusEnable { if user.Status != mdb.AdminUserStatusEnable {
return c.FailJson(ctx, errors.New("user disabled")) return c.FailJson(ctx, constant.AdminUserDisabledErr)
} }
token, err := appjwt.Sign(user.ID, user.Username) token, err := appjwt.Sign(user.ID, user.Username)
if err != nil { if err != nil {
@@ -83,25 +76,6 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
}) })
} }
// GetInitialPassword returns the one-time initial admin password.
// @Summary Get initial admin password (one-time)
// @Description Returns the initial random admin password once, then invalidates it.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password [get]
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, InitialPasswordResponse{
Username: "admin",
Password: password,
})
}
// GetInitialPasswordHash returns the initial-password fingerprint so the // GetInitialPasswordHash returns the initial-password fingerprint so the
// frontend can warn until the password is changed. // frontend can warn until the password is changed.
// @Summary Get initial admin password hash // @Summary Get initial admin password hash
@@ -144,14 +118,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, errors.New("unauthorized")) return c.FailJson(ctx, constant.AdminUnauthorizedErr)
} }
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, errors.New("user not found")) return c.FailJson(ctx, constant.AdminUserNotFoundErr)
} }
// 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.
@@ -176,11 +150,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, errors.New("unauthorized")) return c.FailJson(ctx, constant.AdminUnauthorizedErr)
} }
req := new(ChangePasswordRequest) req := new(ChangePasswordRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -190,7 +164,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, errors.New("old password incorrect")) return c.FailJson(ctx, constant.AdminOldPasswordErr)
} }
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)
+4 -4
View File
@@ -2,6 +2,7 @@ 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"
) )
@@ -13,8 +14,7 @@ type UpdateChainRequest struct {
DisplayName *string `json:"display_name" example:"Tron"` DisplayName *string `json:"display_name" example:"Tron"`
} }
// ListChains returns the chains table (tron/ethereum/solana seeded; new // ListChains returns the chains table for all seeded supported networks.
// 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. tron, ethereum, solana)" // @Param network path string true "Network name (e.g. ton, tron, ethereum, solana, aptos)"
// @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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Enabled != nil { if req.Enabled != nil {
+57 -10
View File
@@ -6,13 +6,16 @@ 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"
) )
// CreateChainTokenRequest is the payload for creating a chain token. // CreateChainTokenRequest is the payload for creating a chain token.
type CreateChainTokenRequest struct { type CreateChainTokenRequest struct {
Network string `json:"network" validate:"required" example:"tron"` Network string `json:"network" validate:"required" example:"tron"`
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"` Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
// Contract address or chain-native asset identifier. For Aptos this is
// the Move asset id (coin type, metadata address, or object type).
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"` ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `json:"decimals" example:"6"` Decimals int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
@@ -21,6 +24,8 @@ type CreateChainTokenRequest struct {
// UpdateChainTokenRequest is the payload for updating a chain token. // UpdateChainTokenRequest is the payload for updating a chain token.
type UpdateChainTokenRequest struct { type UpdateChainTokenRequest struct {
// Contract address or chain-native asset identifier. For Aptos this is
// the Move asset id (coin type, metadata address, or object type).
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"` ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals *int `json:"decimals" example:"6"` Decimals *int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
@@ -32,6 +37,17 @@ type ChangeChainTokenStatusRequest struct {
Enabled bool `json:"enabled" example:"true"` Enabled bool `json:"enabled" example:"true"`
} }
func validateChainTokenPaymentConfig(row *mdb.ChainToken) error {
if row == nil {
return nil
}
contractAddress := strings.TrimSpace(row.ContractAddress)
if row.Enabled && row.Network == mdb.NetworkAptos && contractAddress == "" {
return constant.ParamsMarshalErr
}
return nil
}
// ListChainTokens returns rows, optionally filtered by network. // ListChainTokens returns rows, optionally filtered by network.
// @Summary List chain tokens // @Summary List chain tokens
// @Description Returns chain tokens, optionally filtered by network // @Description Returns chain tokens, optionally filtered by network
@@ -53,7 +69,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed. // CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
// @Summary Create chain token // @Summary Create chain token
// @Description Create a new chain token entry // @Description Create a new chain token entry. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
// @Tags Admin Chain Tokens // @Tags Admin Chain Tokens
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -65,7 +81,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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)
@@ -86,6 +102,9 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
Enabled: enabled, Enabled: enabled,
MinAmount: req.MinAmount, MinAmount: req.MinAmount,
} }
if err := validateChainTokenPaymentConfig(row); err != nil {
return c.FailJson(ctx, err)
}
if err := data.CreateChainToken(row); err != nil { if err := data.CreateChainToken(row); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -94,7 +113,7 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
// UpdateChainToken patches mutable columns. // UpdateChainToken patches mutable columns.
// @Summary Update chain token // @Summary Update chain token
// @Description Patch chain token fields // @Description Patch chain token fields. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
// @Tags Admin Chain Tokens // @Tags Admin Chain Tokens
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -107,25 +126,42 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateChainTokenRequest) req := new(UpdateChainTokenRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
existing, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if existing == nil {
existing = &mdb.ChainToken{}
}
next := *existing
if req.ContractAddress != nil { if req.ContractAddress != nil {
fields["contract_address"] = *req.ContractAddress next.ContractAddress = strings.TrimSpace(*req.ContractAddress)
fields["contract_address"] = next.ContractAddress
} }
if req.Decimals != nil { if req.Decimals != nil {
next.Decimals = *req.Decimals
fields["decimals"] = *req.Decimals fields["decimals"] = *req.Decimals
} }
if req.Enabled != nil { if req.Enabled != nil {
next.Enabled = *req.Enabled
fields["enabled"] = *req.Enabled fields["enabled"] = *req.Enabled
} }
if req.MinAmount != nil { if req.MinAmount != nil {
next.MinAmount = *req.MinAmount
fields["min_amount"] = *req.MinAmount fields["min_amount"] = *req.MinAmount
} }
if next.ID > 0 {
if err = validateChainTokenPaymentConfig(&next); err != nil {
return c.FailJson(ctx, err)
}
}
if err := data.UpdateChainTokenFields(id, fields); err != nil { if err := data.UpdateChainTokenFields(id, fields); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -147,12 +183,23 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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)
}
row, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if row != nil && row.ID > 0 {
next := *row
next.Enabled = req.Enabled
if err = validateChainTokenPaymentConfig(&next); err != nil {
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)
} }
@@ -172,7 +219,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteChainTokenByID(id); err != nil { if err := data.DeleteChainTokenByID(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -0,0 +1,25 @@
package admin
import (
"testing"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
)
func TestValidateChainTokenPaymentConfigRejectsEnabledAptosTokenWithoutAssetID(t *testing.T) {
for _, row := range []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: true},
} {
if err := validateChainTokenPaymentConfig(&row); err != constant.ParamsMarshalErr {
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want %v", row, err, constant.ParamsMarshalErr)
}
}
}
func TestValidateChainTokenPaymentConfigAllowsDisabledAptosTokenWithoutAssetID(t *testing.T) {
row := mdb.ChainToken{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: false}
if err := validateChainTokenPaymentConfig(&row); err != nil {
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want nil", row, err)
}
}
+222 -6
View File
@@ -1,6 +1,9 @@
package admin package admin
import ( import (
"encoding/json"
"fmt"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -29,6 +32,49 @@ 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=异常
@@ -49,16 +95,18 @@ 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"`
Error string `json:"error,omitempty" example:""` ErrorCode int `json:"error_code,omitempty" example:"10001"`
Error string `json:"error,omitempty" example:"wallet address already exists"`
} }
// SettingsUpsertResult is one row in the settings upsert response. // 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"`
Error string `json:"error,omitempty" example:""` ErrorCode int `json:"error_code,omitempty" example:"10043"`
Error string `json:"error,omitempty" example:"invalid setting item"`
} }
// Overview returns the top-card numbers for the dashboard landing page. // Overview returns the top-card numbers for the dashboard landing page.
@@ -228,6 +276,174 @@ 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
+13 -13
View File
@@ -2,7 +2,6 @@ package admin
import ( import (
"encoding/json" "encoding/json"
"errors"
"strconv" "strconv"
"strings" "strings"
@@ -10,6 +9,7 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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 errors.New("config required") return constant.NotificationConfigErr
} }
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 err return constant.NotificationConfigErr
} }
} }
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, errors.New("invalid boolean string") return false, constant.NotificationEventsErr
} }
default: default:
return false, errors.New("invalid event value type") return false, constant.NotificationEventsErr
} }
} }
@@ -299,7 +299,7 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
for _, v := range t { for _, v := range t {
s, ok := v.(string) s, ok := v.(string)
if !ok { if !ok {
return nil, errors.New("events array must contain strings") return nil, constant.NotificationEventsErr
} }
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, errors.New("events must be object or string array") return nil, constant.NotificationEventsErr
} }
} }
+30 -63
View File
@@ -2,7 +2,6 @@ package admin
import ( import (
"encoding/csv" "encoding/csv"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -13,15 +12,11 @@ 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/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"`
@@ -93,7 +88,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, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
return c.SucJson(ctx, order) return c.SucJson(ctx, order)
} }
@@ -115,20 +110,28 @@ 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, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
if order.Status != mdb.StatusWaitPay { if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
return c.FailJson(ctx, errors.New("order is not waiting payment")) return c.FailJson(ctx, constant.OrderNotWaitPay)
} }
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, errors.New("close failed (concurrent state change)")) return c.FailJson(ctx, constant.OrderStatusConflict)
}
// 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)
} }
@@ -137,68 +140,37 @@ 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.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @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 admin.MarkPaidRequest true "Block transaction ID" // @Param request body request.ManualPaymentRequest 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) 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) 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) 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) 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)
} }
order, err := data.GetOrderInfoByTradeId(tradeID) resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
if err != nil { if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid load failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err) 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)
} }
if order.ID == 0 { log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, resp.BlockTransactionId)
err = errors.New("order not found")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
if order.Status != mdb.StatusWaitPay {
err = errors.New("order is not waiting payment")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s status=%d block_transaction_id=%s err=%v", adminUserID, tradeID, order.Status, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
if !isOnChainOrder(order.PayProvider) {
err = errors.New("order is not an on-chain payment order")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s pay_provider=%s block_transaction_id=%s err=%v", adminUserID, tradeID, order.PayProvider, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
verifiedBlockTransactionID, err := service.ValidateManualOrderPayment(order, req.BlockTransactionId)
if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid chain verification failed admin_user_id=%d trade_id=%s network=%s token=%s amount=%.8f block_transaction_id=%s err=%v", adminUserID, tradeID, order.Network, order.Token, order.ActualAmount, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
req.BlockTransactionId = verifiedBlockTransactionID
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 {
log.Sugar.Warnf("[admin-order] mark-paid processing failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, req.BlockTransactionId)
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
@@ -223,17 +195,17 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { if order.ID == 0 {
err = errors.New("order not found") err = constant.OrderNotExists
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) 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) return c.FailJson(ctx, err)
} }
if order.Status != mdb.StatusPaySuccess { if order.Status != mdb.StatusPaySuccess {
err = errors.New("order is not paid (callback not applicable)") 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) 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) return c.FailJson(ctx, err)
} }
if strings.TrimSpace(order.NotifyUrl) == "" { if strings.TrimSpace(order.NotifyUrl) == "" {
err = errors.New("order has empty notify_url") err = constant.OrderNotifyURLEmptyErr
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) 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) return c.FailJson(ctx, err)
} }
@@ -243,7 +215,7 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if !ok { if !ok {
err = errors.New("resend callback failed (concurrent state change)") err = constant.OrderResendCallbackErr
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) 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) return c.FailJson(ctx, err)
} }
@@ -251,11 +223,6 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
func isOnChainOrder(payProvider string) bool {
payProvider = strings.TrimSpace(payProvider)
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
// ExportOrders streams matching orders as CSV. No pagination — the // ExportOrders streams matching orders as CSV. No pagination — the
// caller drives what rows to include via filter params. Capped at // caller drives what rows to include via filter params. Capped at
// 10k rows to keep the response bounded. // 10k rows to keep the response bounded.
@@ -433,7 +400,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, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
subOrders, err := data.GetAllSubOrders(tradeID) subOrders, err := data.GetAllSubOrders(tradeID)
if err != nil { if err != nil {
+90 -17
View File
@@ -1,25 +1,28 @@
package admin package admin
import ( import (
"errors" "net/url"
"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长连接 // 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"` Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" 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.
@@ -28,6 +31,7 @@ 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.
@@ -64,7 +68,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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)
@@ -77,13 +81,23 @@ 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: strings.TrimSpace(req.Url), Url: nodeURL,
Type: strings.ToLower(strings.TrimSpace(req.Type)), Type: nodeType,
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,
} }
@@ -93,7 +107,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. // UpdateRpcNode patches url/weight/api_key/enabled/purpose.
// @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
@@ -108,15 +122,26 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateRpcNodeRequest) req := new(UpdateRpcNodeRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Url != nil { if req.Url != nil {
fields["url"] = *req.Url row, err := data.GetRpcNodeByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
}
nodeURL := strings.TrimSpace(*req.Url)
if err := validateRpcNodeURLForType(nodeURL, row.Type); err != nil {
return c.FailJson(ctx, err)
}
fields["url"] = nodeURL
} }
if req.Weight != nil { if req.Weight != nil {
fields["weight"] = *req.Weight fields["weight"] = *req.Weight
@@ -127,6 +152,13 @@ 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)
} }
@@ -146,7 +178,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteRpcNodeByID(id); err != nil { if err := data.DeleteRpcNodeByID(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -155,9 +187,8 @@ 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 endpoints this is a GET to the URL with a short timeout; for // For HTTP/WS/lite endpoints this is a TCP-level check against the configured
// WS we just attempt a TCP-level check via the same HTTP client (the // URL host. TON lite rows usually point to a global.config.json HTTPS URL.
// 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
@@ -170,14 +201,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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, errors.New("node not found")) return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
} }
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 {
@@ -188,3 +219,45 @@ 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
}
}
+57 -17
View File
@@ -10,6 +10,9 @@ 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"
) )
@@ -17,15 +20,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}}; base/coin keys are normalized to lowercase // 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.api_url (string) — external rate API URL used when no positive forced rate exists // rate.api_url (string) — external rate API URL used when no positive forced rate exists
// 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) — token for EPAY orders, e.g. "usdt" (default) // epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; ignored when a supported type=token.network selector is supplied; empty allows status=4 placeholders when request token/network are also absent
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default) // epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; still applies when a supported type selector is supplied; empty falls back to cny
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default) // epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; ignored when a supported type=token.network selector is supplied; empty allows status=4 placeholders when request token/network are also absent
// //
// - 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
@@ -52,6 +55,7 @@ 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"`
@@ -89,13 +93,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. // @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
// @Description Supported groups: brand, rate, system, epay, okpay. // @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron"). // @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", ignored when a supported type=token.network selector is supplied, empty allows status=4 placeholders), epay.default_currency (e.g. "cny", still applies when a supported type selector is supplied, empty falls back to cny), epay.default_network (e.g. "tron" or "ton", ignored when a supported type=token.network selector is supplied, empty allows status=4 placeholders).
// @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}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. // @Description 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). // @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).
// @Tags Admin Settings // @Tags Admin Settings
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -107,31 +111,36 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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"`
Error string `json:"error,omitempty"` ErrorCode int `json:"error_code,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, result{Key: item.Key, OK: false, Error: "key required"}) out = append(out, errorResult(item.Key, constant.SettingItemErr))
continue continue
} }
value, err := normalizeSettingValue(item.Value) value, err := normalizeSettingValue(item.Value)
if err != nil { if err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) out = append(out, errorResult(key, constant.SettingItemErr))
continue continue
} }
value, err = normalizeAndValidateSettingItem(item.Group, key, value) value, err = normalizeAndValidateSettingItem(item.Group, key, value)
if err != nil { if err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) out = append(out, errorResult(key, constant.SettingItemErr))
continue continue
} }
if key == mdb.SettingKeyAmountPrecision { if key == mdb.SettingKeyAmountPrecision {
@@ -142,10 +151,20 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
item.Group = mdb.SettingGroupRate item.Group = mdb.SettingGroupRate
item.Type = mdb.SettingTypeJSON 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 { if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) 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})
} }
@@ -204,6 +223,22 @@ func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
return value, err return value, err
} }
return normalized, nil 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 return value, nil
} }
@@ -260,7 +295,7 @@ func normalizeForcedRateListSetting(group, key, value string) (string, error) {
// 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) // @Description Remove a setting by key (falls back to default). Deleting system.log_level immediately resets runtime logging to error.
// @Tags Admin Settings // @Tags Admin Settings
// @Security AdminJWT // @Security AdminJWT
// @Produce json // @Produce json
@@ -276,5 +311,10 @@ 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)
} }
+17 -15
View File
@@ -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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, errors.New("wallet not found")) return c.FailJson(ctx, constant.WalletNotFoundErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(AdminUpdateWalletRequest) req := new(AdminUpdateWalletRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(AdminChangeWalletStatusRequest) req := new(AdminChangeWalletStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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. // @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
// @Tags Admin Wallets // @Tags Admin Wallets
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -254,21 +254,23 @@ 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, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
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"`
Error string `json:"error,omitempty"` ErrorCode int `json:"error_code,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 {
out = append(out, result{Address: addr, OK: false, Error: err.Error()}) code, msg := constant.ResolveErrno(err)
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
continue continue
} }
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID). _ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
+2 -2
View File
@@ -1,7 +1,7 @@
package controller package controller
import ( import (
"errors" "github.com/GMWalletApp/epusdt/util/constant"
"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 errors.New(v.Errors.One()) return constant.ParamsMarshalErr
} }
} }
+25 -13
View File
@@ -3,6 +3,7 @@ package comm
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
@@ -13,6 +14,8 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
const EPayTypeContextKey = "epay_type"
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign. // apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
// Returns nil when the middleware didn't run (should not happen on authed routes). // Returns nil when the middleware didn't run (should not happen on authed routes).
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
@@ -25,6 +28,8 @@ 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
@@ -32,16 +37,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. usdt)" // @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
// @Param network formData string false "Network (e.g. tron)" // @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
// @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 "Payment type" // @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse} // @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
// @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)
@@ -58,17 +63,18 @@ 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, creating or returning a sub-order. // @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 Normal values such as tron/solana/ethereum create on-chain child orders. // @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
// @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url. // @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 For EPay orders with a merchant return_url, the returned redirect_url is the internal /pay/return/{trade_id} hop rather than the raw merchant return_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 // @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"
// @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)
@@ -85,7 +91,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, err) return c.FailJson(ctx, constant.SystemErr)
} }
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes)) log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
@@ -99,6 +105,9 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// 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. // @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.
// @Description After signature verification, type accepts only either alipay or a supported type=token.network selector (for example usdt.tron). Token/network resolution is: supported selector first; otherwise request token/network; otherwise epay.default_token / epay.default_network. If token and network are still both empty, the order is created as status=4 placeholder. Supplying only one of token/network remains invalid.
// @Description Currency resolution is unchanged: request currency -> epay.default_currency -> cny. Supported type selectors bypass only token/network defaults, not currency fallback.
// @Description Success return/notify reuse the stored request type. On this branch that means either alipay or a supported token.network selector; when the request omitted type, outbound fallback remains alipay. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Tags Payment // @Tags Payment
// @Accept x-www-form-urlencoded // @Accept x-www-form-urlencoded
// @Produce html // @Produce html
@@ -108,7 +117,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url query string false "Callback URL (GET query)" // @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)" // @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (GET query)" // @Param name query string false "Order name (GET query)"
// @Param type query string false "Payment type (e.g. alipay, GET query)" // @Param type query string false "Either alipay or a supported token.network selector such as usdt.tron (GET query)"
// @Param sign query string false "MD5 signature (GET query)" // @Param sign query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)" // @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID" // @Param pid formData integer true "API key PID"
@@ -117,11 +126,11 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url formData string true "Callback URL" // @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment" // @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name" // @Param name formData string false "Order name"
// @Param type formData string false "Payment type (e.g. alipay)" // @Param type formData string false "Either alipay or a supported token.network selector such as usdt.tron"
// @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 // @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"
// @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) {
@@ -130,6 +139,9 @@ func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err
log.Sugar.Errorf("bind request error: %v", err) log.Sugar.Errorf("bind request error: %v", err)
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if raw, ok := ctx.Get(EPayTypeContextKey).(string); ok {
req.EpayType = strings.TrimSpace(raw)
}
if err = c.ValidateStruct(ctx, req); err != nil { if err = c.ValidateStruct(ctx, req); err != nil {
log.Sugar.Errorf("validate request error: %v", err) log.Sugar.Errorf("validate request error: %v", err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
+65 -2
View File
@@ -1,14 +1,21 @@
package comm package comm
import ( import (
"net/http"
"strings"
"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). // @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 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 defaults resolve to a concrete payment asset. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
// @Description For EPay orders with a merchant return_url, the response redirect_url is rewritten to the internal /pay/return/{trade_id} hop; the database still stores the merchant's raw return_url.
// @Tags Payment // @Tags Payment
// @Produce json // @Produce json
// @Param trade_id path string true "Trade ID" // @Param trade_id path string true "Trade ID"
@@ -25,9 +32,33 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
return c.SucJson(ctx, resp) return c.SucJson(ctx, resp)
} }
// ReturnToMerchant performs the browser-facing EPay success return hop.
// Non-EPay or not-yet-paid orders are sent back to the checkout counter.
// @Summary Return to merchant (EPAY compat)
// @Description Browser-facing success return hop for EPay orders. Paid EPay orders are redirected to the merchant return_url with signed legacy EPay query params. Orders that are not EPay or not yet paid are redirected back to the checkout counter.
// @Description The signed query params reuse the stored request type. On this branch that means either alipay or a supported token.network selector; if the original request omitted type, type=alipay is returned for compatibility.
// @Description This route also returns explicit business errors when the merchant return_url is missing, the order API key is unavailable, or EPay signature construction fails.
// @Tags Payment
// @Produce html
// @Param trade_id path string true "Trade ID"
// @Success 302 "Redirect to merchant return_url or checkout counter"
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10044 invalid order redirect url, 10045 order api key unavailable, 10046 failed to build epay return signature"
// @Router /pay/return/{trade_id} [get]
func (c *BaseCommController) ReturnToMerchant(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
redirect, err := service.ResolveEPayReturnRedirect(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if redirect.IsMerchantRedirect {
ctx.Response().Header().Set("Cache-Control", "no-store")
}
return ctx.Redirect(http.StatusFound, redirect.TargetURL)
}
// 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. // @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
// @Tags Payment // @Tags Payment
// @Produce json // @Produce json
// @Param trade_id path string true "Trade ID" // @Param trade_id path string true "Trade ID"
@@ -46,3 +77,35 @@ 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.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @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)
}
@@ -10,6 +10,7 @@ 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/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -44,6 +45,9 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
} }
symbols := make([]string, 0, len(tokens)) symbols := make([]string, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
if !service.ChainTokenReadyForPayment(t) {
continue
}
sym := strings.ToUpper(strings.TrimSpace(t.Symbol)) sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
if sym == "" { if sym == "" {
continue continue
@@ -109,9 +113,9 @@ 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, "usdt"), DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"), DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"), DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
}, },
OkPay: okpay, OkPay: okpay,
Version: config.GetAppVersion(), Version: config.GetAppVersion(),
@@ -0,0 +1,51 @@
package comm
import (
"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 TestBuildSupportedAssetsIncludesConfiguredAptosTokens(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, DisplayName: "Aptos"}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x1"); err != nil {
t.Fatalf("add aptos wallet: %v", err)
}
for _, row := range []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "", Decimals: 6, Enabled: true},
} {
if err := dao.Mdb.Create(&row).Error; err != nil {
t.Fatalf("create token %s: %v", row.Symbol, err)
}
}
supports, err := buildSupportedAssets()
if err != nil {
t.Fatalf("buildSupportedAssets: %v", err)
}
if len(supports) != 1 {
t.Fatalf("supported assets len = %d, want 1: %#v", len(supports), supports)
}
if supports[0].Network != mdb.NetworkAptos {
t.Fatalf("network = %q, want %q", supports[0].Network, mdb.NetworkAptos)
}
want := []string{"MOVEUSD", "USDT"}
if len(supports[0].Tokens) != len(want) {
t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want)
}
for i := range want {
if supports[0].Tokens[i] != want[i] {
t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want)
}
}
}
+1
View File
@@ -23,6 +23,7 @@ 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
+2
View File
@@ -348,6 +348,8 @@ 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=
+144 -32
View File
@@ -3,23 +3,25 @@
// When the .env config file is absent (or has install=true) the HTTP start // When the .env config file is absent (or has install=true) the HTTP start
// command calls RunInstallServer, which listens on the same address the main // command calls RunInstallServer, which listens on the same address the main
// server will eventually use (default :8000) and mounts two JSON endpoints // server will eventually use (default :8000) and mounts two JSON endpoints
// under /install consumed by the frontend install UI: // consumed by the frontend install UI:
// //
// GET /install/defaults — default field values for the form // GET /api/install/defaults — default field values for the form
// POST /install — validate + write .env, then shut down // POST /api/install — validate + initialize install state, then shut down
// //
// The HTTP listen address is submitted as two separate fields (http_bind_addr // The HTTP listen address is submitted as two separate fields (http_bind_addr
// and http_bind_port) and combined internally as "ADDR:PORT" before writing // and http_bind_port) and combined internally as "ADDR:PORT" before writing
// the http_listen key in .env. This makes the form easier for users who only // the http_listen key in .env. This makes the form easier for users who only
// want to change the port without touching the bind address. // want to change the port without touching the bind address.
// //
// Once the .env is written the install server stops and normal bootstrap // Once install state is initialized and the .env is finalized with
// proceeds on the same port without a restart. // install=false, the install server stops and normal bootstrap proceeds on the
// same port without a restart.
package install package install
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -28,10 +30,15 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
luluHttp "github.com/GMWalletApp/epusdt/util/http" luluHttp "github.com/GMWalletApp/epusdt/util/http"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
) )
// DefaultInstallAddr is the listen address used by the install API. // DefaultInstallAddr is the listen address used by the install API.
@@ -59,6 +66,14 @@ type InstallRequest struct {
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"` OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
} }
// InstallSubmitResponse is returned when the install request succeeds.
type InstallSubmitResponse struct {
// Completion message for the install request.
Message string `json:"message" example:"install complete, starting server…"`
// Initial admin password, returned only while the plaintext is still available.
InitPassword string `json:"init_password,omitempty" example:"a1b2c3d4e5f6"`
}
// InstallDefaults returns sensible default values for the install form. // InstallDefaults returns sensible default values for the install form.
func InstallDefaults() InstallRequest { func InstallDefaults() InstallRequest {
return InstallRequest{ return InstallRequest{
@@ -149,28 +164,20 @@ func (h *installHandler) GetDefaults(c echo.Context) error {
return c.JSON(http.StatusOK, InstallDefaults()) return c.JSON(http.StatusOK, InstallDefaults())
} }
// Submit validates the install payload, writes the .env file, and signals // Submit validates the install payload, writes the .env file, initializes the
// the install server to shut down so the main bootstrap can proceed. // minimum DB/admin state, and signals the install server to shut down so the
// main bootstrap can proceed.
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce // http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
// the http_listen config key (e.g. 0.0.0.0:8000). // the http_listen config key (e.g. 0.0.0.0:8000).
// //
// @Summary Install — submit configuration // @Summary Install — submit configuration
// @Description Validates the submitted configuration and writes the .env file. // @Description Validates the submitted configuration, writes install=true, performs the minimum DB setup needed to ensure the default admin exists, optionally returns init_password, then rewrites install=false before shutting down the install server.
// // @Description http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for http_listen. app_uri is required; other fields fall back to GET /api/install/defaults.
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
// http_bind_port must be in the range 165535 if provided.
// app_uri is required. All other fields are optional and fall back to
// the defaults returned by GET /api/install/defaults.
// Sets install=false in the written .env, then shuts down the install
// server so that normal application bootstrap starts on the same port.
// After installation completes this route is no longer served.
//
// @Tags Install // @Tags Install
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body InstallRequest true "Install configuration" // @Param body body InstallRequest true "Install configuration"
// @Success 200 {object} map[string]string "message" // @Success 200 {object} InstallSubmitResponse
// @Failure 400 {object} map[string]string "error" // @Failure 400 {object} map[string]string "error"
// @Failure 500 {object} map[string]string "error" // @Failure 500 {object} map[string]string "error"
// @Router /api/install [post] // @Router /api/install [post]
@@ -210,16 +217,26 @@ func (h *installHandler) Submit(c echo.Context) error {
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
} }
if err := writeEnvFile(h.envFilePath, req); err != nil { if err := writeEnvFile(h.envFilePath, req, true); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
initPassword, err := initializeInstallState(h.envFilePath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
if err := writeEnvFile(h.envFilePath, req, false); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
} }
go func() { close(h.done) }() go func() { close(h.done) }()
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"}) return c.JSON(http.StatusOK, InstallSubmitResponse{
Message: "install complete, starting server…",
InitPassword: initPassword,
})
} }
// RunInstallServer starts the install REST API on listenAddr (default :8000) // RunInstallServer starts the install UI and REST API on listenAddr
// under the /install path and blocks until the .env file has been written. // (default :8000), then blocks until installation has been finalized.
// The caller should then proceed with normal app initialisation (bootstrap.InitApp). // The caller should then proceed with normal app initialisation (bootstrap.InitApp).
func RunInstallServer(listenAddr, envFilePath string) { func RunInstallServer(listenAddr, envFilePath string) {
if listenAddr == "" { if listenAddr == "" {
@@ -264,13 +281,18 @@ var formControlledKeys = map[string]bool{
"install": true, "install": true,
} }
// writeEnvFile renders and writes a minimal .env file. type envTemplateData struct {
*InstallRequest
InstallValue string
}
// writeEnvFile renders and atomically writes a minimal .env file.
// If the file already exists, values for keys that are NOT controlled by the // If the file already exists, values for keys that are NOT controlled by the
// install form are preserved from the existing file so that operator-specific // install form are preserved from the existing file so that operator-specific
// settings (tg_bot_token, db_type, etc.) survive a re-install. // settings (tg_bot_token, db_type, etc.) survive a re-install.
// Keys that the form controls (app_uri, http_listen, …) always use the // Keys that the form controls (app_uri, http_listen, …) always use the
// submitted values. // submitted values.
func writeEnvFile(path string, r *InstallRequest) error { func writeEnvFile(path string, r *InstallRequest, installEnabled bool) error {
// Collect existing non-empty key→value pairs for non-form keys. // Collect existing non-empty key→value pairs for non-form keys.
existingValues := map[string]string{} existingValues := map[string]string{}
if data, err := os.ReadFile(path); err == nil { if data, err := os.ReadFile(path); err == nil {
@@ -295,7 +317,14 @@ func writeEnvFile(path string, r *InstallRequest) error {
// Render the template into a buffer first. // Render the template into a buffer first.
var buf bytes.Buffer var buf bytes.Buffer
if err := envTemplate.Execute(&buf, r); err != nil { renderData := envTemplateData{
InstallRequest: r,
InstallValue: "false",
}
if installEnabled {
renderData.InstallValue = "true"
}
if err := envTemplate.Execute(&buf, renderData); err != nil {
return fmt.Errorf("render env template: %w", err) return fmt.Errorf("render env template: %w", err)
} }
@@ -315,13 +344,96 @@ func writeEnvFile(path string, r *InstallRequest) error {
} }
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) f, err := os.CreateTemp(filepath.Dir(path), ".env.tmp.*")
if err != nil { if err != nil {
return fmt.Errorf("open config file: %w", err) return fmt.Errorf("open temp config file: %w", err)
}
tempPath := f.Name()
defer func() {
_ = os.Remove(tempPath)
}()
if _, err = fmt.Fprint(f, strings.Join(lines, "\n")); err != nil {
_ = f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(tempPath, path); err != nil {
return fmt.Errorf("replace config file: %w", err)
}
return nil
}
func initializeInstallState(envFilePath string) (string, error) {
if err := initInstallConfig(envFilePath); err != nil {
return "", err
}
if err := initInstallDatabases(); err != nil {
closeInstallDatabases()
return "", err
}
defer closeInstallDatabases()
password, created, err := data.EnsureDefaultAdmin()
if err != nil {
return "", err
}
if created {
password = strings.TrimSpace(password)
if password == "" {
return "", errors.New("default admin created without initial password")
}
return password, nil
}
password, err = data.GetInitialAdminPassword()
if err != nil {
if errors.Is(err, data.ErrInitAdminPasswordUnavailable) || errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) {
return "", nil
}
return "", err
}
return strings.TrimSpace(password), nil
}
func initInstallConfig(envFilePath string) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("init config: %v", recovered)
}
}()
config.SetConfigPath(envFilePath)
config.Init()
return nil
}
func initInstallDatabases() error {
if appLog.Sugar == nil {
appLog.Sugar = zap.NewNop().Sugar()
}
if err := dao.DBInit(); err != nil {
return fmt.Errorf("init store db: %w", err)
}
if err := dao.RuntimeInit(); err != nil {
return fmt.Errorf("init runtime db: %w", err)
}
return nil
}
func closeInstallDatabases() {
if dao.RuntimeDB != nil {
if db, err := dao.RuntimeDB.DB(); err == nil {
_ = db.Close()
}
dao.RuntimeDB = nil
}
if dao.Mdb != nil {
if db, err := dao.Mdb.DB(); err == nil {
_ = db.Close()
}
dao.Mdb = nil
} }
defer f.Close()
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
return err
} }
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}} var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
@@ -360,5 +472,5 @@ order_notice_max_retry={{.OrderNoticeMaxRetry}}
api_rate_url= api_rate_url=
# Set to true to re-run the install wizard on next startup. # Set to true to re-run the install wizard on next startup.
install=false install={{.InstallValue}}
`)) `))
+192 -31
View File
@@ -10,9 +10,89 @@ import (
"testing" "testing"
"time" "time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/spf13/viper"
) )
func resetInstallTestState(t *testing.T) {
t.Helper()
closeInstallDatabases()
dao.ResetMdbTableInitForTest()
config.SetConfigPath("")
viper.Reset()
t.Cleanup(func() {
closeInstallDatabases()
dao.ResetMdbTableInitForTest()
config.SetConfigPath("")
viper.Reset()
})
}
func newInstallTestAPI(envPath string) (*echo.Echo, *installHandler) {
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
return e, h
}
func submitInstallRequest(t *testing.T, e *echo.Echo, payload string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
return body
}
func assertDoneClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
}
}
func changeInstalledAdminPassword(t *testing.T, envPath, newPassword string) {
t.Helper()
if err := initInstallConfig(envPath); err != nil {
t.Fatalf("reopen install config: %v", err)
}
if err := initInstallDatabases(); err != nil {
t.Fatalf("reopen install databases: %v", err)
}
defer closeInstallDatabases()
user, err := data.GetAdminUserByUsername("admin")
if err != nil {
t.Fatalf("load admin user: %v", err)
}
if user.ID == 0 {
t.Fatal("expected seeded admin user")
}
if err := data.UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("change admin password: %v", err)
}
}
func TestInstallDefaults(t *testing.T) { func TestInstallDefaults(t *testing.T) {
d := InstallDefaults() d := InstallDefaults()
if d.AppName != "epusdt" { if d.AppName != "epusdt" {
@@ -43,7 +123,7 @@ func TestWriteEnvFile(t *testing.T) {
OrderExpirationTime: 15, OrderExpirationTime: 15,
OrderNoticeMaxRetry: 3, OrderNoticeMaxRetry: 3,
} }
if err := writeEnvFile(path, req); err != nil { if err := writeEnvFile(path, req, false); err != nil {
t.Fatalf("writeEnvFile: %v", err) t.Fatalf("writeEnvFile: %v", err)
} }
@@ -144,29 +224,30 @@ func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
} }
} }
func TestInstallAPISubmit(t *testing.T) { func TestInstallAPISubmitReturnsInitialPassword(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, h := newInstallTestAPI(envPath)
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}` payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload)) rec := submitInstallRequest(t, e, payload)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
} }
// done channel should be closed after successful submit body := decodeBody(t, rec)
select { if got, _ := body["message"].(string); got == "" {
case <-h.done: t.Fatalf("expected success message, got body=%v", body)
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
} }
initPassword, _ := body["init_password"].(string)
if strings.TrimSpace(initPassword) == "" {
t.Fatalf("expected non-empty init_password, got body=%v", body)
}
assertDoneClosed(t, h.done)
data, err := os.ReadFile(envPath) data, err := os.ReadFile(envPath)
if err != nil { if err != nil {
t.Fatalf("env file not written: %v", err) t.Fatalf("env file not written: %v", err)
@@ -178,20 +259,107 @@ func TestInstallAPISubmit(t *testing.T) {
if !strings.Contains(content, "http_listen=0.0.0.0:8000") { if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
t.Errorf("env file missing http_listen; content:\n%s", content) t.Errorf("env file missing http_listen; content:\n%s", content)
} }
if !strings.Contains(content, "install=false") {
t.Errorf("env file missing install=false; content:\n%s", content)
}
}
func TestInstallAPISubmitRepeatInstallReturnsSamePasswordWhilePlaintextExists(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
e1, h1 := newInstallTestAPI(envPath)
rec1 := submitInstallRequest(t, e1, payload)
if rec1.Code != http.StatusOK {
t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String())
}
body1 := decodeBody(t, rec1)
password1, _ := body1["init_password"].(string)
if strings.TrimSpace(password1) == "" {
t.Fatalf("expected first init_password, got body=%v", body1)
}
assertDoneClosed(t, h1.done)
e2, h2 := newInstallTestAPI(envPath)
rec2 := submitInstallRequest(t, e2, payload)
if rec2.Code != http.StatusOK {
t.Fatalf("second install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String())
}
body2 := decodeBody(t, rec2)
password2, _ := body2["init_password"].(string)
if password2 != password1 {
t.Fatalf("expected repeat install to return same init_password %q, got %q", password1, password2)
}
assertDoneClosed(t, h2.done)
}
func TestInstallAPISubmitRepeatInstallOmitsPasswordAfterPasswordChange(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
e1, h1 := newInstallTestAPI(envPath)
rec1 := submitInstallRequest(t, e1, payload)
if rec1.Code != http.StatusOK {
t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String())
}
assertDoneClosed(t, h1.done)
changeInstalledAdminPassword(t, envPath, "new-password-456")
e2, h2 := newInstallTestAPI(envPath)
rec2 := submitInstallRequest(t, e2, payload)
if rec2.Code != http.StatusOK {
t.Fatalf("repeat install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String())
}
body2 := decodeBody(t, rec2)
if got, ok := body2["init_password"]; ok {
t.Fatalf("expected init_password to be omitted after password change, got %v", got)
}
if got, _ := body2["message"].(string); got == "" {
t.Fatalf("expected success message, got body=%v", body2)
}
assertDoneClosed(t, h2.done)
}
func TestInstallAPISubmitInitFailureKeepsInstallMode(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
e, h := newInstallTestAPI(envPath)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"runtime_root_path":"/dev/null/runtime","order_expiration_time":10,"order_notice_max_retry":1}`
rec := submitInstallRequest(t, e, payload)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body: %s", rec.Code, rec.Body.String())
}
select {
case <-h.done:
t.Fatal("handler should not close done channel on init failure")
case <-time.After(100 * time.Millisecond):
}
contentBytes, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("read env after init failure: %v", err)
}
content := string(contentBytes)
if !strings.Contains(content, "install=true") {
t.Fatalf("expected env to keep install=true after failure; content:\n%s", content)
}
} }
func TestInstallAPISubmitMissingURI(t *testing.T) { func TestInstallAPISubmitMissingURI(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, _ := newInstallTestAPI(envPath)
e := echo.New() rec := submitInstallRequest(t, e, `{"app_name":"x"}`)
e.POST("/install", h.Submit)
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code) t.Fatalf("status = %d, want 400", rec.Code)
@@ -205,15 +373,8 @@ func TestInstallAPISubmitInvalidPort(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, _ := newInstallTestAPI(envPath)
e := echo.New() rec := submitInstallRequest(t, e, `{"app_uri":"http://example.com","http_bind_port":99999}`)
e.POST("/install", h.Submit)
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
+10 -1
View File
@@ -12,6 +12,7 @@ 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"
) )
@@ -65,11 +66,19 @@ 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.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon,
} { } {
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.
+3
View File
@@ -27,6 +27,9 @@ 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 {
if ctx.Path() == "" {
return next(ctx)
}
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization")) token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
if err != nil { if err != nil {
return err return err
+43 -10
View File
@@ -14,6 +14,12 @@ import (
var once sync.Once var once sync.Once
// ResetMdbTableInitForTest resets the install/startup migration guard so tests
// can exercise fresh temporary databases in the same process.
func ResetMdbTableInitForTest() {
once = sync.Once{}
}
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds // MdbTableInit performs AutoMigrate for all primary DB tables and seeds
// the minimum static rows: chains, chain tokens, default settings, and // the minimum static rows: chains, chain tokens, default settings, and
// a Telegram notification channel migrated from legacy settings keys. // a Telegram notification channel migrated from legacy settings keys.
@@ -46,6 +52,7 @@ func MdbTableInit() {
} }
seedChains() seedChains()
backfillRpcNodePurpose()
seedRpcNodes() seedRpcNodes()
seedChainTokens() seedChainTokens()
seedDefaultSettings() seedDefaultSettings()
@@ -63,6 +70,8 @@ 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},
{Network: mdb.NetworkAptos, DisplayName: "Aptos", 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)
@@ -97,6 +106,11 @@ 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},
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", 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)
@@ -106,14 +120,7 @@ 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 := []mdb.RpcNode{ defaults := defaultRpcNodes()
{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{}).
@@ -131,6 +138,31 @@ 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.NetworkAptos, Url: "https://aptos-rest.publicnode.com/", Type: mdb.RpcNodeTypeHttp, 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() {
@@ -140,9 +172,10 @@ 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.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString}, {Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, 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: "tron", Type: mdb.SettingTypeString}, {Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", 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},
+287
View File
@@ -0,0 +1,287 @@
package dao
import (
"path/filepath"
"strings"
"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 TestDefaultRpcNodesIncludesAptosPublicNode(t *testing.T) {
var got *mdb.RpcNode
nodes := defaultRpcNodes()
for i := range nodes {
node := nodes[i]
if node.Network == mdb.NetworkAptos {
got = &node
break
}
}
if got == nil {
t.Fatal("missing Aptos seed rpc node")
}
if got.Url != "https://aptos-rest.publicnode.com/" {
t.Fatalf("Aptos seed url = %q", got.Url)
}
if got.Type != mdb.RpcNodeTypeHttp {
t.Fatalf("Aptos seed type = %q, want %q", got.Type, mdb.RpcNodeTypeHttp)
}
if got.Purpose != mdb.RpcNodePurposeGeneral {
t.Fatalf("Aptos seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
}
if !got.Enabled {
t.Fatal("Aptos seed enabled = false, want true")
}
if got.Status != mdb.RpcNodeStatusUnknown {
t.Fatalf("Aptos seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
}
}
func TestSeedChainsIncludesAptos(t *testing.T) {
db := setupSeedTableTestDB(t, &mdb.Chain{})
Mdb = db
seedChains()
var row mdb.Chain
if err := Mdb.Where("network = ?", mdb.NetworkAptos).Take(&row).Error; err != nil {
t.Fatalf("load Aptos chain seed: %v", err)
}
if !row.Enabled {
t.Fatal("Aptos chain enabled = false, want true")
}
}
func TestSeedChainTokensIncludesAptosAssets(t *testing.T) {
db := setupSeedTableTestDB(t, &mdb.ChainToken{})
Mdb = db
seedChainTokens()
wantEnabled := map[string]bool{
mdb.NetworkAptos + "/USDC": true,
mdb.NetworkAptos + "/USDT": true,
}
wantContract := map[string]string{
mdb.NetworkAptos + "/USDT": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b",
}
for key, enabled := range wantEnabled {
parts := strings.Split(key, "/")
var row mdb.ChainToken
if err := Mdb.Where("network = ? AND symbol = ?", parts[0], parts[1]).Take(&row).Error; err != nil {
t.Fatalf("load token seed %s: %v", key, err)
}
if row.Enabled != enabled {
t.Fatalf("%s enabled = %v, want %v", key, row.Enabled, enabled)
}
if want, ok := wantContract[key]; ok && row.ContractAddress != want {
t.Fatalf("%s contract_address = %q, want %q", key, row.ContractAddress, want)
}
}
}
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
}
func setupSeedTableTestDB(t *testing.T, models ...interface{}) *gorm.DB {
t.Helper()
oldDB := Mdb
t.Cleanup(func() {
Mdb = oldDB
})
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-table.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(models...); err != nil {
t.Fatalf("migrate seed table: %v", err)
}
return db
}
+128 -85
View File
@@ -40,39 +40,47 @@ 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 { if err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := purgeDeletedInitialAdminPasswordPlainTx(tx); err != nil {
return err
}
var count int64
if err := tx.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := tx.Create(user).Error; err != nil {
return err
}
if err := initAdminPasswordStateTx(tx, password); err != nil {
return err
}
created = true
return nil
}); err != nil {
return "", false, err return "", false, err
} }
var count int64 if created {
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil { cacheInitialAdminPasswordState(password)
return "", false, err
} }
if count > 0 { return password, created, nil
return "", false, nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return "", false, err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
return "", false, err
}
if err := initAdminPasswordState(password); err != nil {
// Account was already created; surface both for visibility while
// preserving created=true and password for emergency fallback.
return password, true, err
}
return password, true, nil
} }
func purgeDeletedInitialAdminPasswordPlain() error { func purgeDeletedInitialAdminPasswordPlainTx(tx *gorm.DB) error {
return dao.Mdb.Unscoped(). return tx.Unscoped().
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain). Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
Delete(&mdb.Setting{}).Error Delete(&mdb.Setting{}).Error
} }
@@ -91,12 +99,20 @@ func HashInitialAdminPassword(plain string) string {
} }
func initAdminPasswordState(plain string) error { func initAdminPasswordState(plain string) error {
if err := initAdminPasswordStateTx(dao.Mdb, plain); err != nil {
return err
}
cacheInitialAdminPasswordState(plain)
return nil
}
func initAdminPasswordStateTx(tx *gorm.DB, plain string) error {
hash := HashInitialAdminPassword(plain) hash := HashInitialAdminPassword(plain)
settings := []mdb.Setting{ settings := []mdb.Setting{
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: plain, Type: mdb.SettingTypeString, Value: plain, Type: mdb.SettingTypeString,
Description: "One-time readable initial admin password", Description: "Readable initial admin password until password change",
}, },
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
@@ -106,7 +122,7 @@ func initAdminPasswordState(plain string) error {
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "false", Type: mdb.SettingTypeBool, Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched", Description: "Whether initial admin password plaintext has been cleared",
}, },
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
@@ -115,18 +131,21 @@ func initAdminPasswordState(plain string) error {
}, },
} }
for _, row := range settings { for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil { if err := upsertSettingRow(tx, row); err != nil {
return err return err
} }
} }
// Keep in-process cache coherent for the current process. return nil
}
func cacheInitialAdminPasswordState(plain string) {
hash := HashInitialAdminPassword(plain)
settingsCacheMu.Lock() settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false" settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false" settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock() settingsCacheMu.Unlock()
return nil
} }
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error { func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
@@ -138,56 +157,51 @@ func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
}).Create(&row).Error }).Create(&row).Error
} }
// ConsumeInitialAdminPassword returns the one-time initial admin password. // GetInitialAdminPassword returns the initial admin password plaintext while
// After a successful read, the plaintext is deleted and cannot be fetched // it is still available. The plaintext remains readable until the admin
// again. // password is changed, after which the stored plaintext is deleted.
func ConsumeInitialAdminPassword() (string, error) { func GetInitialAdminPassword() (string, error) {
var password string row := new(mdb.Setting)
err := dao.Mdb.Transaction(func(tx *gorm.DB) error { if err := dao.Mdb.Model(&mdb.Setting{}).
row := new(mdb.Setting) Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
if err := tx.Model(&mdb.Setting{}). Limit(1).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). Find(row).Error; err != nil {
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInitAdminPasswordAlreadyFetched
}
return upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
})
})
if err != nil {
return "", err return "", err
} }
settingsCacheMu.Lock() if row.ID != 0 && row.Value != "" {
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) return row.Value, nil
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" }
settingsCacheMu.Unlock()
return password, nil var fetched mdb.Setting
if err := dao.Mdb.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return "", err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return "", ErrInitAdminPasswordAlreadyFetched
}
return "", ErrInitAdminPasswordUnavailable
}
func clearInitialAdminPasswordPlain(tx *gorm.DB) error {
res := tx.Unscoped().
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if err := upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password plaintext has been cleared",
}); err != nil {
return err
}
return nil
} }
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint // GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
@@ -250,12 +264,15 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
return u, err return u, err
} }
// UpdateAdminUserPassword rehashes and persists a new password. // UpdateAdminUserPassword rehashes and persists a new password. When the
// change succeeds, the stored initial-password plaintext is deleted so it can
// no longer be returned by the install flow.
func UpdateAdminUserPassword(id uint64, newPlain string) error { func UpdateAdminUserPassword(id uint64, newPlain string) error {
hash, err := HashPassword(newPlain) hash, err := HashPassword(newPlain)
if err != nil { if err != nil {
return err return err
} }
clearedPlaintext := false
changedCacheValue := "" changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error { err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}). if err := tx.Model(&mdb.AdminUser{}).
@@ -264,8 +281,18 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
return err return err
} }
var plainRow mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(&plainRow).Error; err != nil {
return err
}
hasPlaintext := plainRow.ID != 0 && strings.TrimSpace(plainRow.Value) != ""
// Old installs may not have initial-password metadata; skip in // Old installs may not have initial-password metadata; skip in
// that case to keep password updates backward compatible. // that case to keep password_changed backward compatible, but still
// clear any leftover plaintext if it exists.
var initHashRow mdb.Setting var initHashRow mdb.Setting
if err := tx.Model(&mdb.Setting{}). if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash). Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
@@ -275,6 +302,12 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
} }
initHash := strings.TrimSpace(initHashRow.Value) initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" { if initHash == "" {
if hasPlaintext {
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
}
return nil return nil
} }
@@ -293,15 +326,25 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
}); err != nil { }); err != nil {
return err return err
} }
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
changedCacheValue = changedValue changedCacheValue = changedValue
return nil return nil
}) })
if err != nil { if err != nil {
return err return err
} }
if changedCacheValue != "" { if clearedPlaintext || changedCacheValue != "" {
settingsCacheMu.Lock() settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue if clearedPlaintext {
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
}
if changedCacheValue != "" {
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
}
settingsCacheMu.Unlock() settingsCacheMu.Unlock()
} }
return nil return nil
+150 -5
View File
@@ -42,7 +42,7 @@ func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
} }
} }
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) { func TestGetInitialAdminPasswordKeepsPlaintext(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -51,14 +51,58 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("seed initial password state: %v", err) t.Fatalf("seed initial password state: %v", err)
} }
got, err := ConsumeInitialAdminPassword() got, err := GetInitialAdminPassword()
if err != nil { if err != nil {
t.Fatalf("consume initial password: %v", err) t.Fatalf("get initial password: %v", err)
} }
if got != password { if got != password {
t.Fatalf("password = %q, want %q", 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 != 1 {
t.Fatalf("plaintext setting rows after read = %d, want 1", count)
}
if GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to stay false before password change")
}
}
func TestUpdateAdminUserPasswordHardDeletesInitialPasswordPlaintext(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
oldPassword = "init-pass-plain"
newPassword = "new-password-123"
)
if err := initAdminPasswordState(oldPassword); err != nil {
t.Fatalf("seed initial password state: %v", err)
}
hash, err := HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
t.Fatalf("seed admin user: %v", err)
}
if err := UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("update admin password: %v", err)
}
var count int64 var count int64
if err := dao.Mdb.Unscoped(). if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}). Model(&mdb.Setting{}).
@@ -67,10 +111,59 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("count plaintext setting: %v", err) t.Fatalf("count plaintext setting: %v", err)
} }
if count != 0 { if count != 0 {
t.Fatalf("plaintext setting rows after consume = %d, want 0", count) t.Fatalf("plaintext setting rows after password change = %d, want 0", count)
} }
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) { if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to be true") t.Fatal("expected fetched flag to be true after password change")
}
}
func TestUpdateAdminUserPasswordHardDeletesPlaintextWithoutHashMetadata(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
oldPassword = "legacy-init-pass"
newPassword = "new-password-456"
)
if err := upsertSettingRow(dao.Mdb, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: oldPassword,
Type: mdb.SettingTypeString,
}); err != nil {
t.Fatalf("seed plaintext setting: %v", err)
}
hash, err := HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
t.Fatalf("seed admin user: %v", err)
}
if err := UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("update admin password: %v", err)
}
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 password change = %d, want 0", count)
}
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to be true after clearing plaintext")
} }
} }
@@ -120,3 +213,55 @@ func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count) t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
} }
} }
func TestEnsureDefaultAdminRollsBackWhenInitialPasswordStateFails(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Exec(`
CREATE TRIGGER fail_initial_password_state
BEFORE INSERT ON settings
WHEN NEW.key = 'system.init_admin_password_plain'
BEGIN
SELECT RAISE(FAIL, 'forced initial password state failure');
END;
`).Error; err != nil {
t.Fatalf("create failure trigger: %v", err)
}
password, created, err := EnsureDefaultAdmin()
if err == nil {
t.Fatal("expected EnsureDefaultAdmin to fail")
}
if created || password != "" {
t.Fatalf("created=%v password=%q, want no created admin on failure", created, password)
}
var adminCount int64
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&adminCount).Error; err != nil {
t.Fatalf("count admin users after failure: %v", err)
}
if adminCount != 0 {
t.Fatalf("admin users after failed initial password state = %d, want 0", adminCount)
}
if err := dao.Mdb.Exec(`DROP TRIGGER fail_initial_password_state`).Error; err != nil {
t.Fatalf("drop failure trigger: %v", err)
}
password, created, err = EnsureDefaultAdmin()
if err != nil {
t.Fatalf("retry EnsureDefaultAdmin: %v", err)
}
if !created || password == "" {
t.Fatalf("retry created=%v password=%q, want new admin with password", created, password)
}
got, err := GetInitialAdminPassword()
if err != nil {
t.Fatalf("get initial password after retry: %v", err)
}
if got != password {
t.Fatalf("initial password after retry = %q, want %q", got, password)
}
}
+3 -3
View File
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
return out, nil return out, nil
} }
// CloseOrderManually transitions a pending order to expired. Only // CloseOrderManually transitions a pending order to expired. It covers both
// touches rows currently in StatusWaitPay so idempotent / safe. // payable orders and placeholder orders waiting for token/network selection.
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 = ?", mdb.StatusWaitPay). Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Update("status", mdb.StatusExpired) Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error return result.RowsAffected > 0, result.Error
} }
+78 -2
View File
@@ -8,6 +8,7 @@ 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"
@@ -39,9 +40,20 @@ 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)
if isEVMNetwork(normalizeLockNetwork(network)) { network = 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
}
}
if network == mdb.NetworkAptos {
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
return normalized
}
}
return address return address
} }
@@ -202,7 +214,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 = ?", mdb.StatusWaitPay). Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
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
@@ -218,6 +230,15 @@ 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)
@@ -281,6 +302,61 @@ 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 {
+73 -44
View File
@@ -1,6 +1,7 @@
package data package data
import ( import (
"fmt"
"time" "time"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
@@ -28,25 +29,40 @@ 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) {
var rows []DailyStat rows, err := queryOrderStats(start, end, false)
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
} }
@@ -56,25 +72,34 @@ 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) {
var rows []DailyStat rows, err := queryOrderStats(start, end, true)
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 {
@@ -122,15 +147,7 @@ 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) {
var rows []AddressDailyStat rows, err := queryAssetByAddress(start, end, false)
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
} }
@@ -140,21 +157,33 @@ 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) {
var rows []AddressDailyStat rows, err := queryAssetByAddress(start, end, true)
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 {
+94
View File
@@ -8,6 +8,7 @@ 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) {
@@ -42,6 +43,56 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
} }
} }
func TestStatsBucketExprForDialect(t *testing.T) {
tests := []struct {
name string
dialect string
hourly bool
want string
}{
{
name: "sqlite daily",
dialect: "sqlite",
want: "substr(created_at, 1, 10)",
},
{
name: "sqlite hourly",
dialect: "sqlite",
hourly: true,
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
},
{
name: "postgres daily",
dialect: "postgres",
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
},
{
name: "postgres hourly",
dialect: "postgres",
hourly: true,
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
if err != nil {
t.Fatalf("statsBucketExprForDialect error: %v", err)
}
if got != tt.want {
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
}
})
}
}
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
t.Fatal("expected unsupported mysql dialect error")
}
}
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) { func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -106,3 +157,46 @@ 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)
}
}
func TestAptosTransactionLockAddressUsesCanonicalKey(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := LockTransaction(mdb.NetworkAptos, "0xA", "USDT", "trade-aptos", 1.23, time.Hour); err != nil {
t.Fatalf("lock aptos transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, "a", "USDT", 1.23)
if err != nil {
t.Fatalf("lookup aptos lock: %v", err)
}
if gotTradeID != "trade-aptos" {
t.Fatalf("aptos lock lookup = %q, want trade-aptos", gotTradeID)
}
}
+21 -1
View File
@@ -4,6 +4,7 @@ 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) {
@@ -20,8 +21,27 @@ 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 dao.Mdb.Model(&mdb.ProviderOrder{}). return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
}
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{}{
+253 -1
View File
@@ -1,14 +1,44 @@
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
@@ -20,6 +50,84 @@ 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)
@@ -29,6 +137,7 @@ 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
} }
@@ -37,6 +146,9 @@ 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
} }
@@ -48,13 +160,15 @@ 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. // run yet. Explicitly down rows are not selected. Manual verification nodes
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) { 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
@@ -82,6 +196,144 @@ 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).
+304
View File
@@ -1,6 +1,7 @@
package data package data
import ( import (
"strings"
"testing" "testing"
"github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
@@ -99,3 +100,306 @@ 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)
}
}
+108
View File
@@ -0,0 +1,108 @@
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)
}
+81
View File
@@ -0,0 +1,81 @@
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)
}
}
+36 -3
View File
@@ -5,6 +5,7 @@ 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"
) )
@@ -34,10 +35,26 @@ 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)
}
if network == mdb.NetworkAptos {
return addressutil.NormalizeMoveAddress(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)
address = normalizeWalletAddressByNetwork(network, address) var err error
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 {
@@ -75,9 +92,13 @@ 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)
address = normalizeWalletAddressByNetwork(network, address) var err error
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
@@ -126,6 +147,12 @@ 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
} }
@@ -149,6 +176,12 @@ 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,6 +7,8 @@ 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) {
@@ -81,3 +83,40 @@ 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)
}
}
func TestAddWalletAddressWithNetworkNormalizesMoveAddressVariants(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
want := "0x000000000000000000000000000000000000000000000000000000000000000a"
row, err := AddWalletAddressWithNetwork(mdb.NetworkAptos, " A ")
if err != nil {
t.Fatalf("add aptos wallet: %v", err)
}
if row.Address != want {
t.Fatalf("stored Aptos address = %q, want %q", row.Address, want)
}
if _, err = AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x0A"); err != constant.WalletAddressAlreadyExists {
t.Fatalf("add equivalent aptos wallet error = %v, want already exists", err)
}
}
+5 -4
View File
@@ -5,10 +5,11 @@ const (
ChainTokenStatusDisable = 2 ChainTokenStatusDisable = 2
) )
// ChainToken describes a token to watch on a given chain. Replaces the // ChainToken describes a token to watch on a given chain. For EVM/TRON rows
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners // ContractAddress is the token contract; for Solana it is the mint; for
// load this table on startup (and refresh periodically) to learn which // TON it is the jetton master; for Aptos it is the Move asset identifier
// contracts to subscribe to. // (coin type, metadata address or object type). Native rows use an empty
// ContractAddress.
type ChainToken struct { type ChainToken struct {
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"` Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"` Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
+27 -14
View File
@@ -1,15 +1,24 @@
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 (
@@ -19,14 +28,15 @@ 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.
// //
// In the current design this is typically used by a switch-network-created // It can be used by a status=4 parent completed in place on first
// child order, while the parent order keeps its original merchant-facing // switch-network, or by a switch-network-created child order after the
// semantics and callback behavior. // parent is already concrete. Merchant callback shape is still controlled by
// 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:"T2026041612345678"` TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"` 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..."`
@@ -36,21 +46,24 @@ 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=已过期 // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"` Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" 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月卡"`
EpayType string `gorm:"column:epay_type;size:64;default:''" json:"-"`
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 string `gorm:"column:payment_type" json:"payment_type" example:"Epay"` // PaymentType is not a channel selector. "Epay" changes callback format
// 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:
// - parent orders and regular chain child orders use on_chain // - chain parent orders and regular chain child orders use on_chain
// - third-party hosted checkout child orders use their provider name // - third-party hosted checkout rows 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
+3 -3
View File
@@ -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 is expected to bind to the concrete child order created // row. For OkPay this can bind either to a status=4 parent completed in place
// by switch-network, not to the parent merchant order. // or to a concrete child order created by later switch-network calls.
// //
// 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:"T2026041612345678"` TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
// Provider values should match the bound Orders.PayProvider value. // Provider 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
+19 -11
View File
@@ -5,6 +5,7 @@ import "github.com/dromara/carbon/v2"
const ( const (
RpcNodeTypeHttp = "http" RpcNodeTypeHttp = "http"
RpcNodeTypeWs = "ws" RpcNodeTypeWs = "ws"
RpcNodeTypeLite = "lite"
) )
const ( const (
@@ -13,19 +14,26 @@ const (
RpcNodeStatusDown = "down" RpcNodeStatusDown = "down"
) )
// RpcNode records one RPC endpoint for a network. Clients select a node const (
// by weight among rows where Enabled=true AND Status=ok. A periodic RpcNodePurposeGeneral = "general"
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt. RpcNodePurposeManualVerify = "manual_verify"
RpcNodePurposeBoth = "both"
)
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
type RpcNode struct { 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长连接 // 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"` Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" 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
+9 -4
View File
@@ -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 epay/okpay groups are seeded on first startup (see // Default rows for the system/epay/okpay groups are seeded on first startup
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start // (see model/dao/mdb_table_init.go seedDefaultSettings). Other groups start
// empty and fall back to hardcoded defaults until an admin sets them. // empty and fall back to hardcoded defaults until an admin sets them. The
// Only exception: system.jwt_secret is auto-generated on first startup. // system.jwt_secret key is auto-generated on first startup.
const ( const (
SettingGroupBrand = "brand" SettingGroupBrand = "brand"
SettingGroupRate = "rate" SettingGroupRate = "rate"
@@ -31,6 +31,7 @@ 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"
@@ -62,6 +63,10 @@ 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_rate_list"`
+2
View File
@@ -12,6 +12,8 @@ const (
NetworkBsc = "binance" NetworkBsc = "binance"
NetworkPolygon = "polygon" NetworkPolygon = "polygon"
NetworkPlasma = "plasma" NetworkPlasma = "plasma"
NetworkTon = "ton"
NetworkAptos = "aptos"
) )
const ( const (
+21 -5
View File
@@ -6,14 +6,19 @@ 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" validate:"required" example:"usdt"` // 币种 如:usdt Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron、aptos;可与 token 同时缺省创建占位订单
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 string `json:"payment_type" form:"payment_type" example:"Epay"` // PaymentType is a compatibility flag, not a gateway selector. Only
// "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"`
EpayType string `json:"-" form:"-"`
} }
func (r CreateTransactionRequest) Translates() map[string]string { func (r CreateTransactionRequest) Translates() map[string]string {
@@ -39,11 +44,22 @@ 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:"T2026041612345678"` TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
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,aptos"`
} }
func (r SwitchNetworkRequest) Translates() map[string]string { func (r SwitchNetworkRequest) Translates() map[string]string {
+7 -4
View File
@@ -4,8 +4,11 @@ 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 {
StatusCode int `json:"status_code" example:"200"` // Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
Message string `json:"message" example:"success"` StatusCode int `json:"status_code" example:"200"`
Data interface{} `json:"data"` // Default server-side errno text. Frontend should prefer status_code for display translation.
RequestID string `json:"request_id" example:"req-123456"` Message string `json:"message" example:"success"`
Data interface{} `json:"data"`
// Request id echoed from X-Request-ID when present.
RequestID string `json:"request_id" example:"req-123456"`
} }
+20 -19
View File
@@ -2,21 +2,22 @@ package response
// CreateTransactionResponse 创建订单成功返回 // CreateTransactionResponse 创建订单成功返回
type CreateTransactionResponse struct { type CreateTransactionResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id 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......
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
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:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 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 保留小数
@@ -30,13 +31,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:"T2026041612345678"` // 平台订单号 TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
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"` // 订单状态
} }
+23 -15
View File
@@ -1,22 +1,30 @@
package response package response
type CheckoutCounterResponse struct { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额 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"` // 订单实际需要支付的金额;status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ... Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETH ... Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳 RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 EPay 时为商户原始回跳地址;EPay 时为内部中转地址 /pay/return/{trade_id}
IsSelected bool `json:"is_selected" example:"false"` PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
} }
type CheckStatusResponse struct { type CheckStatusResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
// 订单状态 1=等待支付 2=支付成功 3=已过期 // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `json:"status" enums:"1,2,3" example:"1"` Status int `json:"status" enums:"1,2,3,4" 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..."` // 已验证并入账的交易哈希
} }
+3 -3
View File
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
} }
type EpayPublicConfig struct { type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:"usdt"` DefaultToken string `json:"default_token" example:""` // EPay default token; ignored when a supported type=token.network selector is supplied. Empty means submit.php can create a status=4 placeholder when request token/network are also absent.
DefaultCurrency string `json:"default_currency" example:"cny"` DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; still applies when a supported type selector is supplied and falls back to cny when unset.
DefaultNetwork string `json:"default_network" example:"tron"` DefaultNetwork string `json:"default_network" example:""` // EPay default network; ignored when a supported type=token.network selector is supplied. Empty means submit.php can create a status=4 placeholder when request token/network are also absent.
} }
type SitePublicConfig struct { type SitePublicConfig struct {
+526
View File
@@ -0,0 +1,526 @@
package service
import (
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/tidwall/gjson"
)
func AptosGetLedgerVersion() (int64, error) {
body, err := aptosGet("/v1")
if err != nil {
return 0, err
}
latest := gjson.GetBytes(body, "ledger_version").Int()
log.Sugar.Debugf("[APTOS] latest ledger version=%d", latest)
return latest, nil
}
func AptosGetTransactionByHash(txID string) ([]byte, error) {
txID = normalizeAptosTxID(txID)
log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s", txID)
return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
}
func AptosGetTransactions(start int64, limit int64) ([]byte, error) {
if start < 0 {
start = 0
}
if limit <= 0 {
limit = 1
}
log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d", start, limit)
return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit))
}
func ParseAptosTransfers(body []byte, address string, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
receive, err := addressutil.NormalizeMoveAddress(address)
if err != nil {
return nil, err
}
return ParseAptosTransfersForWallets(body, map[string]struct{}{receive: {}}, tokens)
}
func ParseAptosTransfersForWallets(body []byte, wallets map[string]struct{}, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
root := gjson.ParseBytes(body)
if !root.IsArray() {
return nil, fmt.Errorf("unexpected Aptos transactions response")
}
var out []MoveObservedTransfer
for _, tx := range root.Array() {
if tx.Get("type").String() != "" && tx.Get("type").String() != "user_transaction" {
continue
}
if !tx.Get("success").Bool() {
continue
}
txID := strings.TrimSpace(tx.Get("hash").String())
if txID == "" {
continue
}
blockTimeMs := parseAptosTimestampMs(tx.Get("timestamp"))
version := tx.Get("version").Int()
txctx := buildAptosTransferContext(tx)
events := buildAptosFungibleEvents(tx, txctx, tokens)
deposits := make([]aptosFungibleEvent, 0)
withdrawals := make(map[string]int)
for _, event := range events {
if event.token == nil {
continue
}
switch event.action {
case "withdraw":
withdrawals[aptosTransferMatchKey(event.token.Symbol, event.rawAmount)]++
case "deposit":
deposits = append(deposits, event)
}
}
usedDeposits := make([]bool, len(deposits))
for i, event := range deposits {
matchKey := aptosTransferMatchKey(event.token.Symbol, event.rawAmount)
if withdrawals[matchKey] <= 0 {
continue
}
if appendAptosObservedTransfer(&out, txID, version, blockTimeMs, event, wallets) {
withdrawals[matchKey]--
usedDeposits[i] = true
} else {
logAptosUnwatchedDeposit(txID, version, event, "direct")
}
}
for i := 0; i < len(deposits); i++ {
if usedDeposits[i] {
continue
}
for j := i + 1; j < len(deposits); j++ {
if usedDeposits[j] || !aptosDepositPairCanMatch(deposits[i], deposits[j]) {
continue
}
sum := new(big.Int).Add(deposits[i].rawAmount, deposits[j].rawAmount)
matchKey := aptosTransferMatchKey(deposits[i].token.Symbol, sum)
if withdrawals[matchKey] <= 0 {
continue
}
appendedFirst := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[i], wallets)
appendedSecond := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[j], wallets)
if !appendedFirst && !appendedSecond {
logAptosUnwatchedDeposit(txID, version, deposits[i], "split")
logAptosUnwatchedDeposit(txID, version, deposits[j], "split")
continue
}
withdrawals[matchKey]--
usedDeposits[i] = true
usedDeposits[j] = true
break
}
}
}
return out, nil
}
func appendAptosObservedTransfer(out *[]MoveObservedTransfer, txID string, version int64, blockTimeMs int64, event aptosFungibleEvent, wallets map[string]struct{}) bool {
recipient := event.owner
if !moveWalletWatched(recipient, wallets) {
return false
}
*out = append(*out, MoveObservedTransfer{
Network: mdb.NetworkAptos,
ReceiveAddress: recipient,
Token: NormalizeAptosPaymentSymbol(event.token.Symbol),
RawAmount: event.rawAmount,
Decimals: event.token.Decimals,
MinAmount: event.token.MinAmount,
Amount: rawToFloat(event.rawAmount, event.token.Decimals),
TxID: normalizeAptosTxID(txID),
BlockTimeMs: blockTimeMs,
Version: version,
TransferKey: moveAptosTransferKey(version, event.eventIndex, recipient, event.token.Symbol, event.rawAmount),
})
return true
}
func logAptosUnwatchedDeposit(txID string, version int64, event aptosFungibleEvent, matchMode string) {
log.Sugar.Debugf(
"[APTOS] matched deposit skipped owner_not_watched mode=%s version=%d tx=%s token=%s amount=%.8f raw=%s owner=%s",
matchMode,
version,
normalizeAptosTxID(txID),
NormalizeAptosPaymentSymbol(event.token.Symbol),
rawToFloat(event.rawAmount, event.token.Decimals),
event.rawAmount.String(),
event.owner,
)
}
func aptosDepositPairCanMatch(first, second aptosFungibleEvent) bool {
if first.token == nil || second.token == nil || first.rawAmount == nil || second.rawAmount == nil {
return false
}
if NormalizeAptosPaymentSymbol(first.token.Symbol) != NormalizeAptosPaymentSymbol(second.token.Symbol) {
return false
}
return first.token.ContractAddress == second.token.ContractAddress
}
type aptosFungibleEvent struct {
eventIndex int
action string
owner string
rawAmount *big.Int
token *mdb.ChainToken
}
func buildAptosFungibleEvents(tx gjson.Result, ctx aptosTransferContext, tokens []mdb.ChainToken) []aptosFungibleEvent {
out := make([]aptosFungibleEvent, 0)
for eventIndex, event := range tx.Get("events").Array() {
action := aptosFungibleEventAction(event)
if action == "" {
continue
}
rawAmount, ok := parseSignedBigInt(aptosEventRawAmount(event, ctx))
if !ok || rawAmount.Sign() <= 0 {
continue
}
token := resolveAptosPaymentToken(aptosEventAssetID(event, ctx), tokens)
if token == nil {
continue
}
out = append(out, aptosFungibleEvent{
eventIndex: eventIndex,
action: action,
owner: aptosEventRecipient(event, ctx),
rawAmount: rawAmount,
token: token,
})
}
return out
}
func aptosFungibleEventAction(event gjson.Result) string {
typ := strings.ToLower(strings.TrimSpace(event.Get("type").String()))
switch typ {
case "0x1::fungible_asset::deposit":
return "deposit"
case "0x1::fungible_asset::withdraw":
return "withdraw"
default:
return ""
}
}
func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string {
raw := ""
if rawAmount != nil {
raw = rawAmount.String()
}
return NormalizeAptosPaymentSymbol(symbol) + ":" + raw
}
func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
return resolveMoveToken(mdb.NetworkAptos, assetID, tokens)
}
func NormalizeAptosPaymentSymbol(symbol string) string {
return strings.ToUpper(strings.TrimSpace(symbol))
}
type aptosTransferContext struct {
storeOwners map[string]string
storeMetadata map[string]string
}
func buildAptosTransferContext(tx gjson.Result) aptosTransferContext {
ctx := aptosTransferContext{
storeOwners: make(map[string]string),
storeMetadata: make(map[string]string),
}
for _, change := range tx.Get("changes").Array() {
if change.Get("type").String() != "write_resource" {
continue
}
store, err := addressutil.NormalizeMoveAddress(change.Get("address").String())
if err != nil {
continue
}
changeType := strings.TrimSpace(change.Get("data.type").String())
switch changeType {
case "0x1::fungible_asset::FungibleStore":
if metadata := firstNonEmpty(
change.Get("data.data.metadata.inner").String(),
change.Get("data.data.metadata").String(),
); metadata != "" {
ctx.storeMetadata[store] = normalizeAptosObservedAssetID(metadata)
}
case "0x1::object::ObjectCore":
if owner := firstMoveAddress(change.Get("data.data.owner").String()); owner != "" {
ctx.storeOwners[store] = owner
}
}
}
return ctx
}
func aptosEventRecipient(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if owner := ctx.storeOwners[store]; owner != "" {
return owner
}
}
return ""
}
func aptosEventRawAmount(event gjson.Result, ctx aptosTransferContext) string {
return event.Get("data.amount").String()
}
func aptosEventAssetID(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if metadata := ctx.storeMetadata[store]; metadata != "" {
return metadata
}
}
return ""
}
func normalizeAptosObservedAssetID(assetID string) string {
return addressutil.NormalizeMoveAssetID(assetID)
}
func parseAptosTimestampMs(value gjson.Result) int64 {
raw := strings.TrimSpace(value.String())
if raw == "" {
return 0
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return n / 1000
}
func normalizeAptosTxID(txID string) string {
txID = strings.ToLower(strings.TrimSpace(txID))
if strings.HasPrefix(txID, "0x") {
return txID
}
return "0x" + txID
}
func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) {
txID = normalizeAptosTxID(txID)
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue
}
if _, err = validateManualAptosPaymentWithNode(order, txID, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", data.RpcNodeLogLabel(node), err))
continue
}
return txID, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual Aptos verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
func validateManualAptosPaymentWithNode(order *mdb.Orders, txID string, node mdb.RpcNode) (string, error) {
txID = normalizeAptosTxID(txID)
body, err := aptosGetWithNode(node, fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
if err != nil {
return "", fmt.Errorf("fetch aptos transaction: %w", err)
}
if err = validateManualAptosTransactionWithConfirm(order, txID, body, func(version int64) error {
return ensureAptosTransferConfirmedWithNode(version, node)
}); err != nil {
return "", err
}
return txID, nil
}
func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error {
return validateManualAptosTransactionWithConfirm(order, txID, body, EnsureAptosTransferConfirmed)
}
func validateManualAptosTransactionWithConfirm(order *mdb.Orders, txID string, body []byte, confirm func(int64) error) error {
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
transfers, err := ParseAptosTransfers([]byte("["+string(body)+"]"), order.ReceiveAddress, tokens)
if err != nil {
return err
}
amountMismatch := false
for _, transfer := range transfers {
if !strings.EqualFold(transfer.TxID, txID) {
continue
}
if err = confirm(transfer.Version); err != nil {
return err
}
if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil {
return nil
}
if strings.Contains(err.Error(), "amount mismatch") {
amountMismatch = true
} else {
return err
}
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching aptos transfer to order address not found")
}
func aptosGet(path string) (body []byte, err error) {
network := mdb.NetworkAptos
defer recordMoveRPCResult(network, &err)
tried := make([]uint64, 0, 3)
var lastErr error
for attempts := 0; attempts < 3; attempts++ {
node, resolveErr := resolveAptosRpcNode(tried...)
if resolveErr != nil {
if lastErr != nil {
return nil, lastErr
}
return nil, resolveErr
}
if len(tried) > 0 {
log.Sugar.Warnf("[APTOS] trying alternate RPC node path=%s node=%s", path, data.RpcNodeLogLabel(*node))
}
body, err = aptosGetWithNode(*node, path)
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("[APTOS] RPC node reached fail threshold path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
} else {
log.Sugar.Warnf("[APTOS] RPC node failed path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
}
tried = append(tried, node.ID)
}
return nil, lastErr
}
func resolveAptosRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkAptos, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 {
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
node.Url = rpcURL
return node, nil
}
func aptosGetWithNode(node mdb.RpcNode, path string) (body []byte, err error) {
endpoint, err := aptosJoinURL(node.Url, path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: manualVerifyRequestTimeout}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ = io.ReadAll(resp.Body)
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("aptos rpc HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func aptosJoinURL(base, path string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
return "", fmt.Errorf("empty aptos rpc url")
}
if strings.TrimSpace(path) == "" {
return strings.TrimRight(base, "/"), nil
}
path = strings.TrimSpace(path)
trimmedBase := strings.TrimRight(base, "/")
lowerBase := strings.ToLower(trimmedBase)
if strings.HasSuffix(lowerBase, "/v1") && strings.HasPrefix(path, "/v1") {
path = strings.TrimPrefix(path, "/v1")
if path == "" {
path = "/"
}
}
return trimmedBase + path, nil
}
func EnsureAptosTransferConfirmed(version int64) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
latest, err := AptosGetLedgerVersion()
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
return ensureAptosConfirmations(version, latest)
}
func ensureAptosTransferConfirmedWithNode(version int64, node mdb.RpcNode) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
body, err := aptosGetWithNode(node, "/v1")
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
latest := gjson.GetBytes(body, "ledger_version").Int()
return ensureAptosConfirmations(version, latest)
}
func ensureAptosConfirmations(version, latest int64) error {
chain, err := data.GetChainByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
minConfirmations := int64(1)
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = int64(chain.MinConfirmations)
}
if latest-version+1 < minConfirmations {
return fmt.Errorf("aptos transaction confirmations insufficient")
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
)
type EPayReturnRedirect struct {
TargetURL string
IsMerchantRedirect bool
}
func isEPayOrder(order *mdb.Orders) bool {
return order != nil && strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay)
}
func buildCheckoutCounterPath(tradeID string) string {
return "/pay/checkout-counter/" + tradeID
}
func buildAbsoluteAppURL(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
base := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if base == "" {
return path
}
return base + path
}
func buildEPayReturnPath(tradeID string) string {
return "/pay/return/" + tradeID
}
func buildPublicRedirectURL(order *mdb.Orders) string {
if order == nil {
return ""
}
raw := strings.TrimSpace(order.RedirectUrl)
if raw == "" || !isEPayOrder(order) {
return raw
}
return buildAbsoluteAppURL(buildEPayReturnPath(order.TradeId))
}
func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order == nil || order.ApiKeyID == 0 {
return nil, constant.OrderApiKeyUnavailableErr
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, err
}
if row == nil || row.ID == 0 || row.Status != mdb.ApiKeyStatusEnable {
return nil, constant.OrderApiKeyUnavailableErr
}
return row, nil
}
func epayResultType(order *mdb.Orders) string {
if order == nil {
return "alipay"
}
if epayType := strings.TrimSpace(order.EpayType); epayType != "" {
return epayType
}
return "alipay"
}
func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) {
if order == nil || apiKeyRow == nil {
return nil, constant.EPayReturnSignatureErr
}
pidInt, err := strconv.Atoi(strings.TrimSpace(apiKeyRow.Pid))
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
notifyData := response.OrderNotifyResponseEpay{
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: epayResultType(order),
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
return map[string]string{
"pid": strconv.Itoa(pidInt),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr,
"sign_type": "MD5",
}, nil
}
func appendQueryParams(rawURL string, params map[string]string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "", constant.OrderRedirectURLErr
}
targetURL, err := url.Parse(rawURL)
if err != nil {
return "", constant.OrderRedirectURLErr
}
query := targetURL.Query()
for key, value := range params {
query.Set(key, value)
}
targetURL.RawQuery = query.Encode()
return targetURL.String(), nil
}
func ResolveEPayReturnRedirect(tradeID string) (*EPayReturnRedirect, error) {
order, err := GetOrderInfoByTradeId(tradeID)
if err != nil {
return nil, err
}
if !isEPayOrder(order) || order.Status != mdb.StatusPaySuccess {
return &EPayReturnRedirect{
TargetURL: buildCheckoutCounterPath(order.TradeId),
}, nil
}
rawMerchantURL := strings.TrimSpace(order.RedirectUrl)
if rawMerchantURL == "" {
return nil, constant.OrderRedirectURLErr
}
apiKeyRow, err := ResolveOrderApiKey(order)
if err != nil {
return nil, err
}
params, err := BuildEPayResultParams(order, apiKeyRow)
if err != nil {
return nil, err
}
targetURL, err := appendQueryParams(rawMerchantURL, params)
if err != nil {
return nil, err
}
return &EPayReturnRedirect{
TargetURL: targetURL,
IsMerchantRedirect: true,
}, nil
}
+174
View File
@@ -0,0 +1,174 @@
package service
import (
"net/url"
"testing"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
)
func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
epayOrder := &mdb.Orders{
TradeId: "trade_epay_redirect",
PaymentType: mdb.PaymentTypeEpay,
RedirectUrl: "https://merchant.example/return",
}
wantRedirect := config.GetAppUri() + "/pay/return/trade_epay_redirect"
if got := buildPublicRedirectURL(epayOrder); got != wantRedirect {
t.Fatalf("epay redirect_url = %q, want internal return route", got)
}
gmpayOrder := &mdb.Orders{
TradeId: "trade_gmpay_redirect",
PaymentType: mdb.PaymentTypeGmpay,
RedirectUrl: "https://merchant.example/return",
}
if got := buildPublicRedirectURL(gmpayOrder); got != "https://merchant.example/return" {
t.Fatalf("gmpay redirect_url = %q, want merchant raw redirect_url", got)
}
if got := buildPublicRedirectURL(&mdb.Orders{TradeId: "trade_empty", PaymentType: mdb.PaymentTypeEpay}); got != "" {
t.Fatalf("empty redirect_url = %q, want empty", got)
}
}
func TestBuildEPayResultParamsUsesOriginalType(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_params",
OrderId: "order_epay_params",
Name: "VIP",
Amount: 1,
EpayType: "usdt.tron",
}, &mdb.ApiKey{
Pid: "1001",
SecretKey: "epay-secret",
})
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
expected := map[string]string{
"pid": "1001",
"trade_no": "trade_epay_params",
"out_trade_no": "order_epay_params",
"type": "usdt.tron",
"name": "VIP",
"money": "1.0000",
"trade_status": "TRADE_SUCCESS",
"sign_type": "MD5",
}
for key, want := range expected {
if got := params[key]; got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
signParams := map[string]interface{}{
"pid": params["pid"],
"trade_no": params["trade_no"],
"out_trade_no": params["out_trade_no"],
"type": params["type"],
"name": params["name"],
"money": params["money"],
"trade_status": params["trade_status"],
}
wantSign, err := sign.Get(signParams, "epay-secret")
if err != nil {
t.Fatalf("sign.Get(): %v", err)
}
if got := params["sign"]; got != wantSign {
t.Fatalf("sign = %q, want %q", got, wantSign)
}
}
func TestBuildEPayResultParamsFallsBackToAlipayWhenTypeMissing(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_fallback",
OrderId: "order_epay_fallback",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "1001",
SecretKey: "epay-secret",
})
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
if got := params["type"]; got != "alipay" {
t.Fatalf("type = %q, want alipay", got)
}
}
func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) {
_, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_bad_pid",
OrderId: "order_bad_pid",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "not-a-number",
SecretKey: "epay-secret",
})
if err != constant.EPayReturnSignatureErr {
t.Fatalf("error = %v, want %v", err, constant.EPayReturnSignatureErr)
}
}
func TestResolveOrderApiKeyRejectsUnavailableRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "missing_api_key_id"}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("missing api_key_id error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
row := &mdb.ApiKey{
Name: "disabled",
Pid: "1201",
SecretKey: "disabled-secret",
Status: mdb.ApiKeyStatusDisable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
t.Fatalf("create disabled api key: %v", err)
}
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "disabled_api_key", ApiKeyID: row.ID}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("disabled api key error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
}
func TestAppendQueryParamsPreservesMerchantQuery(t *testing.T) {
target, err := appendQueryParams("https://merchant.example/return?from=merchant", map[string]string{
"pid": "1001",
"trade_no": "trade_epay_redirect",
"trade_status": "TRADE_SUCCESS",
})
if err != nil {
t.Fatalf("appendQueryParams(): %v", err)
}
parsed, err := url.Parse(target)
if err != nil {
t.Fatalf("url.Parse(): %v", err)
}
query := parsed.Query()
if got := query.Get("from"); got != "merchant" {
t.Fatalf("from = %q, want merchant", got)
}
if got := query.Get("pid"); got != "1001" {
t.Fatalf("pid = %q, want 1001", got)
}
if got := query.Get("trade_no"); got != "trade_epay_redirect" {
t.Fatalf("trade_no = %q, want trade_epay_redirect", got)
}
if got := query.Get("trade_status"); got != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", got)
}
}
+416 -65
View File
@@ -16,6 +16,7 @@ import (
tron "github.com/GMWalletApp/epusdt/crypto" tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"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"
"github.com/GMWalletApp/epusdt/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@@ -23,6 +24,10 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"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"
) )
const manualVerifyRequestTimeout = 15 * time.Second const manualVerifyRequestTimeout = 15 * time.Second
@@ -54,7 +59,9 @@ func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string,
if fn == nil { if fn == nil {
manualOrderPaymentValidator = validateManualOrderPaymentDefault manualOrderPaymentValidator = validateManualOrderPaymentDefault
} else { } else {
manualOrderPaymentValidator = fn manualOrderPaymentValidator = func(order *mdb.Orders, blockTransactionID string) (string, error) {
return fn(order, blockTransactionID)
}
} }
manualOrderPaymentValidatorMu.Unlock() manualOrderPaymentValidatorMu.Unlock()
return func() { return func() {
@@ -80,6 +87,10 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
canonicalTxID, err = validateManualTronPayment(order, txID) canonicalTxID, err = validateManualTronPayment(order, txID)
case mdb.NetworkSolana: case mdb.NetworkSolana:
canonicalTxID, err = validateManualSolanaPayment(order, txID) canonicalTxID, err = validateManualSolanaPayment(order, txID)
case mdb.NetworkTon:
canonicalTxID, err = validateManualTonPayment(order, txID)
case mdb.NetworkAptos:
canonicalTxID, err = ValidateManualAptosPayment(order, txID)
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
canonicalTxID, err = validateManualEvmPayment(order, txID) canonicalTxID, err = validateManualEvmPayment(order, txID)
default: default:
@@ -114,7 +125,7 @@ func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string)
func manualBlockTransactionIDIsHex(network string) bool { func manualBlockTransactionIDIsHex(network string) bool {
switch strings.ToLower(strings.TrimSpace(network)) { switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
return true return true
default: default:
return false return false
@@ -140,7 +151,7 @@ func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string
add(canonicalTxID) add(canonicalTxID)
switch network { switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X") body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body) body = strings.ToLower(body)
add("0x" + body) add("0x" + body)
@@ -282,25 +293,25 @@ func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainRead
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) { func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
var clients []manualEvmClient var clients []manualEvmClient
var connectErrors []string var connectErrors []string
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
node, err := data.SelectRpcNode(network, nodeType) nodes, err := listManualEvmRpcCandidates(network)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" { for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue continue
} }
rpcURL := strings.TrimSpace(node.Url) client, err := dialManualEvmClient(ctx, node)
client, err := ethclient.DialContext(ctx, rpcURL)
if err == nil { if err == nil {
clients = append(clients, manualEvmClient{ clients = append(clients, manualEvmClient{
label: fmt.Sprintf("%s %s", nodeType, rpcURL), label: manualRpcNodeLabel(node),
reader: client, reader: client,
close: client.Close, close: client.Close,
}) })
continue continue
} }
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err)) connectErrors = append(connectErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
} }
if len(clients) > 0 { if len(clients) > 0 {
return clients, nil return clients, nil
@@ -311,6 +322,49 @@ func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClien
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network) return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
} }
func listManualEvmRpcCandidates(network string) ([]mdb.RpcNode, error) {
buckets := make([][]mdb.RpcNode, 4)
for _, nodeType := range []string{mdb.RpcNodeTypeHttp, mdb.RpcNodeTypeWs} {
nodes, err := data.ListManualPaymentRpcCandidates(network, nodeType)
if err != nil {
return nil, err
}
for _, node := range nodes {
node.Purpose = data.NormalizeRpcNodePurpose(node.Purpose)
switch node.Purpose {
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
if node.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], node)
} else {
buckets[1] = append(buckets[1], node)
}
case mdb.RpcNodePurposeManualVerify:
if node.Status == mdb.RpcNodeStatusOk {
buckets[2] = append(buckets[2], node)
} else {
buckets[3] = append(buckets[3], node)
}
}
}
}
out := make([]mdb.RpcNode, 0)
for _, bucket := range buckets {
out = append(out, bucket...)
}
return out, nil
}
func dialManualEvmClient(ctx context.Context, node mdb.RpcNode) (*ethclient.Client, error) {
rpcURL := strings.TrimSpace(node.Url)
return ethclient.DialContext(ctx, rpcURL)
}
func manualRpcNodeLabel(node mdb.RpcNode) string {
purpose := data.NormalizeRpcNodePurpose(node.Purpose)
return fmt.Sprintf("%s purpose=%s", data.RpcNodeLogLabel(node), purpose)
}
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error { func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
if order == nil { if order == nil {
return fmt.Errorf("order not found") return fmt.Errorf("order not found")
@@ -398,55 +452,77 @@ func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
return "", err return "", err
} }
baseURL, apiKey, err := ResolveTronNode() nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(nodes) == 0 {
var tx manualTronTransaction return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
return "", fmt.Errorf("fetch tron transaction: %w", err)
}
if strings.TrimSpace(tx.TxID) == "" {
return "", fmt.Errorf("transaction not found")
}
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
return "", fmt.Errorf("transaction id mismatch")
}
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
} }
var info manualTronTxInfo var verifyErrors []string
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil { for _, node := range nodes {
return "", fmt.Errorf("fetch tron transaction info: %w", err) if strings.TrimSpace(node.Url) == "" {
} continue
if info.BlockNumber <= 0 { }
return "", fmt.Errorf("transaction is not confirmed") if err = validateManualTronPaymentWithNode(order, normalizedTxID, node); err != nil {
} verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" { continue
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
}
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil {
return "", err
}
if info.BlockTimeStamp <= 0 {
return "", fmt.Errorf("transaction block timestamp missing")
}
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
return "", fmt.Errorf("transaction predates the order")
}
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
if err = validateManualTronNativeTransfer(order, &tx); err != nil {
return "", err
} }
return normalizedTxID, nil return normalizedTxID, nil
} }
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil { if len(verifyErrors) > 0 {
return "", err return "", fmt.Errorf("manual TRON verification failed: %s", strings.Join(verifyErrors, "; "))
} }
return normalizedTxID, nil return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
func validateManualTronPaymentWithNode(order *mdb.Orders, normalizedTxID string, node mdb.RpcNode) error {
baseURL := strings.TrimSpace(node.Url)
var tx manualTronTransaction
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
return fmt.Errorf("fetch tron transaction: %w", err)
}
if strings.TrimSpace(tx.TxID) == "" {
return fmt.Errorf("transaction not found")
}
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
return fmt.Errorf("transaction id mismatch")
}
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
return fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
}
var info manualTronTxInfo
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
return fmt.Errorf("fetch tron transaction info: %w", err)
}
if info.BlockNumber <= 0 {
return fmt.Errorf("transaction is not confirmed")
}
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
return fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
}
if err := ensureTronConfirmations(baseURL, node.ApiKey, order.Network, info.BlockNumber); err != nil {
return err
}
if info.BlockTimeStamp <= 0 {
return fmt.Errorf("transaction block timestamp missing")
}
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
if err := validateManualTronNativeTransfer(order, &tx); err != nil {
return err
}
return nil
}
if err := validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
return err
}
return nil
} }
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error { func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
@@ -569,22 +645,289 @@ func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{
return json.Unmarshal(raw, out) return json.Unmarshal(raw, out)
} }
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) { type manualTonTxRef struct {
sig = strings.TrimSpace(sig) ReceiveRaw string
txData, err := SolGetTransaction(sig) LT uint64
HashHex string
HasLT bool
}
func validateManualTonPayment(order *mdb.Orders, txID string) (string, error) {
ref, err := parseManualTonTxRef(txID)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch solana transaction: %w", err) return "", err
} }
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null { orderAddr, err := addressutil.ParseTonMainnetAddress(order.ReceiveAddress)
if err != nil {
return "", fmt.Errorf("invalid order receive address: %w", err)
}
if ref.ReceiveRaw != "" && ref.ReceiveRaw != orderAddr.Raw {
return "", fmt.Errorf("transaction recipient mismatch")
}
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTon, mdb.RpcNodeTypeLite)
if err != nil {
return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite)
}
var verifyErrors []string
for _, node := range nodes {
api, closeFn, err := dialManualTonClient(node)
if err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: connect: %v", manualRpcNodeLabel(node), err))
continue
}
canonicalID, err := validateManualTonPaymentWithAPI(context.Background(), api, order, orderAddr.Address, ref)
closeFn()
if err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
continue
}
return canonicalID, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual TON verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite)
}
func parseManualTonTxRef(input string) (manualTonTxRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return manualTonTxRef{}, fmt.Errorf("block_transaction_id is required")
}
if strings.HasPrefix(strings.ToLower(raw), "ton:") {
body := raw[4:]
last := strings.LastIndex(body, ":")
if last <= 0 || last == len(body)-1 {
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id")
}
hashHex, err := normalizeTonTxHashHex(body[last+1:])
if err != nil {
return manualTonTxRef{}, err
}
left := body[:last]
mid := strings.LastIndex(left, ":")
if mid <= 0 || mid == len(left)-1 {
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id")
}
lt, err := parseUint64Strict(left[mid+1:])
if err != nil {
return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt")
}
receiveRaw, err := addressutil.TonRawAddressKey(left[:mid])
if err != nil {
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical recipient: %w", err)
}
return manualTonTxRef{ReceiveRaw: receiveRaw, LT: lt, HashHex: hashHex, HasLT: true}, nil
}
if idx := strings.Index(raw, ":"); idx > 0 {
lt, err := parseUint64Strict(raw[:idx])
if err != nil {
return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt")
}
hashHex, err := normalizeTonTxHashHex(raw[idx+1:])
if err != nil {
return manualTonTxRef{}, err
}
return manualTonTxRef{LT: lt, HashHex: hashHex, HasLT: true}, nil
}
hashHex, err := normalizeTonTxHashHex(raw)
if err != nil {
return manualTonTxRef{}, err
}
return manualTonTxRef{HashHex: hashHex}, nil
}
func normalizeTonTxHashHex(raw string) (string, error) {
hashHex := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X")))
if len(hashHex) != 64 {
return "", fmt.Errorf("invalid ton transaction hash length")
}
if _, err := hex.DecodeString(hashHex); err != nil {
return "", fmt.Errorf("invalid ton transaction hash")
}
return hashHex, nil
}
func parseUint64Strict(raw string) (uint64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("empty integer")
}
var out uint64
for _, ch := range raw {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf("invalid integer")
}
out = out*10 + uint64(ch-'0')
}
return out, nil
}
func dialManualTonClient(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) {
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
defer cancel()
return ConnectTonLiteAPI(ctx, node.Url, manualVerifyRequestTimeout, 3)
}
func validateManualTonPaymentWithAPI(ctx context.Context, api ton.APIClientWrapped, order *mdb.Orders, receive *address.Address, ref manualTonTxRef) (string, error) {
master, err := api.CurrentMasterchainInfo(ctx)
if err != nil {
return "", fmt.Errorf("fetch current masterchain: %w", err)
}
txs, err := listManualTonCandidateTransactions(ctx, api, master, receive, ref)
if err != nil {
return "", fmt.Errorf("list ton account transactions: %w", err)
}
if len(txs) == 0 {
return "", fmt.Errorf("transaction not found") return "", fmt.Errorf("transaction not found")
} }
if err = ensureSolanaConfirmations(order.Network, sig); err != nil {
state, err := manualTonTokenState(ctx, api, master, receive)
if err != nil {
return "", err return "", err
} }
return validateManualTonPaymentCandidates(order, receive, ref, txs, state)
}
func validateManualTonPaymentCandidates(order *mdb.Orders, receive *address.Address, ref manualTonTxRef, txs []*tlb.Transaction, state *manualTonState) (string, error) {
if state == nil {
state = &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)}
}
var matched []*TonObservedTransfer
for _, tx := range txs {
if tx == nil {
continue
}
hashHex := hex.EncodeToString(tx.Hash)
if !strings.EqualFold(hashHex, ref.HashHex) {
continue
}
if ref.HasLT && tx.LT != ref.LT {
continue
}
transfer, err := ParseTonInboundTransfer(tx, receive, state.nativeToken, state.jettonWallets)
if err != nil || transfer == nil {
continue
}
if err = EnsureTonTransferMatchesOrder(order, transfer); err != nil {
return "", err
}
matched = append(matched, transfer)
}
if len(matched) == 0 {
return "", fmt.Errorf("matching ton transfer to order address not found")
}
if len(matched) > 1 && !ref.HasLT {
return "", fmt.Errorf("ton transaction hash matched multiple recent transactions; submit canonical id or lt:hash")
}
receiveRaw := addressutil.TonRawAddressObjectKey(receive)
return TonCanonicalBlockTransactionID(receiveRaw, matched[0].LT, matched[0].TxHashHex), nil
}
func listManualTonCandidateTransactions(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address, ref manualTonTxRef) ([]*tlb.Transaction, error) {
waiter := api.WaitForBlock(master.SeqNo)
if ref.HasLT {
hashBytes, err := hex.DecodeString(ref.HashHex)
if err != nil {
return nil, err
}
return waiter.ListTransactions(ctx, receive, 1, ref.LT, hashBytes)
}
account, err := waiter.GetAccount(ctx, master, receive)
if err != nil {
return nil, fmt.Errorf("fetch ton account state: %w", err)
}
if account == nil || account.LastTxLT == 0 || len(account.LastTxHash) == 0 {
return nil, fmt.Errorf("transaction not found")
}
return waiter.ListTransactions(ctx, receive, 100, account.LastTxLT, account.LastTxHash)
}
type manualTonState struct {
nativeToken *mdb.ChainToken
jettonWallets map[string]mdb.ChainToken
}
func manualTonTokenState(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address) (*manualTonState, error) {
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon)
if err != nil {
return nil, err
}
state := &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)}
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
contract := strings.TrimSpace(tokens[i].ContractAddress)
if sym == TonNativeSymbol && contract == "" {
token := tokens[i]
state.nativeToken = &token
continue
}
if contract == "" {
continue
}
masterAddr, err := addressutil.ParseTonMainnetAddress(contract)
if err != nil {
continue
}
wallet, err := jetton.NewJettonMasterClient(api, masterAddr.Address).GetJettonWalletAtBlock(ctx, receive, master)
if err != nil {
continue
}
state.jettonWallets[addressutil.TonRawAddressObjectKey(wallet.Address())] = tokens[i]
}
return state, nil
}
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
sig = strings.TrimSpace(sig)
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
continue
}
if err = validateManualSolanaPaymentWithRPC(order, sig, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
continue
}
return sig, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual Solana verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
func validateManualSolanaPaymentWithRPC(order *mdb.Orders, sig string, node mdb.RpcNode) error {
rpcURL := strings.TrimSpace(node.Url)
txData, err := solGetTransactionWithURL(rpcURL, sig)
if err != nil {
return fmt.Errorf("fetch solana transaction: %w", err)
}
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
return fmt.Errorf("transaction not found")
}
if err = ensureSolanaConfirmationsWithRPC(order.Network, sig, rpcURL); err != nil {
return err
}
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana) tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil { if err != nil {
return "", err return err
} }
mintTokens := make(map[string]*mdb.ChainToken, len(tokens)) mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken var nativeSolToken *mdb.ChainToken
@@ -615,17 +958,17 @@ func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error)
continue continue
} }
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil { if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
return "", err return err
} }
if amountMatchesFloat(order.ActualAmount, amount) { if amountMatchesFloat(order.ActualAmount, amount) {
return sig, nil return nil
} }
amountMismatch = true amountMismatch = true
} }
if amountMismatch { if amountMismatch {
return "", fmt.Errorf("transaction amount mismatch") return fmt.Errorf("transaction amount mismatch")
} }
return "", fmt.Errorf("matching solana transfer to order address not found") return fmt.Errorf("matching solana transfer to order address not found")
} }
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error { func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
@@ -642,6 +985,14 @@ func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) erro
} }
func ensureSolanaConfirmations(network, sig string) error { func ensureSolanaConfirmations(network, sig string) error {
rpcURL, err := resolveSolanaRpcURL()
if err != nil {
return err
}
return ensureSolanaConfirmationsWithRPC(network, sig, rpcURL)
}
func ensureSolanaConfirmationsWithRPC(network, sig string, rpcURL string) error {
chain, err := data.GetChainByNetwork(network) chain, err := data.GetChainByNetwork(network)
if err != nil { if err != nil {
return err return err
@@ -654,7 +1005,7 @@ func ensureSolanaConfirmations(network, sig string) error {
return nil return nil
} }
body, err := SolRetryClient("getSignatureStatuses", []interface{}{ body, err := solRetryClientWithURL(rpcURL, "getSignatureStatuses", []interface{}{
[]string{sig}, []string{sig},
map[string]interface{}{"searchTransactionHistory": true}, map[string]interface{}{"searchTransactionHistory": true},
}) })
+455 -8
View File
@@ -1,7 +1,9 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,17 +11,40 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "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/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types" "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) { func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
hash := strings.Repeat("a", 64) hash := strings.Repeat("a", 64)
if !isEvmHash(hash) { if !isEvmHash(hash) {
@@ -88,6 +113,275 @@ func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
} }
} }
func TestValidateManualAptosPayment(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "USDC",
ContractAddress: usdc,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create aptos usdc token: %v", err)
}
receive, err := addressutil.NormalizeMoveAddress("0xa")
if err != nil {
t.Fatalf("normalize aptos receive: %v", err)
}
store, err := addressutil.NormalizeMoveAddress("0x11")
if err != nil {
t.Fatalf("normalize aptos store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress("0x12")
if err != nil {
t.Fatalf("normalize aptos sender store: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
case r.URL.Path == "/v1":
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 4.2,
ReceiveAddress: receive,
}
got, err := ValidateManualAptosPayment(order, "ABC")
if err != nil {
t.Fatalf("ValidateManualAptosPayment(): %v", err)
}
if got != "0xabc" {
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
}
}
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) { func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -117,6 +411,154 @@ func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
} }
} }
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) { func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}} order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
txTime := uint64(time.Now().Add(-time.Hour).Unix()) txTime := uint64(time.Now().Add(-time.Hour).Unix())
@@ -220,15 +662,13 @@ func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("recipient address: %v", err) t.Fatalf("recipient address: %v", err)
} }
if err = dao.Mdb.Create(&mdb.ChainToken{ upsertTestChainToken(t, mdb.ChainToken{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
Symbol: "USDT", Symbol: "USDT",
ContractAddress: contractAddress, ContractAddress: contractAddress,
Decimals: 6, Decimals: 6,
Enabled: true, Enabled: true,
}).Error; err != nil { })
t.Fatalf("create token: %v", err)
}
rawAmount := big.NewInt(1230000) rawAmount := big.NewInt(1230000)
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount) tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
@@ -271,7 +711,12 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
rawAmount := big.NewInt(1230000) rawAmount := big.NewInt(1230000)
blockTimeMs := time.Now().Add(time.Minute).UnixMilli() blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
requestCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 var req map[string]string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode tron request: %v", err) t.Errorf("decode tron request: %v", err)
@@ -333,18 +778,17 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
Url: server.URL, Url: server.URL,
Enabled: true, Enabled: true,
Status: mdb.RpcNodeStatusOk, Status: mdb.RpcNodeStatusOk,
Purpose: mdb.RpcNodePurposeManualVerify,
}).Error; err != nil { }).Error; err != nil {
t.Fatalf("create tron rpc node: %v", err) t.Fatalf("create tron rpc node: %v", err)
} }
if err = dao.Mdb.Create(&mdb.ChainToken{ upsertTestChainToken(t, mdb.ChainToken{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
Symbol: "USDT", Symbol: "USDT",
ContractAddress: contractAddress, ContractAddress: contractAddress,
Decimals: 6, Decimals: 6,
Enabled: true, Enabled: true,
}).Error; err != nil { })
t.Fatalf("create token: %v", err)
}
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: "manual-tron-http-flow", TradeId: "manual-tron-http-flow",
OrderId: "manual-tron-http-flow", OrderId: "manual-tron-http-flow",
@@ -366,6 +810,9 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
if got != txID { if got != txID {
t.Fatalf("canonical tx id = %q, want %q", 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) { func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
+203
View File
@@ -0,0 +1,203 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"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"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/dromara/carbon/v2"
)
func seedTestAptosRPCNode(t *testing.T, url, purpose, status string, weight int) mdb.RpcNode {
t.Helper()
node := mdb.RpcNode{
Network: mdb.NetworkAptos,
Url: url,
Type: mdb.RpcNodeTypeHttp,
Weight: weight,
Enabled: true,
Purpose: purpose,
Status: status,
}
if node.Purpose == "" {
node.Purpose = mdb.RpcNodePurposeGeneral
}
if node.Status == "" {
node.Status = mdb.RpcNodeStatusOk
}
if err := dao.Mdb.Create(&node).Error; err != nil {
t.Fatalf("seed aptos rpc_nodes: %v", err)
}
return node
}
func TestAptosGetLedgerVersionUsesRpcNodes(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
var disabledCalls int
disabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
disabledCalls++
_, _ = w.Write([]byte(`{"ledger_version":"999"}`))
}))
defer disabled.Close()
disabledNode := mdb.RpcNode{
Network: mdb.NetworkAptos,
Url: disabled.URL,
Type: mdb.RpcNodeTypeHttp,
Weight: 1000,
Enabled: false,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(&disabledNode).Error; err != nil {
t.Fatalf("seed disabled aptos rpc_nodes: %v", err)
}
if err := dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", disabledNode.ID).Update("enabled", false).Error; err != nil {
t.Fatalf("disable aptos rpc_nodes: %v", err)
}
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/v1" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ledger_version":"123"}`))
}))
defer server.Close()
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
got, err := AptosGetLedgerVersion()
if err != nil {
t.Fatalf("AptosGetLedgerVersion(): %v", err)
}
if got != 123 {
t.Fatalf("ledger version = %d, want 123", got)
}
if calls != 1 {
t.Fatalf("rpc calls = %d, want 1", calls)
}
if disabledCalls != 0 {
t.Fatalf("disabled rpc calls = %d, want 0", disabledCalls)
}
}
func TestAptosGetTransactionsFallsBackToAlternateRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
var primaryCalls int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
primaryCalls++
http.Error(w, "temporary failure", http.StatusBadGateway)
}))
defer primary.Close()
var backupCalls int
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupCalls++
if r.URL.Path != "/v1/transactions" || r.URL.Query().Get("start") != "10" || r.URL.Query().Get("limit") != "2" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer backup.Close()
seedTestAptosRPCNode(t, primary.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
seedTestAptosRPCNode(t, backup.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 1)
body, err := AptosGetTransactions(10, 2)
if err != nil {
t.Fatalf("AptosGetTransactions(): %v", err)
}
if string(body) != "[]" {
t.Fatalf("transactions body = %s, want []", string(body))
}
if primaryCalls != 1 || backupCalls != 1 {
t.Fatalf("rpc calls primary=%d backup=%d, want 1/1", primaryCalls, backupCalls)
}
}
func TestValidateManualAptosPaymentUsesManualRpcFallback(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
const usdc = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
upsertTestChainToken(t, mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "USDC",
ContractAddress: usdc,
Decimals: 6,
Enabled: true,
})
receive, err := addressutil.NormalizeMoveAddress("0xa")
if err != nil {
t.Fatalf("normalize aptos receive: %v", err)
}
store, err := addressutil.NormalizeMoveAddress("0x31")
if err != nil {
t.Fatalf("normalize aptos store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress("0x32")
if err != nil {
t.Fatalf("normalize aptos sender store: %v", err)
}
var generalCalls int
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
generalCalls++
http.Error(w, "general unavailable", http.StatusBadGateway)
}))
defer general.Close()
var manualCalls int
manual := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
manualCalls++
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
case r.URL.Path == "/v1":
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
default:
http.NotFound(w, r)
}
}))
defer manual.Close()
seedTestAptosRPCNode(t, general.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
seedTestAptosRPCNode(t, manual.URL, mdb.RpcNodePurposeManualVerify, mdb.RpcNodeStatusOk, 1)
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 4.2,
ReceiveAddress: receive,
}
got, err := ValidateManualAptosPayment(order, "ABC")
if err != nil {
t.Fatalf("ValidateManualAptosPayment(): %v", err)
}
if got != "0xabc" {
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
}
if generalCalls != 1 || manualCalls != 2 {
t.Fatalf("rpc calls general=%d manual=%d, want 1/2", generalCalls, manualCalls)
}
}
+232
View File
@@ -0,0 +1,232 @@
package service
import (
"errors"
"fmt"
"math/big"
"strings"
"sync"
"time"
"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"
)
var gProcessedMoveTx sync.Map // transfer-level key -> unix timestamp
var processMoveOrder = OrderProcessing
type MoveObservedTransfer struct {
Network string
ReceiveAddress string
Token string
RawAmount *big.Int
Decimals int
MinAmount float64
Amount float64
TxID string
BlockTimeMs int64
Version int64
TransferKey string
}
func ProcessMoveObservedTransferResult(transfer MoveObservedTransfer) error {
net := moveLogLabel(transfer.Network)
cacheKey := MoveTransferCacheKey(transfer)
now := time.Now().Unix()
if _, loaded := gProcessedMoveTx.LoadOrStore(cacheKey, now); loaded {
return nil
}
clearCache := func() {
gProcessedMoveTx.Delete(cacheKey)
}
if transfer.RawAmount == nil || transfer.RawAmount.Sign() <= 0 || transfer.Amount <= 0 {
return nil
}
if transfer.MinAmount > 0 && transfer.Amount < transfer.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip below min amount tx=%s amount=%.2f min=%.2f", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, transfer.MinAmount)
return nil
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(transfer.Network, transfer.ReceiveAddress, transfer.Token, transfer.Amount)
if err != nil {
clearCache()
log.Sugar.Errorf("[%s-%s][%s] lock lookup failed tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, err)
return err
}
if tradeID == "" {
raw := ""
if transfer.RawAmount != nil {
raw = transfer.RawAmount.String()
}
log.Sugar.Infof("[%s-%s][%s] skip unmatched tx=%s amount=%.8f raw=%s decimals=%d", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, raw, transfer.Decimals)
return nil
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
clearCache()
log.Sugar.Errorf("[%s-%s][%s] load order trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return err
}
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
log.Sugar.Warnf("[%s-%s][%s] skip tx=%s trade_id=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, tradeID, err)
return nil
}
req := &request.OrderProcessingRequest{
ReceiveAddress: transfer.ReceiveAddress,
Token: transfer.Token,
Network: transfer.Network,
TradeId: tradeID,
Amount: transfer.Amount,
BlockTransactionId: transfer.TxID,
}
if err = processMoveOrder(req); err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[%s-%s][%s] already resolved trade_id=%s tx=%s reason=%v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return nil
}
clearCache()
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing failed trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return err
}
sendPaymentNotification(order)
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s tx=%s", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID)
return nil
}
func MoveTransferCacheKey(transfer MoveObservedTransfer) string {
if transfer.TransferKey != "" {
return strings.ToLower(strings.TrimSpace(transfer.TransferKey))
}
receive := strings.ToLower(strings.TrimSpace(transfer.ReceiveAddress))
token := strings.ToUpper(strings.TrimSpace(transfer.Token))
raw := ""
if transfer.RawAmount != nil {
raw = transfer.RawAmount.String()
}
switch strings.ToLower(strings.TrimSpace(transfer.Network)) {
case mdb.NetworkAptos:
return fmt.Sprintf("aptos:%d:%s:%s:%s", transfer.Version, receive, token, raw)
default:
return strings.ToLower(strings.TrimSpace(transfer.Network)) + ":" + strings.TrimSpace(transfer.TxID)
}
}
func EnsureMoveTransferMatchesOrder(order *mdb.Orders, transfer MoveObservedTransfer) error {
if order == nil || order.ID == 0 {
return fmt.Errorf("order not found")
}
if strings.ToLower(strings.TrimSpace(order.Network)) != strings.ToLower(strings.TrimSpace(transfer.Network)) {
return fmt.Errorf("network mismatch")
}
orderAddr, err := addressutil.NormalizeMoveAddress(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
transferAddr, err := addressutil.NormalizeMoveAddress(transfer.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid transfer receive address: %w", err)
}
if orderAddr != transferAddr {
return fmt.Errorf("transaction recipient mismatch")
}
if !strings.EqualFold(order.Token, transfer.Token) {
return fmt.Errorf("token mismatch")
}
if transfer.BlockTimeMs <= 0 {
return fmt.Errorf("transaction block time missing")
}
if transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Decimals) {
return fmt.Errorf("transaction amount mismatch")
}
return nil
}
func moveWalletWatched(address string, wallets map[string]struct{}) bool {
if address == "" || len(wallets) == 0 {
return false
}
_, ok := wallets[address]
return ok
}
func moveAptosTransferKey(version int64, eventIndex int, receive, token string, raw *big.Int) string {
rawText := ""
if raw != nil {
rawText = raw.String()
}
return fmt.Sprintf("aptos:%d:%d:%s:%s:%s", version, eventIndex, strings.ToLower(strings.TrimSpace(receive)), strings.ToUpper(strings.TrimSpace(token)), rawText)
}
func resolveMoveToken(network, assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
assetID = addressutil.NormalizeMoveAssetID(assetID)
for i := range tokens {
contract := strings.TrimSpace(tokens[i].ContractAddress)
if contract == "" {
continue
}
if addressutil.MoveAssetMatches(assetID, contract) {
return &tokens[i]
}
}
return nil
}
func firstMoveAddress(values ...string) string {
for _, v := range values {
if normalized, err := addressutil.NormalizeMoveAddress(v); err == nil {
return normalized
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func parseSignedBigInt(raw string) (*big.Int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, false
}
out, ok := new(big.Int).SetString(raw, 10)
return out, ok
}
func rawToFloat(raw *big.Int, decimals int) float64 {
if raw == nil {
return 0
}
value, _ := new(big.Rat).SetFrac(new(big.Int).Set(raw), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)).Float64()
return value
}
func recordMoveRPCResult(network string, errp *error) {
if errp != nil && *errp != nil {
data.RecordRpcFailure(network)
return
}
data.RecordRpcSuccess(network)
}
func moveLogLabel(network string) string {
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkAptos:
return "APTOS"
default:
return strings.ToUpper(network)
}
}
+284
View File
@@ -0,0 +1,284 @@
package service
import (
"math/big"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/dromara/carbon/v2"
)
func TestParseAptosTransfersUSDTAndUSDCFungibleEvents(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
usdcStore, _ := addressutil.NormalizeMoveAddress("0x12")
usdcSenderStore, _ := addressutil.NormalizeMoveAddress("0x13")
usdtStore, _ := addressutil.NormalizeMoveAddress("0x14")
usdtSenderStore, _ := addressutil.NormalizeMoveAddress("0x15")
noOwnerStore, _ := addressutil.NormalizeMoveAddress("0x16")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"abc",
"version":"101",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"2500000","store":"` + usdcSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2500000","store":"` + usdcStore + `"}}
],
"changes":[
{"address":"` + usdcSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
{"address":"` + usdcStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
{"address":"` + usdcStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"0xa"}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"def",
"version":"102",
"timestamp":"1700000001123456",
"payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdt + `"},"` + receive2 + `","3100000"],"type":"entry_function_payload"},
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3100000","store":"` + usdtSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"3100000","store":"` + usdtStore + `"}}
],
"changes":[
{"address":"` + usdtSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + usdtStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + usdtStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-withdraw-only",
"version":"103",
"timestamp":"1700000002123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4100000","metadata_address":"` + usdc + `"}}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-no-owner",
"version":"104",
"timestamp":"1700000003123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"5100000","store":"` + usdcSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"5100000","store":"` + noOwnerStore + `"}}
],
"changes":[
{"address":"` + noOwnerStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":false,
"hash":"skip-failed",
"events":[
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"1","metadata_address":"` + usdc + `"}}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-non-store-owner-fallback",
"version":"105",
"timestamp":"1700000004123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"6100000","metadata_address":"` + usdc + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"6100000","metadata_address":"` + usdc + `"}}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: usdc, Decimals: 6, Enabled: true, MinAmount: 1},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true, MinAmount: 3},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].Token != "USDC" || got[0].Amount != 2.5 || got[0].TxID != "0xabc" || got[0].ReceiveAddress != receive {
t.Fatalf("usdc transfer = %#v", got[0])
}
if got[0].TransferKey == "" {
t.Fatalf("transfer key should be non-empty: %#v", got[0])
}
if got[0].MinAmount != 1 {
t.Fatalf("usdc min amount = %.2f, want 1", got[0].MinAmount)
}
gotSecondWallet, err := ParseAptosTransfers(body, receive2, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers second wallet: %v", err)
}
if len(gotSecondWallet) != 1 || gotSecondWallet[0].Token != "USDT" || gotSecondWallet[0].Amount != 3.1 || gotSecondWallet[0].TxID != "0xdef" {
t.Fatalf("usdt transfer = %#v", gotSecondWallet)
}
}
func TestParseAptosTransfersConfiguredCustomFungibleEvent(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
custom := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
store, _ := addressutil.NormalizeMoveAddress("0x21")
senderStore, _ := addressutil.NormalizeMoveAddress("0x22")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"custom",
"version":"201",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"7500000","store":"` + senderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"7500000","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: custom, Decimals: 6, Enabled: true},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].Token != "MOVEUSD" || got[0].Amount != 7.5 || got[0].TxID != "0xcustom" {
t.Fatalf("custom transfer = %#v", got[0])
}
}
func TestParseAptosTransfersSplitDepositsMatchSingleWithdraw(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
withdrawStore, _ := addressutil.NormalizeMoveAddress("0x21")
depositStore, _ := addressutil.NormalizeMoveAddress("0x22")
depositStore2, _ := addressutil.NormalizeMoveAddress("0x23")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"split",
"version":"201",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3000000","store":"` + withdrawStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"1000000","store":"` + depositStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2000000","store":"` + depositStore2 + `"}}
],
"changes":[
{"address":"` + withdrawStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"},
{"address":"` + depositStore2 + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore2 + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
}
wallets := map[string]struct{}{receive: {}, receive2: {}}
got, err := ParseAptosTransfersForWallets(body, wallets, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfersForWallets: %v", err)
}
if len(got) != 2 {
t.Fatalf("transfers len = %d, want 2: %#v", len(got), got)
}
if got[0].Token != "USDT" || got[0].Amount != 1 || got[0].ReceiveAddress != receive || got[0].TxID != "0xsplit" {
t.Fatalf("first split transfer = %#v", got[0])
}
if got[1].Token != "USDT" || got[1].Amount != 2 || got[1].ReceiveAddress != receive2 || got[1].TxID != "0xsplit" {
t.Fatalf("second split transfer = %#v", got[1])
}
}
func TestParseAptosTransfersUsesStoreOwnerWhenGuidAccountIsZero(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
store, _ := addressutil.NormalizeMoveAddress("0x31")
senderStore, _ := addressutil.NormalizeMoveAddress("0x32")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"decimal",
"version":"301",
"timestamp":"1700000000123456",
"events":[
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Withdraw","data":{"amount":"10000","store":"` + senderStore + `"}},
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Deposit","data":{"amount":"10000","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].ReceiveAddress != receive || got[0].Decimals != 6 || got[0].Amount != 0.01 || got[0].RawAmount.String() != "10000" {
t.Fatalf("transfer amount = %#v", got[0])
}
}
func TestEnsureMoveTransferMatchesOrder(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xc")
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 3.5,
ReceiveAddress: strings.ToUpper(receive),
}
transfer := MoveObservedTransfer{
Network: mdb.NetworkAptos,
ReceiveAddress: receive,
Token: "USDC",
RawAmount: big.NewInt(3_500_000),
Decimals: 6,
BlockTimeMs: time.Now().UnixMilli(),
}
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
t.Fatalf("EnsureMoveTransferMatchesOrder: %v", err)
}
transfer.RawAmount = big.NewInt(3_400_000)
if err := EnsureMoveTransferMatchesOrder(order, transfer); err == nil || !strings.Contains(err.Error(), "amount") {
t.Fatalf("amount mismatch err = %v, want amount error", err)
}
}
+73
View File
@@ -102,6 +102,79 @@ func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
} }
} }
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 { func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
form := map[string]string{ form := map[string]string{
"code": "200", "code": "200",
+454 -68
View File
@@ -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,9 +14,11 @@ 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,35 +49,83 @@ func normalizeOrderAddressByNetwork(network, address string) string {
switch network { switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return strings.ToLower(address) return strings.ToLower(address)
case mdb.NetworkTon:
if normalized, err := addressutil.NormalizeTonAddress(address); err == nil {
return normalized
}
return address
case mdb.NetworkAptos:
if normalized, err := addressutil.NormalizeMoveAddress(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
}
if !ChainTokenReadyForPayment(*tokenRow) {
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) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token)) token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency)) currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network)) 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
}
epayType := ""
if paymentType == mdb.PaymentTypeEpay {
epayType = strings.TrimSpace(req.EpayType)
}
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
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
@@ -84,9 +134,50 @@ 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,
EpayType: epayType,
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
@@ -116,10 +207,11 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
Token: token, Token: token,
Network: network, Network: network,
Status: mdb.StatusWaitPay, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl, NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
PaymentType: req.PaymentType, EpayType: epayType,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey), ApiKeyID: apiKeyID(apiKey),
} }
@@ -134,19 +226,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, err return nil, err
} }
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp() return buildCreateTransactionResponse(order), nil
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.
@@ -179,15 +259,21 @@ 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 == "" {
@@ -205,8 +291,10 @@ 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 err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil { if hasTransactionLock(&sub) {
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err) 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)
}
} }
} }
return nil return nil
@@ -257,8 +345,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
} }
// Release parent's own wallet lock // Release parent's own wallet lock
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil { if hasTransactionLock(parent) {
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err) 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)
}
} }
// Release sibling locks after their status transitions commit. // Release sibling locks after their status transitions commit.
@@ -268,8 +358,10 @@ 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 err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { if hasTransactionLock(&sib) {
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err) 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)
}
} }
} }
@@ -316,9 +408,11 @@ 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 {
date := time.Now().Format("20060102") buf := make([]byte, 18)
r := rand.Intn(1000) if _, err := cryptorand.Read(buf); err != nil {
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
}
return base64.RawURLEncoding.EncodeToString(buf)
} }
// GetOrderInfoByTradeId returns a validated order. // GetOrderInfoByTradeId returns a validated order.
@@ -333,9 +427,106 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
return order, nil return order, nil
} }
const MaxSubOrders = 2 // SubmitManualPayment verifies a submitted transaction hash and marks the
// matching on-chain order paid using the same path as automatic chain scans.
func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
tradeId = strings.TrimSpace(tradeId)
blockTransactionId = strings.TrimSpace(blockTransactionId)
// SwitchNetwork creates or returns an existing sub-order for a different token+network. order, err := GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
return submitManualPaymentForOrder(order, blockTransactionId)
}
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
// already stored on any order before touching RPC so repeated/public probes do
// not spend RPC quota. Admin mark-paid intentionally keeps using
// SubmitManualPayment and the existing verification path.
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
tradeId = strings.TrimSpace(tradeId)
blockTransactionId = strings.TrimSpace(blockTransactionId)
order, err := GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if err = validateManualPaymentOrder(order); err != nil {
return nil, err
}
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
return nil, err
}
return submitManualPaymentForOrder(order, blockTransactionId)
}
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
blockTransactionId = strings.TrimSpace(blockTransactionId)
if err := validateManualPaymentOrder(order); err != nil {
return nil, err
}
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
if err != nil {
var rspErr *constant.RspError
if errors.As(err, &rspErr) {
return nil, err
}
return nil, constant.ManualPaymentVerifyErr
}
if err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: verifiedBlockTransactionID,
}); err != nil {
return nil, err
}
updatedOrder, err := GetOrderInfoByTradeId(order.TradeId)
if err != nil {
return nil, err
}
return &response.ManualPaymentResponse{
TradeId: updatedOrder.TradeId,
Status: updatedOrder.Status,
BlockTransactionId: updatedOrder.BlockTransactionId,
}, nil
}
func validateManualPaymentOrder(order *mdb.Orders) error {
if order.Status != mdb.StatusWaitPay {
return constant.OrderNotWaitPay
}
if !isOnChainOrder(order.PayProvider) {
return constant.ManualPaymentProviderErr
}
return nil
}
func isOnChainOrder(payProvider string) bool {
payProvider = strings.TrimSpace(payProvider)
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
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()
@@ -354,7 +545,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 { if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
return nil, constant.OrderNotWaitPay return nil, constant.OrderNotWaitPay
} }
@@ -362,6 +553,10 @@ 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)
@@ -382,8 +577,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return buildCheckoutResponse(existing), nil return buildCheckoutResponse(existing), nil
} }
// 4. Check sub-order limit // 4. Only one sub-order may ever be created under a parent. Existing
count, err := data.CountActiveSubOrders(parent.TradeId) // active sub-orders are reused above; any other target after that is blocked.
count, err := data.CountSubOrders(parent.TradeId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -391,7 +587,16 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return nil, constant.SubOrderLimitExceeded return nil, constant.SubOrderLimitExceeded
} }
// 5. Calculate amount for the new network // 5. Validate the target chain and asset before any rate lookup so
// 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
@@ -402,10 +607,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// 6. Find and lock wallet // 7. 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
@@ -424,7 +626,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return nil, constant.NotAvailableAmountErr return nil, constant.NotAvailableAmountErr
} }
// 7. Create sub-order // 8. Create sub-order
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
subOrder := &mdb.Orders{ subOrder := &mdb.Orders{
TradeId: subTradeID, TradeId: subTradeID,
@@ -441,6 +643,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
NotifyUrl: "", NotifyUrl: "",
RedirectUrl: parent.RedirectUrl, RedirectUrl: parent.RedirectUrl,
Name: parent.Name, Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
@@ -464,7 +667,63 @@ 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 isEPayOrder(order) {
paymentType = mdb.PaymentTypeEpay
}
return &response.CheckoutCounterResponse{ return &response.CheckoutCounterResponse{
TradeId: order.TradeId, TradeId: order.TradeId,
Amount: order.Amount, Amount: order.Amount,
@@ -473,14 +732,93 @@ 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: buildPublicRedirectURL(order),
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
@@ -492,6 +830,29 @@ 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
@@ -504,16 +865,21 @@ 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
} }
_ = data.MarkOrderSelected(parent.TradeId) updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, 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.CountActiveSubOrders(parent.TradeId) count, err := data.CountSubOrders(parent.TradeId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -557,6 +923,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
NotifyUrl: "", NotifyUrl: "",
RedirectUrl: parent.RedirectUrl, RedirectUrl: parent.RedirectUrl,
Name: parent.Name, Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk, CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOkPay, PayProvider: mdb.PaymentProviderOkPay,
@@ -588,16 +955,35 @@ 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, err return nil, constant.PaymentProviderCreateErr
} }
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
finalizeTx := dao.Mdb.Begin()
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
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.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID) _ = data.ExpireOrderByTradeID(subTradeID)
return nil, err return nil, err
} }
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
resp := buildCheckoutResponse(subOrder) resp := buildCheckoutResponse(subOrder)
resp.PaymentUrl = okpayOrder.PayURL resp.PaymentUrl = okpayOrder.PayURL
+752 -13
View File
@@ -1,8 +1,10 @@
package service package service
import ( import (
"encoding/base64"
"fmt" "fmt"
"io" "io"
"math/big"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -17,6 +19,7 @@ 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 {
@@ -26,7 +29,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
Token: "USDT", Token: "USDT",
Network: "tron", Network: "tron",
Amount: amount, Amount: amount,
NotifyUrl: "https://merchant.example/callback", NotifyUrl: "https://93.184.216.34/callback",
} }
} }
@@ -36,6 +39,31 @@ 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()
@@ -50,6 +78,94 @@ 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()
@@ -246,6 +362,614 @@ 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 TestCreateTransactionRejectsEnabledMisconfiguredAptosUSDT(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "USDT",
ContractAddress: "",
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create aptos usdt token: %v", err)
}
req := newCreateTransactionRequest("order_aptos_misconfigured_usdt_1", 10)
req.Network = mdb.NetworkAptos
req.Token = "USDT"
_, err := CreateTransaction(req, nil)
if err != constant.SupportedAssetNotFound {
t.Fatalf("create misconfigured Aptos USDT error = %v, want %v", err, constant.SupportedAssetNotFound)
}
}
func TestEnsureEnabledOrderAssetAllowsConfiguredAptosCustomToken(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "MOVEUSD",
ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create aptos custom token: %v", err)
}
token, err := ensureEnabledOrderAsset(mdb.NetworkAptos, "MOVEUSD")
if err != nil {
t.Fatalf("ensureEnabledOrderAsset(): %v", err)
}
if token.Symbol != "MOVEUSD" {
t.Fatalf("token symbol = %q, want MOVEUSD", token.Symbol)
}
}
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()
@@ -764,7 +1488,6 @@ 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",
@@ -774,13 +1497,29 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
t.Fatalf("switch to ethereum sub-order: %v", err) t.Fatalf("switch to ethereum sub-order: %v", err)
} }
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{ bscTradeID := GenerateCode()
TradeId: parentResp.TradeId, bscAddress := strings.ToLower("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
Token: "usdt", subBsc := &mdb.Orders{
Network: mdb.NetworkBsc, TradeId: bscTradeID,
}) OrderId: bscTradeID,
if err != nil { ParentTradeId: parentResp.TradeId,
t.Fatalf("switch to bsc sub-order: %v", err) Amount: parentResp.Amount,
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.
@@ -797,12 +1536,12 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
} }
// BSC sibling must be expired. // BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId) reloadedBsc, err := data.GetOrderInfoByTradeId(subBsc.TradeId)
if err != nil { if err != nil {
t.Fatalf("reload bsc sub-order: %v", err) t.Fatalf("reload bsc sub-order: %v", err)
} }
if subBsc.Status != mdb.StatusExpired { if reloadedBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired) t.Fatalf("bsc sibling status = %d, want %d (expired)", reloadedBsc.Status, mdb.StatusExpired)
} }
// Parent runtime lock must be released. // Parent runtime lock must be released.
@@ -817,7 +1556,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, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount) mdb.NetworkBsc, subBsc.ReceiveAddress, subBsc.Token, subBsc.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)
} }
+10 -14
View File
@@ -1,8 +1,8 @@
package service package service
import ( import (
"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/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
) )
@@ -20,19 +20,15 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
if orderInfo.ID <= 0 { if orderInfo.ID <= 0 {
return nil, ErrOrder return nil, ErrOrder
} }
resp := buildCheckoutResponse(orderInfo)
resp := &response.CheckoutCounterResponse{ if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
TradeId: orderInfo.TradeId, providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
Amount: orderInfo.Amount, if rowErr != nil {
ActualAmount: orderInfo.ActualAmount, return nil, rowErr
Token: orderInfo.Token, }
Currency: orderInfo.Currency, if providerRow.ID > 0 {
ReceiveAddress: orderInfo.ReceiveAddress, resp.PaymentUrl = providerRow.PayURL
Network: orderInfo.Network, }
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
} }
return resp, nil return resp, nil
} }
+32
View File
@@ -0,0 +1,32 @@
package service
import (
"strings"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func ChainTokenReadyForPayment(token mdb.ChainToken) bool {
if strings.TrimSpace(token.Symbol) == "" {
return false
}
if chainTokenRequiresAssetID(token) && strings.TrimSpace(token.ContractAddress) == "" {
return false
}
return true
}
func chainTokenRequiresAssetID(token mdb.ChainToken) bool {
network := strings.ToLower(strings.TrimSpace(token.Network))
symbol := strings.ToUpper(strings.TrimSpace(token.Symbol))
switch network {
case mdb.NetworkTron:
return symbol != "TRX"
case mdb.NetworkSolana:
return symbol != "SOL"
case mdb.NetworkTon:
return symbol != TonNativeSymbol
default:
return true
}
}
+148 -22
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"net/http"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -28,6 +29,12 @@ var gProcessedSignatures sync.Map // sig -> unix timestamp
var processSolanaOrder = OrderProcessing 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)
@@ -293,26 +300,82 @@ type solSignatureResult struct {
} }
func resolveSolanaRpcURL() (string, error) { func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp) node, err := resolveSolanaRpcNode()
if err != nil { if err != nil {
return "", err return "", err
} }
if node == nil || node.ID == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url) rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil return rpcURL, nil
} }
func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 {
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
node.Url = rpcURL
return node, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试 // SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) { func SolRetryClient(method string, params []interface{}) (body []byte, err 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(5) client.SetRetryCount(solRPCRetryCount)
client.SetRetryWaitTime(2 * time.Second) client.SetRetryWaitTime(solRPCRetryWait)
client.SetRetryMaxWaitTime(10 * time.Second) client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
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
@@ -323,25 +386,35 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false return false
}) })
rpcUrl, err := resolveSolanaRpcURL() req := client.R().SetHeader("Content-Type", "application/json")
if err != nil { for key, values := range headers {
return nil, err for _, value := range values {
req.SetHeader(key, value)
}
} }
resp, err := client.R(). resp, err := req.SetBody(map[string]interface{}{
SetHeader("Content-Type", "application/json"). "jsonrpc": "2.0",
SetBody(map[string]interface{}{ "id": 1,
"jsonrpc": "2.0", "method": method,
"id": 1, "params": params,
"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
} }
@@ -378,6 +451,32 @@ 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,
@@ -401,6 +500,33 @@ 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
+305
View File
@@ -28,6 +28,7 @@ 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 {
@@ -59,6 +60,310 @@ 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) { func TestSolCallBackDoesNotCacheSignatureAfterRetryableOrderProcessingError(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+14 -4
View File
@@ -18,18 +18,28 @@ import (
) )
func resolveTronNode() (string, string, error) { func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp) node, err := ResolveTronRpcNode()
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 "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp) return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
} }
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/") rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
if rpcURL == "" { if rpcURL == "" {
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID) return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
} }
return rpcURL, node.ApiKey, nil node.Url = rpcURL
return node, nil
} }
func ResolveTronNode() (string, string, error) { func ResolveTronNode() (string, string, error) {
+18 -4
View File
@@ -12,8 +12,24 @@ 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()
@@ -158,15 +174,13 @@ func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing.
contract := common.HexToAddress("0x3333333333333333333333333333333333333333") contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444") receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
if err := dao.Mdb.Create(&mdb.ChainToken{ upsertTaskServiceTestChainToken(t, mdb.ChainToken{
Network: mdb.NetworkEthereum, Network: mdb.NetworkEthereum,
Symbol: tokenSym, Symbol: tokenSym,
ContractAddress: contract.Hex(), ContractAddress: contract.Hex(),
Decimals: 6, Decimals: 6,
Enabled: true, Enabled: true,
}).Error; err != nil { })
t.Fatalf("seed chain token: %v", err)
}
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: tradeID, TradeId: tradeID,
+43
View File
@@ -51,6 +51,49 @@ func TestResolveTronNode_WithRow(t *testing.T) {
} }
} }
func TestResolveTronNodeIgnoresManualVerifyOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://paid-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "paid-key",
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
func TestResolveTronNodeUsesGeneralWhenManualVerifyExists(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://paid-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "paid-key", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://general-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "general-key", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
gotURL, gotKey, err := resolveTronNode()
if err != nil {
t.Fatalf("resolveTronNode(): %v", err)
}
if gotURL != "https://general-tron.example.com" || gotKey != "general-key" {
t.Fatalf("resolveTronNode() = (%q, %q), want general node", gotURL, gotKey)
}
}
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored. // 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)
+37
View File
@@ -0,0 +1,37 @@
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
}
+262
View File
@@ -0,0 +1,262 @@
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
}
+173
View File
@@ -0,0 +1,173 @@
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)
}
}
+25 -46
View File
@@ -2,9 +2,7 @@ package mq
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -13,6 +11,7 @@ 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/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
@@ -25,18 +24,12 @@ import (
// other secret would produce a signature the merchant can't verify. // other secret would produce a signature the merchant can't verify.
// The admin can resend the callback after fixing the key. // The admin can resend the callback after fixing the key.
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) { func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order.ApiKeyID == 0 { row, err := service.ResolveOrderApiKey(order)
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId) if err != nil || row == nil || row.ID == 0 {
} if err != nil {
row, err := data.GetApiKeyByID(order.ApiKeyID) return nil, err
if err != nil { }
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err) return nil, errors.New("api key unavailable")
}
if row.ID == 0 {
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
}
if row.Status != mdb.ApiKeyStatusEnable {
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
} }
return row, nil return row, nil
} }
@@ -48,10 +41,12 @@ 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() {
@@ -91,8 +86,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", "network", "receive_address", "token", "actual_amount"). Select("id", "trade_id", "status", "network", "receive_address", "token", "actual_amount", "pay_provider").
Where("status = ?", mdb.StatusWaitPay). Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Where("created_at <= ?", expirationCutoff). Where("created_at <= ?", expirationCutoff).
Order("id asc"). Order("id asc").
Limit(batchSize). Limit(batchSize).
@@ -115,6 +110,17 @@ 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)
} }
@@ -192,40 +198,13 @@ 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 order.PaymentType { switch {
case mdb.PaymentTypeEpay: case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
// EPAY uses pid (integer) and secret_key as "key". formData, err := service.BuildEPayResultParams(order, apiKeyRow)
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
if convErr != nil {
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
}
notifyData := response.OrderNotifyResponseEpay{
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: "alipay",
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil { if err != nil {
return err return err
} }
formData := map[string]string{
"pid": fmt.Sprintf("%d", notifyData.PID),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr2,
"sign_type": "MD5",
}
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl) epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
if err != nil { if err != nil {
return err return err
+100 -5
View File
@@ -62,6 +62,21 @@ 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)
@@ -93,6 +108,14 @@ 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) {
@@ -295,7 +318,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: mdb.PaymentTypeEpay, PaymentType: "epay",
ApiKeyID: epayKey.ID, ApiKeyID: epayKey.ID,
} }
if err := dao.Mdb.Create(order).Error; err != nil { if err := dao.Mdb.Create(order).Error; err != nil {
@@ -349,7 +372,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: mdb.PaymentTypeEpay, PaymentType: "epay",
ApiKeyID: epayKey.ID, ApiKeyID: epayKey.ID,
} }
if err := dao.Mdb.Create(order).Error; err != nil { if err := dao.Mdb.Create(order).Error; err != nil {
@@ -550,7 +573,8 @@ 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: mdb.PaymentTypeEpay, EpayType: "usdt.tron",
PaymentType: "epay",
ApiKeyID: key.ID, ApiKeyID: key.ID,
} }
@@ -564,8 +588,8 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
if formPayload["sign_type"] != "MD5" { if formPayload["sign_type"] != "MD5" {
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"]) t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
} }
if formPayload["type"] != "alipay" { if formPayload["type"] != "usdt.tron" {
t.Fatalf("type = %q, want alipay", formPayload["type"]) t.Fatalf("type = %q, want usdt.tron", formPayload["type"])
} }
if formPayload["trade_status"] != "TRADE_SUCCESS" { if formPayload["trade_status"] != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"]) t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
@@ -606,6 +630,77 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
} }
} }
func TestSendOrderCallbackEpayPreservesStoredAlipayType(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
key := &mdb.ApiKey{
Name: "epay-key-stored-type",
Pid: "9201",
SecretKey: "epay-secret-9201",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(key).Error; err != nil {
t.Fatalf("create epay api key: %v", err)
}
formPayload := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for k, v := range r.Form {
if len(v) > 0 {
formPayload[k] = v[0]
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_epay_type_case_alipay",
OrderId: "order_epay_type_case_alipay",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_type_case",
Token: "USDT",
Name: "VIP",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_type_case_alipay",
EpayType: "alipay",
PaymentType: "epay",
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err)
}
if got := formPayload["type"]; got != "alipay" {
t.Fatalf("type = %q, want alipay", got)
}
signParams := map[string]interface{}{
"pid": formPayload["pid"],
"trade_no": formPayload["trade_no"],
"out_trade_no": formPayload["out_trade_no"],
"type": formPayload["type"],
"name": formPayload["name"],
"money": formPayload["money"],
"trade_status": formPayload["trade_status"],
}
calcSig, err := sign.Get(signParams, key.SecretKey)
if err != nil {
t.Fatalf("calc epay signature: %v", err)
}
if got := formPayload["sign"]; got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig)
}
}
func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) { func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+594 -31
View File
@@ -1,6 +1,8 @@
package route package route
import ( import (
"bufio"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -8,12 +10,15 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"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/model/mdb"
"github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -228,6 +233,7 @@ func TestAdminProtectedRoute_NoToken(t *testing.T) {
"/admin/api/v1/wallets", "/admin/api/v1/wallets",
"/admin/api/v1/orders", "/admin/api/v1/orders",
"/admin/api/v1/dashboard/overview", "/admin/api/v1/dashboard/overview",
"/admin/api/v1/dashboard/rpc-stats",
"/admin/api/v1/settings", "/admin/api/v1/settings",
"/admin/api/v1/rpc-nodes", "/admin/api/v1/rpc-nodes",
"/admin/api/v1/notification-channels", "/admin/api/v1/notification-channels",
@@ -284,9 +290,9 @@ func TestAdminChangePassword(t *testing.T) {
} }
} }
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint // TestAdminInitialPasswordMetadataFlow verifies that the public plaintext route
// and hash-based "password changed" detection flow. // is gone while the hash-based default-password detection flow still works.
func TestAdminInitPasswordFlow(t *testing.T) { func TestAdminInitialPasswordMetadataFlow(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
const initPassword = "init-pass-123456" const initPassword = "init-pass-123456"
@@ -314,14 +320,11 @@ func TestAdminInitPasswordFlow(t *testing.T) {
t.Fatalf("expected password_changed=false before change, got true") t.Fatalf("expected password_changed=false before change, got true")
} }
recFetch := doGet(e, "/admin/api/v1/auth/init-password") token := adminLogin(t, e, testAdminUsername, initPassword)
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{}) recFetch := doGetAdmin(e, "/admin/api/v1/auth/init-password", token)
if fetchData["username"] != testAdminUsername { if recFetch.Code != http.StatusNotFound {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"]) t.Fatalf("expected removed init-password route to return 404, got %d body=%s", recFetch.Code, recFetch.Body.String())
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
} }
var plaintextRows int64 var plaintextRows int64
if err := dao.Mdb.Unscoped(). if err := dao.Mdb.Unscoped().
@@ -330,20 +333,10 @@ func TestAdminInitPasswordFlow(t *testing.T) {
Count(&plaintextRows).Error; err != nil { Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows: %v", err) t.Fatalf("count init password plaintext rows: %v", err)
} }
if plaintextRows != 0 { if plaintextRows != 1 {
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows) t.Fatalf("expected init password plaintext to remain available, got %d rows", plaintextRows)
} }
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest {
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
}
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
}
token := adminLogin(t, e, testAdminUsername, initPassword)
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token) recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe1 := assertOK(t, recMe1) respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{}) meData1, _ := respMe1["data"].(map[string]interface{})
@@ -363,6 +356,15 @@ func TestAdminInitPasswordFlow(t *testing.T) {
if got, _ := hashData2["password_changed"].(bool); !got { if got, _ := hashData2["password_changed"].(bool); !got {
t.Fatalf("expected password_changed=true after change, got %v", got) t.Fatalf("expected password_changed=true after change, got %v", got)
} }
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows after change: %v", err)
}
if plaintextRows != 0 {
t.Fatalf("expected init password plaintext to be hard-deleted after change, got %d rows", plaintextRows)
}
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token) recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe2 := assertOK(t, recMe2) respMe2 := assertOK(t, recMe2)
@@ -522,14 +524,25 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
if nodeID == nil { if nodeID == nil {
t.Fatal("CreateRpcNode missing id") t.Fatal("CreateRpcNode missing id")
} }
if got, _ := dataObj["purpose"].(string); got != mdb.RpcNodePurposeGeneral {
t.Fatalf("created purpose = %q, want %q", got, mdb.RpcNodePurposeGeneral)
}
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64)) nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
// Update. // Update.
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{ rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "https://eth-mainnet2.example.com", "url": "https://eth-mainnet2.example.com",
"purpose": mdb.RpcNodePurposeManualVerify,
}, token) }, token)
t.Logf("UpdateRpcNode: %s", rec.Body.String()) t.Logf("UpdateRpcNode: %s", rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
updatedNode, err := data.GetRpcNodeByID(uint64(nodeID.(float64)))
if err != nil {
t.Fatalf("reload rpc node: %v", err)
}
if updatedNode.Purpose != mdb.RpcNodePurposeManualVerify {
t.Fatalf("updated purpose = %q, want %q", updatedNode.Purpose, mdb.RpcNodePurposeManualVerify)
}
// Health check — network likely unreachable in test, but route must not 404/500. // Health check — network likely unreachable in test, but route must not 404/500.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token) rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
@@ -544,6 +557,95 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
assertOK(t, rec) assertOK(t, rec)
} }
func TestAdminRpcNodes_RejectsURLTypeMismatch(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "wss://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
}, token)
t.Logf("CreateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10022)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeWs,
}, token)
t.Logf("CreateRpcNode(ws with https): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10023)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
"purpose": "invalid-purpose",
}, token)
t.Logf("CreateRpcNode(invalid purpose): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected invalid purpose to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10020)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
}, token)
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
nodeID := uint64(dataObj["id"].(float64))
nodeIDStr := fmt.Sprintf("%d", nodeID)
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "wss://eth-mainnet.example.com",
}, token)
t.Logf("UpdateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected update mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10022)
node, err := data.GetRpcNodeByID(nodeID)
if err != nil {
t.Fatalf("reload rpc node: %v", err)
}
if node.Url != "https://eth-mainnet.example.com" {
t.Fatalf("node url changed after rejected update: %q", node.Url)
}
}
func TestAdminPathIDParseErrorUsesParamsErrno(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/api-keys/not-a-number/stats", token)
assertErrorCode(t, rec, 10009)
}
func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode int) {
t.Helper()
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected HTTP 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != wantCode {
t.Fatalf("status_code = %d, want %d; response=%v", got, wantCode, resp)
}
if got, _ := resp["message"].(string); got != constant.Errno[wantCode] {
t.Fatalf("message = %q, want %q; response=%v", got, constant.Errno[wantCode], resp)
}
}
// ─── Wallets ───────────────────────────────────────────────────────────────── // ─── Wallets ─────────────────────────────────────────────────────────────────
// TestAdminWallets_CRUD verifies wallet management routes. // TestAdminWallets_CRUD verifies wallet management routes.
@@ -650,6 +752,34 @@ func TestAdminOrders_CloseNotFound(t *testing.T) {
} }
} }
func TestAdminOrders_CloseWaitSelectOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-close-wait-select",
OrderId: "order-admin-close-wait-select",
Amount: 10,
Currency: "CNY",
Status: mdb.StatusWaitSelect,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create wait-select order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/close", nil, token)
t.Logf("CloseOrder wait-select: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != mdb.StatusExpired {
t.Fatalf("status = %d, want %d", reloaded.Status, mdb.StatusExpired)
}
}
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order. // TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
func TestAdminOrders_MarkPaidNotFound(t *testing.T) { func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -692,9 +822,14 @@ func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
}) })
defer restore() defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{ jsonBytes, _ := json.Marshal(map[string]interface{}{
"block_transaction_id": "block-admin-ok", "block_transaction_id": "block-admin-ok",
}, token) })
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String()) t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
if !verified { if !verified {
@@ -857,7 +992,7 @@ func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
// ─── Dashboard ─────────────────────────────────────────────────────────────── // ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200. // TestAdminDashboard_AllRoutes verifies ordinary JSON dashboard endpoints return 200.
func TestAdminDashboard_AllRoutes(t *testing.T) { func TestAdminDashboard_AllRoutes(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
routes := []string{ routes := []string{
@@ -874,6 +1009,306 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
} }
} }
func TestAdminDashboardRpcStatsSSE(t *testing.T) {
e, token := setupAdminTestEnv(t)
data.RecordRpcSuccess("TRON")
data.RecordRpcSuccess("tron")
data.RecordRpcFailure(" tron ")
data.RecordRpcBlockHeight("tron", 64532100)
server := httptest.NewServer(e)
defer server.Close()
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, server.URL+"/admin/api/v1/dashboard/rpc-stats", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
req.Header.Set(echo.HeaderAccept, "text/event-stream")
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("sse request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("sse status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get(echo.HeaderContentType); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
reader := bufio.NewReader(resp.Body)
var eventName string
var dataLine string
for {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read sse line: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if strings.HasPrefix(line, "event: ") {
eventName = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") {
dataLine = strings.TrimPrefix(line, "data: ")
}
if line == "" && dataLine != "" {
break
}
}
if eventName != "rpc_stats" {
t.Fatalf("event = %q, want rpc_stats", eventName)
}
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(dataLine), &envelope); err != nil {
t.Fatalf("unmarshal sse data: %v data=%s", err, dataLine)
}
if got, _ := envelope["status_code"].(float64); got != 200 {
t.Fatalf("sse status_code = %v, want 200", got)
}
dataObj, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("sse data = %#v, want object", envelope["data"])
}
rows, ok := dataObj["chains"].([]interface{})
if !ok {
t.Fatalf("sse chains = %#v, want array", dataObj["chains"])
}
summary, ok := dataObj["summary"].(map[string]interface{})
if !ok {
t.Fatalf("summary = %#v, want object", dataObj["summary"])
}
if got, _ := summary["success_count"].(float64); got != 2 {
t.Fatalf("summary success_count = %v, want 2", got)
}
if got, _ := summary["failure_count"].(float64); got != 1 {
t.Fatalf("summary failure_count = %v, want 1", got)
}
if got, _ := summary["success_rate"].(float64); got < 0.666 || got > 0.667 {
t.Fatalf("summary success_rate = %v, want about 0.6667", got)
}
var tron map[string]interface{}
var solana map[string]interface{}
for _, row := range rows {
item, _ := row.(map[string]interface{})
switch item["network"] {
case mdb.NetworkTron:
tron = item
case mdb.NetworkSolana:
solana = item
}
}
if tron == nil {
t.Fatalf("missing tron row in %#v", rows)
}
if got, _ := tron["success_count"].(float64); got != 2 {
t.Fatalf("tron success_count = %v, want 2", got)
}
if got, _ := tron["failure_count"].(float64); got != 1 {
t.Fatalf("tron failure_count = %v, want 1", got)
}
if got, _ := tron["success_rate"].(float64); got < 0.666 || got > 0.667 {
t.Fatalf("tron success_rate = %v, want about 0.6667", got)
}
if got, _ := tron["latest_block_height"].(float64); got != 64532100 {
t.Fatalf("tron latest_block_height = %v, want 64532100", got)
}
if got, _ := tron["last_sync_at"].(string); got == "" {
t.Fatalf("tron last_sync_at is empty: %#v", tron)
}
if got, _ := tron["display_name"].(string); got != "TRON" {
t.Fatalf("tron display_name = %q, want TRON", got)
}
if got, _ := tron["status"].(string); got != "warning" {
t.Fatalf("tron status = %q, want warning", got)
}
if solana == nil {
t.Fatalf("missing solana row in %#v", rows)
}
if got, _ := solana["success_count"].(float64); got != 0 {
t.Fatalf("solana success_count = %v, want 0", got)
}
if got, _ := solana["failure_count"].(float64); got != 0 {
t.Fatalf("solana failure_count = %v, want 0", got)
}
if got, _ := solana["success_rate"].(float64); got != 0 {
t.Fatalf("solana success_rate = %v, want 0", got)
}
if got, _ := solana["last_sync_at"].(string); got != "" {
t.Fatalf("solana last_sync_at = %q, want empty", got)
}
if got, _ := solana["latest_block_height"].(float64); got != 0 {
t.Fatalf("solana latest_block_height = %v, want 0", got)
}
if got, _ := solana["status"].(string); got != "unknown" {
t.Fatalf("solana status = %q, want unknown", got)
}
}
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
e, token := setupAdminTestEnv(t)
yesterday := time.Now().AddDate(0, 0, -1)
createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local)
updatedAt := createdAt.Add(5 * time.Minute)
order := &mdb.Orders{
TradeId: "trade-dashboard-yesterday",
OrderId: "order-dashboard-yesterday",
Amount: 100,
Currency: "CNY",
ActualAmount: 12.5,
ReceiveAddress: "TTestDashboardAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusPaySuccess,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create historical order: %v", err)
}
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
"created_at": createdAt,
"updated_at": updatedAt,
}).Error; err != nil {
t.Fatalf("age historical order: %v", err)
}
todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token))
todayData, _ := todayResp["data"].(map[string]interface{})
if got, _ := todayData["order_count"].(float64); got != 0 {
t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got)
}
statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token))
statsData, _ := statsResp["data"].(map[string]interface{})
if got, _ := statsData["order_count"].(float64); got != 1 {
t.Fatalf("7d order-stats order_count = %v, want 1", got)
}
if got, _ := statsData["success_count"].(float64); got != 1 {
t.Fatalf("7d order-stats success_count = %v, want 1", got)
}
overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token))
overviewData, _ := overviewResp["data"].(map[string]interface{})
if got, _ := overviewData["order_count"].(float64); got != 1 {
t.Fatalf("7d overview order_count = %v, want 1", got)
}
if got, _ := overviewData["volume"].(float64); got != 12.5 {
t.Fatalf("7d overview volume = %v, want 12.5", got)
}
for _, path := range []string{
"/admin/api/v1/dashboard/asset-trend?range=7d",
"/admin/api/v1/dashboard/revenue-trend?range=7d",
"/admin/api/v1/dashboard/asset-trend?range=30d",
"/admin/api/v1/dashboard/revenue-trend?range=30d",
} {
resp := assertOK(t, doGetAdmin(e, path, token))
rows, _ := resp["data"].([]interface{})
day := createdAt.Format("2006-01-02")
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["day"] != day {
continue
}
found = true
if got, _ := item["order_count"].(float64); got != 1 {
t.Fatalf("%s order_count for %s = %v, want 1", path, day, got)
}
if got, _ := item["success_count"].(float64); got != 1 {
t.Fatalf("%s success_count for %s = %v, want 1", path, day, got)
}
if got, _ := item["actual_amount"].(float64); got != 12.5 {
t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got)
}
}
if !found {
t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows)
}
}
addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token))
addressRows, _ := addressResp["data"].([]interface{})
day := createdAt.Format("2006-01-02")
foundAddress := false
for _, row := range addressRows {
item, _ := row.(map[string]interface{})
if item["day"] != day || item["address"] != order.ReceiveAddress {
continue
}
foundAddress = true
if got, _ := item["actual_amount"].(float64); got != 12.5 {
t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got)
}
}
if !foundAddress {
t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows)
}
}
func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) {
e, token := setupAdminTestEnv(t)
now := time.Now()
createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local)
updatedAt := createdAt.Add(2 * time.Minute)
order := &mdb.Orders{
TradeId: "trade-dashboard-today",
OrderId: "order-dashboard-today",
Amount: 80,
Currency: "CNY",
ActualAmount: 9.75,
ReceiveAddress: "TTestDashboardAddressHourly",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusPaySuccess,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create today order: %v", err)
}
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
"created_at": createdAt,
"updated_at": updatedAt,
}).Error; err != nil {
t.Fatalf("set today order time: %v", err)
}
for _, path := range []string{
"/admin/api/v1/dashboard/asset-trend?range=today",
"/admin/api/v1/dashboard/revenue-trend?range=today",
} {
resp := assertOK(t, doGetAdmin(e, path, token))
rows, _ := resp["data"].([]interface{})
hour := createdAt.Format("2006-01-02 15:00")
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["day"] != hour {
continue
}
found = true
if got, _ := item["order_count"].(float64); got != 1 {
t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got)
}
if got, _ := item["success_count"].(float64); got != 1 {
t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got)
}
if got, _ := item["actual_amount"].(float64); got != 9.75 {
t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got)
}
}
if !found {
t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows)
}
}
}
// ─── Settings ──────────────────────────────────────────────────────────────── // ─── Settings ────────────────────────────────────────────────────────────────
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings. // TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
@@ -1024,6 +1459,90 @@ func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
} }
} }
func TestAdminSettings_LogLevelValidationRuntimeUpdateAndReset(t *testing.T) {
e, token := setupAdminTestEnv(t)
oldLevel := appLog.CurrentLevel()
t.Cleanup(func() {
_ = appLog.SetLevel(oldLevel)
})
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
t.Fatalf("reset log level: %v", err)
}
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": " DEBUG ", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != true {
t.Fatalf("system.log_level upsert result = %v", result)
}
if got := appLog.CurrentLevel(); got != "debug" {
t.Fatalf("runtime log level = %q, want debug", got)
}
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, ""); got != "debug" {
t.Fatalf("stored system.log_level = %q, want debug", got)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "fatal", "type": "string"},
{"group": "rate", "key": mdb.SettingKeySystemLogLevel, "value": "info", "type": "string"},
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "", "type": "string"},
},
}, token)
resp = assertOK(t, rec)
results, ok = resp["data"].([]interface{})
if !ok || len(results) != 3 {
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
}
for _, item := range results {
result, _ := item.(map[string]interface{})
if result["ok"] != false {
t.Fatalf("invalid system.log_level result = %v, want ok=false", result)
}
}
if got := appLog.CurrentLevel(); got != "debug" {
t.Fatalf("invalid upsert changed runtime log level to %q", got)
}
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeySystemLogLevel, token)
assertOK(t, rec)
if got := appLog.CurrentLevel(); got != mdb.SettingDefaultSystemLogLevel {
t.Fatalf("runtime log level after delete = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
}
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel); got != mdb.SettingDefaultSystemLogLevel {
t.Fatalf("deleted system.log_level fallback = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "warn", "type": "string"},
},
}, token)
assertOK(t, rec)
if got := appLog.CurrentLevel(); got != "warn" {
t.Fatalf("runtime log level after restore = %q, want warn", got)
}
var restored mdb.Setting
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&restored).Error; err != nil {
t.Fatalf("load restored system.log_level unscoped: %v", err)
}
if restored.DeletedAt.Valid {
t.Fatalf("restored system.log_level still has deleted_at=%v", restored.DeletedAt)
}
if restored.Value != "warn" {
t.Fatalf("restored system.log_level value = %q, want warn", restored.Value)
}
}
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting. // TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
func TestAdminSettings_DeleteNonExistent(t *testing.T) { func TestAdminSettings_DeleteNonExistent(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -1035,12 +1554,56 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
} }
} }
func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "http://127.0.0.1:8080/", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != false {
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
}
if got := int(result["error_code"].(float64)); got != 10043 {
t.Fatalf("private rate.api_url error_code = %d, want 10043; result=%v", got, result)
}
if got, _ := result["error"].(string); got != constant.Errno[10043] {
t.Fatalf("private rate.api_url error = %q, want %q", got, constant.Errno[10043])
}
}
func TestAdminSettings_AllowsPublicRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != true {
t.Fatalf("public rate.api_url result = %v, want ok=true", result)
}
}
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) { func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.old.example", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-old", "type": "string"},
}, },
}, token) }, token)
assertOK(t, rec) assertOK(t, rec)
@@ -1053,7 +1616,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.new.example", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-new", "type": "string"},
}, },
}, token) }, token)
assertOK(t, rec) assertOK(t, rec)
@@ -1069,7 +1632,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
item, _ := row.(map[string]interface{}) item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyRateApiUrl { if item["key"] == mdb.SettingKeyRateApiUrl {
found = true found = true
if item["value"] != "https://rate.new.example" { if item["value"] != "https://93.184.216.34/rate-new" {
t.Fatalf("rate.api_url value = %v, want new value", item["value"]) t.Fatalf("rate.api_url value = %v, want new value", item["value"])
} }
} }
@@ -1350,7 +1913,7 @@ func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
"currency": "CNY", "currency": "CNY",
"token": "USDT", "token": "USDT",
"network": "tron", "network": "tron",
"notify_url": "https://merchant.example/callback", "notify_url": "https://93.184.216.34/callback",
"redirect_url": "https://merchant.example/redirect", "redirect_url": "https://merchant.example/redirect",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
+73 -8
View File
@@ -8,18 +8,50 @@ 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"
"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"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
) )
// resolveEPayTypeSelector only claims the type as a selector when it maps to a
// currently supported payment asset. Non-selector values are handled by the
// caller, which now only accepts empty type or alipay before falling back to
// request token/network/default resolution.
func resolveEPayTypeSelector(rawType string) (string, string, bool, error) {
rawType = strings.TrimSpace(rawType)
if rawType == "" || strings.Count(rawType, ".") != 1 {
return "", "", false, nil
}
parts := strings.SplitN(rawType, ".", 2)
token := strings.TrimSpace(parts[0])
network := strings.ToLower(strings.TrimSpace(parts[1]))
if token == "" || network == "" {
return "", "", false, nil
}
if !data.IsChainEnabled(network) {
return "", "", false, nil
}
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
if err != nil {
return "", "", false, err
}
if tokenRow == nil || tokenRow.ID == 0 || !service.ChainTokenReadyForPayment(*tokenRow) {
return "", "", false, nil
}
return token, network, true, nil
}
// RegisterRoute 路由注册 // RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) { func RegisterRoute(e *echo.Echo) {
e.POST("/", func(c echo.Context) error { e.POST("/", func(c echo.Context) error {
@@ -36,13 +68,17 @@ 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("/return/:trade_id", comm.Ctrl.ReturnToMerchant)
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 // gmpay v1 routes. CreateTransaction accepts token+network together
// 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)
@@ -74,7 +110,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, fmt.Errorf("invalid epay form params: %w", err)) return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err == nil { if err == nil {
copyParams(formParams) copyParams(formParams)
@@ -125,19 +161,47 @@ func RegisterRoute(e *echo.Echo) {
money := getString(params, "money") money := getString(params, "money")
name := getString(params, "name") name := getString(params, "name")
epayType := strings.TrimSpace(getString(params, "type"))
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")
selectorToken, selectorNetwork, selectorMatched, err := resolveEPayTypeSelector(epayType)
if err != nil {
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
}
if epayType != "" && !selectorMatched && !strings.EqualFold(epayType, "alipay") {
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
}
token := strings.TrimSpace(getString(params, "token"))
network := strings.TrimSpace(getString(params, "network"))
if selectorMatched {
token = selectorToken
network = selectorNetwork
} else {
if token == "" {
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
}
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, fmt.Errorf("invalid money value: %s", money)) return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
} }
body := map[string]interface{}{ body := map[string]interface{}{
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"), "token": token,
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"), "currency": currency,
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"), "network": network,
"amount": amountFloat, "amount": amountFloat,
"notify_url": notifyURL, "notify_url": notifyURL,
"order_id": outTradeNo, "order_id": outTradeNo,
@@ -148,12 +212,13 @@ func RegisterRoute(e *echo.Echo) {
} }
ctx.Set("request_body", body) ctx.Set("request_body", body)
ctx.Set(comm.EPayTypeContextKey, epayType)
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID) ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow) ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
jsonBytes, err := json.Marshal(body) jsonBytes, err := json.Marshal(body)
if err != nil { if err != nil {
return comm.Ctrl.FailJson(ctx, err) return comm.Ctrl.FailJson(ctx, constant.SystemErr)
} }
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
@@ -198,7 +263,6 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT) // Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login) adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash) adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated // Authenticated
@@ -268,6 +332,7 @@ 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
+1539 -12
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,8 @@ func Start() {
go StartPolygonWebSocketListener() go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener() go StartPlasmaWebSocketListener()
go StartTronBlockScannerListener() go StartTronBlockScannerListener()
go StartTonBlockScannerListener()
go StartAptosLedgerScannerListener()
c := cron.New() c := cron.New()
+255
View File
@@ -0,0 +1,255 @@
package task
import (
"context"
"fmt"
"sync"
"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"
)
const (
aptosLedgerChunkSize int64 = 100
aptosLedgerWorkerCount = 3
aptosLedgerQueueLimit = 100
)
type aptosRuntimeCursor struct {
initialized bool
lastSeenVersion int64
}
type aptosLedgerRange struct {
start int64
limit int64
state moveWatchState
}
type aptosChainProvider interface {
LatestLedgerVersion() (int64, error)
Transactions(start int64, limit int64) ([]byte, error)
}
type serviceAptosProvider struct{}
func (serviceAptosProvider) LatestLedgerVersion() (int64, error) {
return service.AptosGetLedgerVersion()
}
func (serviceAptosProvider) Transactions(start int64, limit int64) ([]byte, error) {
return service.AptosGetTransactions(start, limit)
}
var (
parseAptosTransfersForWallets = service.ParseAptosTransfersForWallets
processAptosObservedTransferFunc = service.ProcessMoveObservedTransferResult
)
func StartAptosLedgerScannerListener() {
provider := serviceAptosProvider{}
cursor := &aptosRuntimeCursor{}
log.Sugar.Infof("[APTOS] ledger scanner starting mode=full_ledger chunk_size=%d workers=%d queue_limit=%d",
aptosLedgerChunkSize,
aptosLedgerWorkerCount,
aptosLedgerQueueLimit,
)
for {
if err := runAptosLedgerScanner(context.Background(), provider, cursor); err != nil {
log.Sugar.Warnf("[APTOS] scanner stopped: %v", err)
sleepOrDone(context.Background(), moveScannerRetryDelay)
continue
}
}
}
func runAptosLedgerScanner(ctx context.Context, provider aptosChainProvider, cursor *aptosRuntimeCursor) error {
if cursor == nil {
cursor = &aptosRuntimeCursor{}
}
watchSignature := ""
for {
chain, interval := moveChainConfig(mdb.NetworkAptos)
if chain == nil || !chain.Enabled {
log.Sugar.Debug("[APTOS] chain disabled or not configured, idling")
if !sleepOrDone(ctx, interval) {
return nil
}
continue
}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
return err
}
if len(state.wallets) == 0 || len(state.tokens) == 0 {
log.Sugar.Debug("[APTOS] no enabled wallets or tokens, idling")
if !sleepOrDone(ctx, interval) {
return nil
}
continue
}
if sig := moveWatchStateSignature(state); sig != watchSignature {
watchSignature = sig
log.Sugar.Infof("[APTOS] ledger scanner watching wallets=%d tokens=%d", len(state.wallets), len(state.tokens))
}
latest, err := provider.LatestLedgerVersion()
if err != nil {
return fmt.Errorf("get latest ledger version: %w", err)
}
data.RecordRpcBlockHeight(mdb.NetworkAptos, latest)
confirmed := moveConfirmedCursor(latest, chain.MinConfirmations)
if !cursor.initialized {
cursor.initialized = true
cursor.lastSeenVersion = confirmed - aptosLedgerChunkSize
if cursor.lastSeenVersion < -1 {
cursor.lastSeenVersion = -1
}
log.Sugar.Infof(
"[APTOS] initialized ledger cursor at version=%d confirmed_head=%d chunk_size=%d mode=full_ledger_watch",
cursor.lastSeenVersion,
confirmed,
aptosLedgerChunkSize,
)
}
catchup, err := processAptosLedgerRound(ctx, provider, state, cursor, confirmed)
if err != nil {
return err
}
if !sleepOrDone(ctx, chooseAptosIdleDelay(catchup, interval)) {
return nil
}
}
}
func processAptosLedgerRound(ctx context.Context, provider aptosChainProvider, state moveWatchState, cursor *aptosRuntimeCursor, confirmedVersion int64) (bool, error) {
ranges := buildAptosLedgerRanges(cursor.lastSeenVersion, confirmedVersion, state)
if len(ranges) == 0 {
return false, nil
}
lastRange := ranges[len(ranges)-1]
log.Sugar.Infof(
"[APTOS] queued ledger ranges count=%d from=%d to=%d cursor=%d confirmed_head=%d workers=%d",
len(ranges),
ranges[0].start,
lastRange.start+lastRange.limit-1,
cursor.lastSeenVersion,
confirmedVersion,
aptosLedgerWorkerCount,
)
type rangeResult struct {
item aptosLedgerRange
err error
}
jobs := make(chan aptosLedgerRange, len(ranges))
results := make(chan rangeResult, len(ranges))
workers := aptosLedgerWorkerCount
if len(ranges) < workers {
workers = len(ranges)
}
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
results <- rangeResult{item: item, err: processAptosLedgerRange(ctx, provider, item, confirmedVersion)}
}
}()
}
for _, item := range ranges {
jobs <- item
}
close(jobs)
wg.Wait()
close(results)
errByStart := make(map[int64]error, len(ranges))
for result := range results {
if result.err != nil {
errByStart[result.item.start] = result.err
}
}
for _, item := range ranges {
if err := errByStart[item.start]; err != nil {
return cursor.lastSeenVersion < confirmedVersion, err
}
cursor.lastSeenVersion = item.start + item.limit - 1
}
return cursor.lastSeenVersion < confirmedVersion, nil
}
func buildAptosLedgerRanges(lastSeenVersion int64, confirmedVersion int64, state moveWatchState) []aptosLedgerRange {
if confirmedVersion <= lastSeenVersion {
return nil
}
ranges := make([]aptosLedgerRange, 0, aptosLedgerQueueLimit)
next := lastSeenVersion + 1
if next < 0 {
next = 0
}
for next <= confirmedVersion && len(ranges) < aptosLedgerQueueLimit {
limit := confirmedVersion - next + 1
if limit > aptosLedgerChunkSize {
limit = aptosLedgerChunkSize
}
ranges = append(ranges, aptosLedgerRange{start: next, limit: limit, state: state})
next += limit
}
return ranges
}
func processAptosLedgerRange(ctx context.Context, provider aptosChainProvider, item aptosLedgerRange, confirmedVersion int64) error {
if err := ctx.Err(); err != nil {
return err
}
body, err := provider.Transactions(item.start, item.limit)
if err != nil {
return fmt.Errorf("get transactions start=%d limit=%d: %w", item.start, item.limit, err)
}
transfers, err := parseAptosTransfersForWallets(body, item.state.wallets, item.state.tokens)
if err != nil {
return fmt.Errorf("parse transactions start=%d limit=%d: %w", item.start, item.limit, err)
}
for _, transfer := range transfers {
log.Sugar.Infof(
"[APTOS] observed transfer version=%d tx=%s token=%s amount=%.8f raw=%s decimals=%d receive=%s key=%s",
transfer.Version,
transfer.TxID,
transfer.Token,
transfer.Amount,
transfer.RawAmount.String(),
transfer.Decimals,
transfer.ReceiveAddress,
transfer.TransferKey,
)
if err = processAptosObservedTransferFunc(transfer); err != nil {
return err
}
}
if len(transfers) > 0 {
log.Sugar.Infof(
"[APTOS] ledger range processed start=%d limit=%d to=%d transfers=%d confirmed_head=%d",
item.start,
item.limit,
item.start+item.limit-1,
len(transfers),
confirmedVersion,
)
}
return nil
}
func chooseAptosIdleDelay(catchup bool, interval time.Duration) time.Duration {
if catchup {
return moveCatchupYield
}
return interval
}
+440
View File
@@ -0,0 +1,440 @@
package task
import (
"context"
"errors"
"fmt"
"sort"
"sync"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
addressutil "github.com/GMWalletApp/epusdt/util/address"
)
type fakeAptosProvider struct {
mu sync.Mutex
latest int64
bodiesByStart map[int64][]byte
errByStart map[int64]error
calls []aptosRangeCall
}
type aptosRangeCall struct {
start int64
limit int64
}
func (f *fakeAptosProvider) LatestLedgerVersion() (int64, error) {
return f.latest, nil
}
func (f *fakeAptosProvider) Transactions(start int64, limit int64) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, aptosRangeCall{start: start, limit: limit})
if err := f.errByStart[start]; err != nil {
return nil, err
}
if body := f.bodiesByStart[start]; len(body) > 0 {
return append([]byte(nil), body...), nil
}
return []byte("[]"), nil
}
func (f *fakeAptosProvider) rangeCalls() []aptosRangeCall {
f.mu.Lock()
defer f.mu.Unlock()
calls := append([]aptosRangeCall(nil), f.calls...)
sort.Slice(calls, func(i, j int) bool {
if calls[i].start == calls[j].start {
return calls[i].limit < calls[j].limit
}
return calls[i].start < calls[j].start
})
return calls
}
func resetAptosScannerHooks(t *testing.T) {
t.Helper()
oldParse := parseAptosTransfersForWallets
oldProcess := processAptosObservedTransferFunc
t.Cleanup(func() {
parseAptosTransfersForWallets = oldParse
processAptosObservedTransferFunc = oldProcess
})
}
func seedAptosScannerChain(t *testing.T, wallets ...string) []string {
t.Helper()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1, ScanIntervalSec: 1}).Error; err != nil {
t.Fatalf("seed Aptos chain: %v", err)
}
if err := dao.Mdb.Create(&[]mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true},
}).Error; err != nil {
t.Fatalf("seed Aptos tokens: %v", err)
}
if len(wallets) == 0 {
wallets = []string{"0xa"}
}
out := make([]string, 0, len(wallets))
for _, wallet := range wallets {
receive, err := addressutil.NormalizeMoveAddress(wallet)
if err != nil {
t.Fatalf("normalize Aptos address %q: %v", wallet, err)
}
if _, err = data.AddWalletAddressWithNetwork(mdb.NetworkAptos, receive); err != nil {
t.Fatalf("seed Aptos wallet %s: %v", receive, err)
}
out = append(out, receive)
}
return out
}
func TestProcessAptosLedgerRangePassesConfiguredTokens(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
var gotSymbols []string
parseAptosTransfersForWallets = func(_ []byte, _ map[string]struct{}, tokens []mdb.ChainToken) ([]service.MoveObservedTransfer, error) {
for _, token := range tokens {
gotSymbols = append(gotSymbols, token.Symbol)
}
sort.Strings(gotSymbols)
return nil, nil
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}}
err = processAptosLedgerRange(context.Background(), provider, aptosLedgerRange{start: 100, limit: 1, state: state}, 100)
if err != nil {
t.Fatalf("processAptosLedgerRange(): %v", err)
}
want := []string{"MOVEUSD", "USDC", "USDT"}
if len(gotSymbols) != len(want) {
t.Fatalf("tokens = %#v, want %#v", gotSymbols, want)
}
for i := range want {
if gotSymbols[i] != want[i] {
t.Fatalf("tokens = %#v, want %#v", gotSymbols, want)
}
}
}
func TestRunAptosLedgerScannerIdlesWhenChainDisabled(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: false, MinConfirmations: 1, ScanIntervalSec: 1}).Error; err != nil {
t.Fatalf("seed disabled Aptos chain: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
provider := &fakeAptosProvider{latest: 999}
cursor := &aptosRuntimeCursor{}
if err := runAptosLedgerScanner(ctx, provider, cursor); err != nil {
t.Fatalf("runAptosLedgerScanner(): %v", err)
}
if cursor.initialized {
t.Fatalf("cursor initialized while chain disabled: %#v", cursor)
}
if calls := provider.rangeCalls(); len(calls) != 0 {
t.Fatalf("range calls = %#v, want none", calls)
}
}
func TestRunAptosLedgerScannerInitializesAtTrailingConfirmedChunk(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
provider := &fakeAptosProvider{latest: 999}
cursor := &aptosRuntimeCursor{}
if err := runAptosLedgerScanner(ctx, provider, cursor); err != nil {
t.Fatalf("runAptosLedgerScanner(): %v", err)
}
if !cursor.initialized || cursor.lastSeenVersion != 999 {
t.Fatalf("cursor = %#v, want initialized and advanced to confirmed head 999", cursor)
}
calls := provider.rangeCalls()
if len(calls) != 1 || calls[0].start != 900 || calls[0].limit != 100 {
t.Fatalf("range calls = %#v, want start=900 limit=100", calls)
}
}
func TestProcessAptosLedgerRoundDoesNotAdvanceUnconfirmedVersion(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
provider := &fakeAptosProvider{latest: 100}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 99)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 99 {
t.Fatalf("lastSeenVersion = %d, want 99", cursor.lastSeenVersion)
}
if calls := provider.rangeCalls(); len(calls) != 0 {
t.Fatalf("range calls = %#v, want none", calls)
}
}
func TestProcessAptosLedgerRoundAdvancesAfterSuccessfulRange(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 250)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 250 {
t.Fatalf("lastSeenVersion = %d, want 250", cursor.lastSeenVersion)
}
calls := provider.rangeCalls()
if len(calls) != 2 || calls[0].start != 100 || calls[0].limit != 100 || calls[1].start != 200 || calls[1].limit != 51 {
t.Fatalf("range calls = %#v, want [100/100 200/51]", calls)
}
}
func TestProcessAptosLedgerRoundDoesNotAdvanceOnRangeFailure(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{errByStart: map[int64]error{100: errors.New("temporary rpc failure")}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 150); err == nil {
t.Fatal("processAptosLedgerRound() error = nil, want error")
}
if cursor.lastSeenVersion != 99 {
t.Fatalf("lastSeenVersion = %d, want 99", cursor.lastSeenVersion)
}
}
func TestProcessAptosLedgerRoundProcessesMultipleChunks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{
bodiesByStart: map[int64][]byte{
100: []byte("[]"),
200: []byte("[]"),
},
}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 250)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 250 {
t.Fatalf("lastSeenVersion = %d, want 250", cursor.lastSeenVersion)
}
calls := provider.rangeCalls()
if len(calls) != 2 || calls[0].start != 100 || calls[0].limit != 100 || calls[1].start != 200 || calls[1].limit != 51 {
t.Fatalf("range calls = %#v, want [100/100 200/51]", calls)
}
}
func TestBuildAptosLedgerRangesStopsAtQueueLimit(t *testing.T) {
state := moveWatchState{}
ranges := buildAptosLedgerRanges(0, aptosLedgerChunkSize*aptosLedgerQueueLimit+500, state)
if len(ranges) != aptosLedgerQueueLimit {
t.Fatalf("ranges len = %d, want %d", len(ranges), aptosLedgerQueueLimit)
}
first := ranges[0]
last := ranges[len(ranges)-1]
if first.start != 1 || first.limit != aptosLedgerChunkSize {
t.Fatalf("first range = %#v, want start=1 limit=%d", first, aptosLedgerChunkSize)
}
if last.start != 1+aptosLedgerChunkSize*int64(aptosLedgerQueueLimit-1) || last.limit != aptosLedgerChunkSize {
t.Fatalf("last range = %#v", last)
}
}
func TestProcessAptosLedgerRoundMarksMatchingUSDTOrderPaid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
wallets := seedAptosScannerChain(t, "0xa")
receive := wallets[0]
amount := 3.1
tradeID := "aptos_trade_1"
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
body := aptosFungibleTransferBody(t, "0xabc", 101, receive, usdt, "3100000")
order := &mdb.Orders{
TradeId: tradeID,
OrderId: "aptos_order_1",
Amount: amount,
ActualAmount: amount,
ReceiveAddress: receive,
Token: "USDT",
Network: mdb.NetworkAptos,
Status: mdb.StatusWaitPay,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkAptos, receive, "USDT", tradeID, amount, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{101: body}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 101); err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
paid, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if paid.Status != mdb.StatusPaySuccess || paid.CallBackConfirm != mdb.CallBackConfirmNo || paid.BlockTransactionId != "0xabc" {
t.Fatalf("paid order = %#v", paid)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, receive, "USDT", amount)
if err != nil {
t.Fatalf("lookup lock: %v", err)
}
if lockTradeID != "" {
t.Fatalf("transaction lock still exists: %s", lockTradeID)
}
}
func TestProcessAptosLedgerRoundDoesNotReprocessDuplicateTransferKey(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
wallets := seedAptosScannerChain(t, "0xa")
receive := wallets[0]
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{
bodiesByStart: map[int64][]byte{
101: aptosFungibleTransferBody(t, "0xabc", 101, receive, usdc, "1200000"),
102: aptosFungibleTransferBody(t, "0xabc", 101, receive, usdc, "1200000"),
},
}
var processed int
processAptosObservedTransferFunc = func(transfer service.MoveObservedTransfer) error {
processed++
return service.ProcessMoveObservedTransferResult(transfer)
}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 101); err != nil {
t.Fatalf("first processAptosLedgerRound(): %v", err)
}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 102); err != nil {
t.Fatalf("second processAptosLedgerRound(): %v", err)
}
if processed != 2 {
t.Fatalf("scanner processed callback count = %d, want 2", processed)
}
if cursor.lastSeenVersion != 102 {
t.Fatalf("lastSeenVersion = %d, want 102", cursor.lastSeenVersion)
}
}
func aptosFungibleTransferBody(t *testing.T, hash string, version int64, receive string, metadata string, rawAmount string) []byte {
t.Helper()
store, err := addressutil.NormalizeMoveAddress(fmt.Sprintf("0x%x", version+1000))
if err != nil {
t.Fatalf("normalize store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress(fmt.Sprintf("0x%x", version+2000))
if err != nil {
t.Fatalf("normalize sender store: %v", err)
}
return []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"` + hash + `",
"version":"` + fmt.Sprintf("%d", version) + `",
"timestamp":"` + fmt.Sprintf("%d", time.Now().UnixMicro()) + `",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"` + rawAmount + `","store":"` + senderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"` + rawAmount + `","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + metadata + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + metadata + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
}
+5 -3
View File
@@ -66,18 +66,18 @@ func runBscListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkBsc, "[BSC-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[BSC-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkBsc, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -98,9 +98,11 @@ 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
} }
+13 -4
View File
@@ -93,19 +93,28 @@ 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, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs) node, ok := resolveChainWsNode(network, logPrefix)
if !ok {
return "", false
}
return strings.TrimSpace(node.Url), true
}
func resolveChainWsNode(network, logPrefix string, excludeIDs ...uint64) (mdb.RpcNode, bool) {
node, err := data.SelectGeneralRpcNode(network, mdb.RpcNodeTypeWs, excludeIDs...)
if err == nil && node != nil && node.ID > 0 { if err == nil && node != nil && node.ID > 0 {
rpcURL := strings.TrimSpace(node.Url) rpcURL := strings.TrimSpace(node.Url)
if rpcURL != "" { if rpcURL != "" {
return rpcURL, true node.Url = rpcURL
return *node, true
} }
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID) log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
return "", false return mdb.RpcNode{}, 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 "", false return mdb.RpcNode{}, false
} }
+147
View File
@@ -1,13 +1,23 @@
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()
@@ -42,6 +52,143 @@ 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()
+5 -3
View File
@@ -71,18 +71,18 @@ func runEthereumListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkEthereum, "[ETH-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[ETH-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkEthereum, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -100,9 +100,11 @@ 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
} }
+134 -14
View File
@@ -2,8 +2,11 @@ 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"
@@ -11,18 +14,26 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs, const (
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. The ctx lets the caller trigger a clean // with exponential backoff until the node reaches the failure threshold.
// exit — e.g. when admin disables the chain, the caller cancels the // The ctx lets the caller trigger a clean exit — e.g. when admin disables
// context and the function returns instead of reconnecting forever. // the chain, the caller cancels the context and the function returns.
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) { func runEvmWsLogListener(ctx context.Context, network string, logPrefix string, node mdb.RpcNode, 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 {
@@ -31,7 +42,13 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
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
} }
@@ -43,7 +60,13 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
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
} }
@@ -51,22 +74,49 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
continue continue
} }
failWait = minBackoff failWait = minBackoff
data.RecordRpcSuccess(network)
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix) log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog) connectedAt := time.Now()
recvErr := recvLoop(ctx, client, sub, logsCh, 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, logPrefix string, handleLog func(*ethclient.Client, types.Log)) { 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 {
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()
}() }()
@@ -75,24 +125,94 @@ 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 return nil
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 return fmt.Errorf("log channel closed")
}
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).
+77
View File
@@ -0,0 +1,77 @@
package task
import (
"fmt"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/log"
)
const (
moveScannerRetryDelay = 3 * time.Second
moveCatchupYield = 250 * time.Millisecond
)
type moveWatchState struct {
wallets map[string]struct{}
tokens []mdb.ChainToken
}
func moveChainConfig(network string) (*mdb.Chain, time.Duration) {
row, err := data.GetChainByNetwork(network)
if err != nil || row == nil || row.ID == 0 {
if err != nil {
log.Sugar.Warnf("[%s] load chain config failed: %v", strings.ToUpper(network), err)
}
return nil, 5 * time.Second
}
interval := time.Duration(row.ScanIntervalSec) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
return row, interval
}
func loadMoveWatchState(network string) (moveWatchState, error) {
rows, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return moveWatchState{}, err
}
state := moveWatchState{wallets: make(map[string]struct{})}
for _, row := range rows {
address, err := addressutil.NormalizeMoveAddress(row.Address)
if err != nil {
log.Sugar.Warnf("[%s] skip invalid wallet address=%s err=%v", strings.ToUpper(network), row.Address, err)
continue
}
state.wallets[address] = struct{}{}
}
tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
return moveWatchState{}, err
}
state.tokens = tokens
return state, nil
}
func moveWatchStateSignature(state moveWatchState) string {
return fmt.Sprintf("wallets=%d/tokens=%d", len(state.wallets), len(state.tokens))
}
func moveConfirmedCursor(latest int64, minConfirmations int) int64 {
if latest <= 0 {
return 0
}
if minConfirmations <= 1 {
return latest
}
confirmed := latest - int64(minConfirmations) + 1
if confirmed < 0 {
return 0
}
return confirmed
}
+5 -3
View File
@@ -65,18 +65,18 @@ func runPlasmaListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkPlasma, "[PLASMA-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[PLASMA-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkPlasma, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -97,9 +97,11 @@ 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
} }
+5 -3
View File
@@ -65,18 +65,18 @@ func runPolygonListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkPolygon, "[POLYGON-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[POLYGON-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkPolygon, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -97,9 +97,11 @@ 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
} }
+52 -24
View File
@@ -12,31 +12,59 @@ 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() {
gListenSolJobLock.Lock() shouldRecordHeight := false
defer gListenSolJobLock.Unlock() func() {
log.Sugar.Debug("[ListenSolJob] Job triggered") gListenSolJobLock.Lock()
if !data.IsChainEnabled(mdb.NetworkSolana) { defer gListenSolJobLock.Unlock()
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
return 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
}
height, err := service.SolGetBlockHeight()
if err != nil {
log.Sugar.Warnf("[ListenSolJob] get Solana block height failed: %v", err)
} else {
data.RecordRpcBlockHeight(mdb.NetworkSolana, height)
} }
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")
} }
+124
View File
@@ -0,0 +1,124 @@
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])
}
}
+574
View File
@@ -0,0 +1,574 @@
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)
}
+717
View File
@@ -0,0 +1,717 @@
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)
}
}
+56 -7
View File
@@ -167,19 +167,32 @@ 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
return &block, json.Unmarshal(b, &block) if err := json.Unmarshal(b, &block); err != nil {
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
return &block, json.Unmarshal(b, &block) if err := json.Unmarshal(b, &block); err != nil {
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 {
@@ -337,6 +350,8 @@ 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
@@ -349,16 +364,16 @@ func NewScanner() *Scanner {
} }
func (s *Scanner) Init() error { func (s *Scanner) Init() error {
baseURL, apiKey, err := service.ResolveTronNode() node, err := service.ResolveTronRpcNode()
if err != nil { if err != nil {
return fmt.Errorf("resolve tron node: %w", err) return fmt.Errorf("resolve tron node: %w", err)
} }
s.baseURL = baseURL s.useRpcNode(node)
s.apiKey = apiKey
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL) log.Sugar.Infof("[TRON-BLOCK] using RPC node %s", s.nodeLabel)
block, err := GetNowBlock(s.baseURL, s.apiKey) 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
@@ -366,6 +381,13 @@ 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)
@@ -388,14 +410,17 @@ 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 {
@@ -404,7 +429,8 @@ 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)
continue hadBlockFetchError = true
break
} }
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
@@ -420,6 +446,29 @@ func (s *Scanner) poll() {
} }
} }
} }
if hadBlockFetchError {
s.recordRpcFailure("get historical block")
return
}
data.RecordRpcNodeSuccess(s.nodeID)
}
func (s *Scanner) recordRpcFailure(reason string) {
failures, cooling := data.RecordRpcNodeFailure(s.nodeID)
if !cooling {
log.Sugar.Warnf("[TRON-BLOCK] RPC node failed (%s), node=%s failures=%d/%d", reason, s.nodeLabel, failures, data.RpcFailoverThreshold)
return
}
log.Sugar.Warnf("[TRON-BLOCK] RPC node reached fail threshold (%s), node=%s, trying another node", reason, s.nodeLabel)
node, err := service.ResolveTronRpcNode(s.nodeID)
if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] no alternate RPC node available after failure, keep current node=%s: %v", s.nodeLabel, err)
return
}
oldLabel := s.nodeLabel
s.useRpcNode(node)
log.Sugar.Warnf("[TRON-BLOCK] switched RPC node from %s to %s", oldLabel, s.nodeLabel)
} }
func StartTronBlockScannerListener() { func StartTronBlockScannerListener() {

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