mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
16 Commits
v1.0.6
..
5e4dcfb539
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4dcfb539 | |||
| 1cdb1edd86 | |||
| 7d09adff20 | |||
| 15de0e6940 | |||
| 73aef80499 | |||
| 2dc538c0e1 | |||
| b4d1a4d8d4 | |||
| 8216c91739 | |||
| de96a5d3e7 | |||
| 79ca1730cd | |||
| ad7679b7fd | |||
| 0215b2787e | |||
| 75dfeced01 | |||
| bfdf453e3c | |||
| 55cc6a9c61 | |||
| 3ab3659fe4 |
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
| **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) 为准。
|
||||||
@@ -74,7 +75,7 @@ Epusdt 已完成第三方安全审计。
|
|||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络
|
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon、Aptos 等主流网络
|
||||||
- **私有化部署** — 资金完全自主掌控
|
- **私有化部署** — 资金完全自主掌控
|
||||||
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
|
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
|
||||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
|
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
|
||||||
@@ -93,17 +94,24 @@ Epusdt 已完成第三方安全审计。
|
|||||||
|
|
||||||
| 教程 | 说明 |
|
| 教程 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| [仓库内: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/ 文档与知识库
|
||||||
```
|
```
|
||||||
|
|||||||
Executable
+379
@@ -0,0 +1,379 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly IMAGE_TAG="epctl-ubuntu-systemd-test:$(date +%s)-$$"
|
||||||
|
readonly CONTAINER_NAME="epctl-systemd-test-$$"
|
||||||
|
readonly BASE_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:building_image) template='building the Ubuntu systemd test image' ;;
|
||||||
|
zh:building_image) template='正在构建 Ubuntu systemd 测试镜像' ;;
|
||||||
|
en:starting_container) template='starting the test container' ;;
|
||||||
|
zh:starting_container) template='正在启动测试容器' ;;
|
||||||
|
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: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 -lc "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local exit_code="$?"
|
||||||
|
if [[ "${exit_code}" -ne 0 ]]; then
|
||||||
|
log "$(trf test_failed_collecting)"
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'systemctl status epusdt --no-pager || true' || true
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'journalctl -u epusdt --no-pager -n 100 || true' || true
|
||||||
|
fi
|
||||||
|
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||||
|
docker rmi -f "${IMAGE_TAG}" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "${TMPDIR_PATH:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_systemd() {
|
||||||
|
local attempt
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if docker exec "${CONTAINER_NAME}" bash -lc 'systemctl show --property=Version --value >/dev/null 2>&1'; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
TMPDIR_PATH="$(mktemp -d)"
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cat > "${TMPDIR_PATH}/Dockerfile" <<EOF
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
ENV container=docker
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
jq \
|
||||||
|
sudo \
|
||||||
|
systemd \
|
||||||
|
systemd-sysv \
|
||||||
|
tar \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd -m -s /bin/bash tester \
|
||||||
|
&& echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/tester
|
||||||
|
|
||||||
|
STOPSIGNAL SIGRTMIN+3
|
||||||
|
|
||||||
|
CMD ["/sbin/init"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "$(trf base_image "${BASE_IMAGE}")"
|
||||||
|
log "$(trf building_image)"
|
||||||
|
docker build -t "${IMAGE_TAG}" "${TMPDIR_PATH}" >/dev/null
|
||||||
|
|
||||||
|
log "$(trf starting_container)"
|
||||||
|
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 \
|
||||||
|
"${IMAGE_TAG}" \
|
||||||
|
/sbin/init >/dev/null
|
||||||
|
|
||||||
|
wait_for_systemd || die "$(trf systemd_not_ready)"
|
||||||
|
|
||||||
|
log "$(trf self_installing)"
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc "su - tester -c 'cd /work && ./epctl self-install'"
|
||||||
|
docker_bash '[[ -x /usr/local/bin/epctl ]]'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc '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 -lc 'su - tester -c "epctl download --tag ${TAG}"'
|
||||||
|
|
||||||
|
log "$(trf installing_release "${INSTALL_TAG}")"
|
||||||
|
docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"'
|
||||||
|
|
||||||
|
log "$(trf waiting_assets)"
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc '
|
||||||
|
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
|
||||||
|
'
|
||||||
|
|
||||||
|
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 -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc '
|
||||||
|
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 -lc '
|
||||||
|
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}")"
|
||||||
|
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -lc 'su - tester -c "epctl upgrade --tag ${TAG}"'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc '
|
||||||
|
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
|
||||||
|
'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
|
||||||
|
docker exec "${CONTAINER_NAME}" bash -lc '
|
||||||
|
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 -lc '
|
||||||
|
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,3 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
|
bin/
|
||||||
@@ -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
|
||||||
@@ -55,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 {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
@@ -35,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
|
||||||
@@ -84,28 +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 {
|
|
||||||
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
|
||||||
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
|
||||||
}
|
|
||||||
return c.FailJson(ctx, err)
|
|
||||||
}
|
|
||||||
return c.SucJson(ctx, InitialPasswordResponse{
|
|
||||||
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
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
|||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)"
|
// @Param network path string true "Network name (e.g. 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
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
|
|
||||||
// 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"`
|
||||||
@@ -22,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"`
|
||||||
@@ -33,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
|
||||||
@@ -54,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
|
||||||
@@ -87,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)
|
||||||
}
|
}
|
||||||
@@ -95,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
|
||||||
@@ -115,18 +133,35 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
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)
|
||||||
}
|
}
|
||||||
@@ -154,6 +189,17 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
|||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
|
row, err := data.GetChainTokenByID(id)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.OrderNotExists)
|
return c.FailJson(ctx, constant.OrderNotExists)
|
||||||
}
|
}
|
||||||
if order.Status != mdb.StatusWaitPay {
|
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
|
||||||
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
||||||
}
|
}
|
||||||
ok, err := data.CloseOrderManually(tradeID)
|
ok, err := data.CloseOrderManually(tradeID)
|
||||||
@@ -122,8 +122,16 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, constant.OrderStatusConflict)
|
return c.FailJson(ctx, constant.OrderStatusConflict)
|
||||||
}
|
}
|
||||||
// Release the transaction lock so the amount slot becomes reusable.
|
// Release only concrete on-chain locks. Placeholder orders and provider-only
|
||||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
// 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)
|
||||||
|
}
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +141,8 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
// @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 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
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import (
|
|||||||
// 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" or "ton" (default "tron")
|
// 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
|
||||||
@@ -95,7 +95,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
// @Summary Upsert settings
|
// @Summary Upsert settings
|
||||||
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
// @Description Batch insert/update settings. Returns per-key status; 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" or "ton"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron" or "ton").
|
// @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,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
// @Description rate group keys: rate.forced_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.
|
||||||
|
|||||||
@@ -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,14 +37,14 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||||
// @Param order_id formData string false "Merchant order ID"
|
// @Param order_id formData string false "Merchant order ID"
|
||||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||||
// @Param token formData string false "Crypto token (e.g. TON, USDT)"
|
// @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. ton, 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 "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
// @Failure 400 {object} response.ApiResponse "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]
|
||||||
@@ -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 ton/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 "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
// @Failure 400 {object} response.ApiResponse "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)
|
||||||
@@ -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,7 +126,7 @@ 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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
@@ -12,7 +13,9 @@ import (
|
|||||||
|
|
||||||
// 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"
|
||||||
@@ -29,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"
|
||||||
@@ -55,6 +82,8 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
|||||||
// @Summary Submit transaction 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 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 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
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
-32
@@ -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 1–65535 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
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -65,6 +71,7 @@ func seedChains() {
|
|||||||
{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.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)
|
||||||
@@ -102,6 +109,8 @@ func seedChainTokens() {
|
|||||||
// TON
|
// TON
|
||||||
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||||
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, 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)
|
||||||
@@ -138,6 +147,7 @@ func defaultRpcNodes() []mdb.RpcNode {
|
|||||||
{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.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.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.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.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.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},
|
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
@@ -163,9 +173,9 @@ func seedDefaultSettings() {
|
|||||||
defaults := []mdb.Setting{
|
defaults := []mdb.Setting{
|
||||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "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},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dao
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
@@ -74,6 +75,80 @@ func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
||||||
db := setupSeedSettingsTestDB(t)
|
db := setupSeedSettingsTestDB(t)
|
||||||
Mdb = db
|
Mdb = db
|
||||||
@@ -95,6 +170,63 @@ func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
|
||||||
db := setupSeedSettingsTestDB(t)
|
db := setupSeedSettingsTestDB(t)
|
||||||
Mdb = db
|
Mdb = db
|
||||||
@@ -137,3 +269,19 @@ func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
|
|||||||
}
|
}
|
||||||
return db
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ func normalizeLockAddress(network, address string) string {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if network == mdb.NetworkAptos {
|
||||||
|
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,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
|
||||||
@@ -225,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)
|
||||||
@@ -288,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 {
|
||||||
|
|||||||
@@ -184,3 +184,19 @@ func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
|
|||||||
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{}{
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
|
|||||||
if network == mdb.NetworkTon {
|
if network == mdb.NetworkTon {
|
||||||
return addressutil.NormalizeTonAddress(address)
|
return addressutil.NormalizeTonAddress(address)
|
||||||
}
|
}
|
||||||
|
if network == mdb.NetworkAptos {
|
||||||
|
return addressutil.NormalizeMoveAddress(address)
|
||||||
|
}
|
||||||
return normalizeWalletAddressByNetwork(network, address), nil
|
return normalizeWalletAddressByNetwork(network, address), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,3 +103,20 @@ func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
|
|||||||
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
|
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,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"`
|
||||||
|
|||||||
+26
-13
@@ -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,9 +28,10 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
NetworkTon = "ton"
|
NetworkTon = "ton"
|
||||||
|
NetworkAptos = "aptos"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -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、ton
|
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
|
||||||
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:ton、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 {
|
||||||
@@ -54,7 +59,7 @@ func (r ManualPaymentRequest) Translates() map[string]string {
|
|||||||
type SwitchNetworkRequest struct {
|
type SwitchNetworkRequest struct {
|
||||||
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
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 {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type CreateTransactionResponse struct {
|
|||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
|
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type CheckoutCounterResponse struct {
|
type CheckoutCounterResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"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 ETH;status=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/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
|
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 EPay 时为商户原始回跳地址;EPay 时为内部中转地址 /pay/return/{trade_id}
|
||||||
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
|
||||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||||
IsSelected bool `json:"is_selected" example:"false"`
|
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 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 {
|
type ManualPaymentResponse struct {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
|
|||||||
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
||||||
case mdb.NetworkTon:
|
case mdb.NetworkTon:
|
||||||
canonicalTxID, err = validateManualTonPayment(order, txID)
|
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:
|
||||||
@@ -123,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
|
||||||
@@ -149,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)
|
||||||
|
|||||||
@@ -113,6 +113,66 @@ 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) {
|
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
||||||
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
||||||
hash := strings.Repeat("a", 64)
|
hash := strings.Repeat("a", 64)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
return address
|
return address
|
||||||
|
case mdb.NetworkAptos:
|
||||||
|
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return address
|
||||||
default:
|
default:
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
@@ -67,18 +72,54 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
|||||||
if tokenRow == nil || tokenRow.ID == 0 {
|
if tokenRow == nil || tokenRow.ID == 0 {
|
||||||
return nil, constant.SupportedAssetNotFound
|
return nil, constant.SupportedAssetNotFound
|
||||||
}
|
}
|
||||||
|
if !ChainTokenReadyForPayment(*tokenRow) {
|
||||||
|
return nil, constant.SupportedAssetNotFound
|
||||||
|
}
|
||||||
return tokenRow, nil
|
return tokenRow, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCreateTransactionTokenNetwork(token, network string) error {
|
||||||
|
if (token == "") != (network == "") {
|
||||||
|
return constant.ParamsMarshalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
|
||||||
|
return &response.CreateTransactionResponse{
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
OrderId: order.OrderId,
|
||||||
|
Amount: order.Amount,
|
||||||
|
Currency: order.Currency,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
Status: order.Status,
|
||||||
|
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
|
||||||
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
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)
|
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
||||||
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
||||||
return nil, constant.NotifyURLErr
|
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()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
@@ -93,6 +134,33 @@ 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
|
||||||
}
|
}
|
||||||
@@ -105,11 +173,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, constant.RateAmountErr
|
return nil, constant.RateAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
return nil, constant.PayAmountErr
|
return nil, constant.PayAmountErr
|
||||||
}
|
}
|
||||||
@@ -146,7 +210,8 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
NotifyUrl: 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),
|
||||||
}
|
}
|
||||||
@@ -161,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.
|
||||||
@@ -206,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 == "" {
|
||||||
@@ -232,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
|
||||||
@@ -284,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.
|
||||||
@@ -295,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,9 +513,20 @@ func isOnChainOrder(payProvider string) bool {
|
|||||||
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaxSubOrders = 2
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
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()
|
||||||
@@ -469,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,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)
|
||||||
@@ -497,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
|
||||||
}
|
}
|
||||||
@@ -562,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,
|
||||||
@@ -585,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,
|
||||||
@@ -594,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
|
||||||
@@ -613,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
|
||||||
@@ -625,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
|
||||||
}
|
}
|
||||||
@@ -678,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,
|
||||||
@@ -711,14 +957,33 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
return nil, constant.PaymentProviderCreateErr
|
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
|
||||||
|
|||||||
@@ -87,6 +87,85 @@ func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -390,6 +469,56 @@ func TestCreateTransactionRejectsDisabledTonTokenBeforeRate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -459,6 +588,146 @@ func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -1219,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",
|
||||||
@@ -1229,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.
|
||||||
@@ -1252,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.
|
||||||
@@ -1272,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-46
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -290,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"
|
||||||
|
|
||||||
@@ -320,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().
|
||||||
@@ -336,27 +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())
|
|
||||||
}
|
|
||||||
var respFetch2 map[string]interface{}
|
|
||||||
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
|
|
||||||
t.Fatalf("unmarshal second fetch response: %v", err)
|
|
||||||
}
|
|
||||||
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
|
|
||||||
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
|
|
||||||
}
|
|
||||||
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
|
|
||||||
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := adminLogin(t, e, testAdminUsername, initPassword)
|
|
||||||
|
|
||||||
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{})
|
||||||
@@ -376,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)
|
||||||
@@ -763,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)
|
||||||
|
|||||||
+68
-5
@@ -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,6 +68,7 @@ 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("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
|
||||||
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||||
@@ -43,7 +76,9 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
// 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)
|
||||||
@@ -126,9 +161,37 @@ 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 {
|
||||||
@@ -136,9 +199,9 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -149,6 +212,7 @@ 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)
|
||||||
|
|
||||||
@@ -199,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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ func Start() {
|
|||||||
go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
go StartTronBlockScannerListener()
|
go StartTronBlockScannerListener()
|
||||||
go StartTonBlockScannerListener()
|
go StartTonBlockScannerListener()
|
||||||
|
go StartAptosLedgerScannerListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package addressutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeMoveAddress returns the canonical 0x-prefixed, 64-hex-character
|
||||||
|
// account address used by Aptos.
|
||||||
|
func NormalizeMoveAddress(input string) (string, error) {
|
||||||
|
raw := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
raw = strings.TrimPrefix(raw, "0x")
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("move address is empty")
|
||||||
|
}
|
||||||
|
if len(raw) > 64 {
|
||||||
|
return "", fmt.Errorf("move address is too long")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(padEvenHex(raw)); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid move address")
|
||||||
|
}
|
||||||
|
return "0x" + strings.Repeat("0", 64-len(raw)) + raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func padEvenHex(raw string) string {
|
||||||
|
if len(raw)%2 == 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return "0" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMoveAssetID canonicalizes Move asset identifiers used in
|
||||||
|
// chain_tokens.contract_address for Aptos. A plain address is normalized to
|
||||||
|
// 64 hex chars; structured types keep module/name suffixes after normalizing
|
||||||
|
// the leading address.
|
||||||
|
func NormalizeMoveAssetID(input string) string {
|
||||||
|
raw := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, "::")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
if addr, err := NormalizeMoveAddress(raw); err == nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if addr, err := NormalizeMoveAddress(parts[0]); err == nil {
|
||||||
|
parts[0] = addr
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "::")
|
||||||
|
}
|
||||||
|
|
||||||
|
func MoveAssetMatches(observed, configured string) bool {
|
||||||
|
observed = NormalizeMoveAssetID(observed)
|
||||||
|
configured = NormalizeMoveAssetID(configured)
|
||||||
|
if configured == "" {
|
||||||
|
return observed == ""
|
||||||
|
}
|
||||||
|
if observed == configured {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Aptos FA event payloads may expose only the metadata address while other
|
||||||
|
// payloads expose a type string containing the same address.
|
||||||
|
return strings.Contains(observed, configured)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package addressutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeMoveAddress(t *testing.T) {
|
||||||
|
got, err := NormalizeMoveAddress(" A ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeMoveAddress: %v", err)
|
||||||
|
}
|
||||||
|
want := "0x000000000000000000000000000000000000000000000000000000000000000a"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("address = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = NormalizeMoveAddress("0X" + "ABCDEF")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeMoveAddress uppercase: %v", err)
|
||||||
|
}
|
||||||
|
want = "0x0000000000000000000000000000000000000000000000000000000000abcdef"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("uppercase address = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMoveAddressRejectsInvalid(t *testing.T) {
|
||||||
|
for _, input := range []string{"", "0xzz", "0x" + strings.Repeat("1", 65)} {
|
||||||
|
if _, err := NormalizeMoveAddress(input); err == nil {
|
||||||
|
t.Fatalf("NormalizeMoveAddress(%q) succeeded, want error", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMoveAssetIDNormalizesLeadingAddress(t *testing.T) {
|
||||||
|
got := NormalizeMoveAssetID("0X1::FUNGIBLE_ASSET::METADATA")
|
||||||
|
want := "0x0000000000000000000000000000000000000000000000000000000000000001::fungible_asset::metadata"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("asset id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ var Errno = map[int]string{
|
|||||||
10041: "invalid notify url",
|
10041: "invalid notify url",
|
||||||
10042: "payment provider order creation failed",
|
10042: "payment provider order creation failed",
|
||||||
10043: "invalid setting item",
|
10043: "invalid setting item",
|
||||||
|
10044: "invalid order redirect url",
|
||||||
|
10045: "order api key unavailable",
|
||||||
|
10046: "failed to build epay return signature",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -94,6 +97,9 @@ var (
|
|||||||
NotifyURLErr = Err(10041)
|
NotifyURLErr = Err(10041)
|
||||||
PaymentProviderCreateErr = Err(10042)
|
PaymentProviderCreateErr = Err(10042)
|
||||||
SettingItemErr = Err(10043)
|
SettingItemErr = Err(10043)
|
||||||
|
OrderRedirectURLErr = Err(10044)
|
||||||
|
OrderApiKeyUnavailableErr = Err(10045)
|
||||||
|
EPayReturnSignatureErr = Err(10046)
|
||||||
)
|
)
|
||||||
|
|
||||||
type RspError struct {
|
type RspError struct {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./unauthorized-error-Btb6_KCA.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./unauthorized-error-CtrGmVOn.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./forbidden-2YkGwREa.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./forbidden-CgQyYxDt.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./not-found-error-DAU8lBe-.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./not-found-error-BvJzTnw0.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./general-error-CJCCJkg5.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./general-error-BoAB2alm.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./maintenance-error-D_5WJHLy.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./maintenance-error-DeZhljU8.js";var t=e;export{t as component};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Kp as n}from"./messages-CokTNgkr.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-DpS_F-Xn.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-CjZe6DmC.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{tm as n}from"./messages-BOatyqUm.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-Bj5CESUC.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-DrG03Wse.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{h as e}from"./editor.api2-BpueNIaM.js";import"./toggleHighContrast-DRztGxhw.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
|
import{h as e}from"./editor.api2-BM_YT0wu.js";import"./toggleHighContrast-CYWWoXog.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{tm as e}from"./messages-BOatyqUm.js";import{m as t}from"./search-provider-C-_FQI9C.js";import{n,t as r}from"./theme-switch-CHLQml_8.js";import{t as i}from"./general-error-BoAB2alm.js";import{t as a}from"./not-found-error-BvJzTnw0.js";import{t as o}from"./_error-CtV7sHbI.js";import{t as s}from"./unauthorized-error-CtrGmVOn.js";import{t as c}from"./forbidden-CgQyYxDt.js";import{t as l}from"./maintenance-error-DeZhljU8.js";import{n as u,r as d,t as f}from"./header-DVAsq5d6.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-BWjxg4aC.js","assets/search-provider-C-_FQI9C.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-DPPquN_M.js","assets/messages-BOatyqUm.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-D1L-0EhT.js","assets/button-C_NDYaz8.js","assets/clsx-D9aGYY3V.js","assets/dist-iiotRAEO.js","assets/dist-rcWP-8Cu.js","assets/dist-D4X8zGEB.js","assets/dist-Cc8_LDAq.js","assets/es2015-DT8UeWzs.js","assets/dist-DvwKOQ8R.js","assets/dist-BNFQuJTu.js","assets/dist-C5heX0fx.js","assets/dist-D0DoWChj.js","assets/dist-CHFjpVqk.js","assets/dist-BixeH3nX.js","assets/dist-ZdRQQNeu.js","assets/separator-CsUemQNs.js","assets/tooltip-xZuTTfnF.js","assets/dist-CsIb2aLK.js","assets/dist-Dtn5c_cx.js","assets/auth-store-De4Y7PFV.js","assets/dist-JOUh6qvR.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-B_SlhXkX.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-CZ-2RPb-.js","assets/skeleton-CmDjDV1O.js","assets/wallet-CtiawGS1.js","assets/sun-JU8p4FoE.js","assets/shield-check-MAPDL1u8.js","assets/logo-agrtpj2n.js","assets/input-8zzM34r5.js","assets/font-provider-BPsIRWbb.js","assets/theme-provider-BREWrHp_.js","assets/theme-switch-CHLQml_8.js","assets/dropdown-menu-DzCIpsuG.js","assets/check-CHp0nSkR.js","assets/header-DVAsq5d6.js","assets/link-DPnL8P5J.js","assets/ClientOnly-Bj5CESUC.js","assets/forbidden-CgQyYxDt.js","assets/general-error-BoAB2alm.js","assets/maintenance-error-DeZhljU8.js","assets/not-found-error-BvJzTnw0.js","assets/unauthorized-error-CtrGmVOn.js"])))=>i.map(i=>d[i]);
|
||||||
|
import{n as e,t}from"./lazyRouteComponent-JF8ipq5d.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-BWjxg4aC.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-oy4G8XRq.js","assets/search-provider-BIQ51MIB.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-CKhUg8lz.js","assets/messages-CokTNgkr.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-D_xrov5z.js","assets/button-DdD9bP3z.js","assets/clsx-D9aGYY3V.js","assets/dist-BTFQXMl4.js","assets/dist-BQFHFtr-.js","assets/dist-r2DO4E6w.js","assets/dist-CrzsE82B.js","assets/es2015-Cc8TzKT6.js","assets/dist-BbSJdbb-.js","assets/dist-TZZx9SXO.js","assets/dist-C8gCQuQD.js","assets/dist-3039QLji.js","assets/dist-CQdg-Tnw.js","assets/dist-BixeH3nX.js","assets/dist-DIWGcwtg.js","assets/separator-BSf5wz9l.js","assets/tooltip-GAwM0SrD.js","assets/dist-5sRZ1J6O.js","assets/dist-_fvN-8F3.js","assets/auth-store-C6nFFVPp.js","assets/dist-CSdexNO_.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-Ci9WQkc-.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-DixYhGRE.js","assets/skeleton-C0TiN-ew.js","assets/wallet-BEOGa_nU.js","assets/sun-BwqrnykR.js","assets/shield-check-CiWq1V4z.js","assets/logo-BeMv8yVy.js","assets/input-BdArIRZ9.js","assets/font-provider-XOdRSAjV.js","assets/theme-provider-Bq1RKBft.js","assets/theme-switch-DEZuhZJw.js","assets/dropdown-menu-tLOgVJlP.js","assets/check-DldThLle.js","assets/header-DxK_xQxT.js","assets/link-CK5djrfn.js","assets/ClientOnly-DpS_F-Xn.js","assets/forbidden-2YkGwREa.js","assets/general-error-CJCCJkg5.js","assets/maintenance-error-D_5WJHLy.js","assets/not-found-error-DAU8lBe-.js","assets/unauthorized-error-Btb6_KCA.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-CE1uAfxe.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-oy4G8XRq.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{Kp as e}from"./messages-CokTNgkr.js";import{m as t}from"./search-provider-BIQ51MIB.js";import{n,t as r}from"./theme-switch-DEZuhZJw.js";import{t as i}from"./general-error-CJCCJkg5.js";import{t as a}from"./not-found-error-DAU8lBe-.js";import{t as o}from"./_error-DWrFFiWw.js";import{t as s}from"./unauthorized-error-Btb6_KCA.js";import{t as c}from"./forbidden-2YkGwREa.js";import{t as l}from"./maintenance-error-D_5WJHLy.js";import{n as u,r as d,t as f}from"./header-DxK_xQxT.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-DCdjklsJ.js","assets/chunk-DECur_0Z.js","assets/button-DdD9bP3z.js","assets/clsx-D9aGYY3V.js","assets/messages-CokTNgkr.js","assets/react-CO2uhaBc.js","assets/select-CjMGKVW5.js","assets/dist-5sRZ1J6O.js","assets/dist-CKhUg8lz.js","assets/dist-r2DO4E6w.js","assets/dist-CrzsE82B.js","assets/dist-DIWGcwtg.js","assets/dist-CRowTfGU.js","assets/dist-CQdg-Tnw.js","assets/dist-TZZx9SXO.js","assets/dist-BQFHFtr-.js","assets/es2015-Cc8TzKT6.js","assets/dist-BixeH3nX.js","assets/dist-_fvN-8F3.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-C6nFFVPp.js","assets/dist-CSdexNO_.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard--eZunBzP.js","assets/arrow-left-Ck1MERx0.js","assets/radio-group-DA6nlktd.js","assets/dist-C8gCQuQD.js","assets/dist-BbSJdbb-.js","assets/dist-3039QLji.js","assets/loader-circle-DRj7MiMc.js","assets/send-g-CrrC4k.js","assets/triangle-alert-BEkslGAT.js","assets/dialog-DixYhGRE.js","assets/dist-BTFQXMl4.js","assets/crypto-icon-BxZHTcEz.js","assets/labels-Baqaexjy.js","assets/input-BdArIRZ9.js","assets/checkout-model-qShof1IE.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-CE1uAfxe.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-DCdjklsJ.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42])),`component`)});export{r as t};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-CrABgEyD.js","assets/chunk-DECur_0Z.js","assets/button-C_NDYaz8.js","assets/clsx-D9aGYY3V.js","assets/messages-BOatyqUm.js","assets/react-CO2uhaBc.js","assets/select-CzebumOF.js","assets/dist-CsIb2aLK.js","assets/dist-DPPquN_M.js","assets/dist-D4X8zGEB.js","assets/dist-Cc8_LDAq.js","assets/dist-ZdRQQNeu.js","assets/dist-CRowTfGU.js","assets/dist-CHFjpVqk.js","assets/dist-BNFQuJTu.js","assets/dist-rcWP-8Cu.js","assets/es2015-DT8UeWzs.js","assets/dist-BixeH3nX.js","assets/dist-Dtn5c_cx.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-CHp0nSkR.js","assets/chevron-down-BB5h1CsX.js","assets/auth-store-De4Y7PFV.js","assets/dist-JOUh6qvR.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard-C1tj4a8X.js","assets/arrow-left-right-CcCrkQiR.js","assets/arrow-left-BpZwGREZ.js","assets/radio-group-D2rceepu.js","assets/dist-C5heX0fx.js","assets/dist-DvwKOQ8R.js","assets/dist-D0DoWChj.js","assets/loader-circle-DpEz1fQv.js","assets/send-BNvceEpO.js","assets/triangle-alert-DB5v_9sS.js","assets/dialog-CZ-2RPb-.js","assets/dist-iiotRAEO.js","assets/crypto-icon-DnliVYVs.js","assets/labels-Cbc2zlmv.js","assets/input-8zzM34r5.js","assets/checkout-model-CQ3aqlob.js"])))=>i.map(i=>d[i]);
|
||||||
|
import{n as e,t}from"./lazyRouteComponent-JF8ipq5d.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-CrABgEyD.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43])),`component`)});export{r as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Fp as e,Kp as t,Pp as n,_n as r,bn as i,gn as a,hn as o,mn as s,vn as c,yn as l}from"./messages-CokTNgkr.js";import{i as u,n as d,t as f}from"./button-DdD9bP3z.js";import{n as p,r as m}from"./font-provider-XOdRSAjV.js";import{n as h}from"./theme-provider-Bq1RKBft.js";import{t as g}from"./chevron-down-BPPKEraq.js";import{n as _,t as v}from"./radio-group-DA6nlktd.js";import{r as y,s as b}from"./zod-CQemhjci.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-B-HnSRAy.js";import{t as A}from"./page-header-Bgrl_xms.js";import{t as j}from"./show-submitted-data-C7kK9HtD.js";var M=t(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:i}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&i(e.font),e.theme!==l&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:r()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:t})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:a()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:t.value,onValueChange:t.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:n()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:s()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:i(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
|
import{Cn as e,Sn as t,Up as n,Wp as r,_n as i,bn as a,tm as o,vn as s,xn as c,yn as l}from"./messages-BOatyqUm.js";import{i as u,n as d,t as f}from"./button-C_NDYaz8.js";import{n as p,r as m}from"./font-provider-BPsIRWbb.js";import{n as h}from"./theme-provider-BREWrHp_.js";import{t as g}from"./chevron-down-BB5h1CsX.js";import{n as _,t as v}from"./radio-group-D2rceepu.js";import{r as y,s as b}from"./zod-rwFXxHlg.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-KfFEakes.js";import{t as A}from"./page-header-GTtyZFBB.js";import{t as j}from"./show-submitted-data-BqZb-_Pf.js";var M=o(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:e,setFont:t}=p(),{theme:o,setTheme:y}=h(),b={theme:o,font:e},A=w({resolver:S(N),defaultValues:b});function P(n){n.font!==e&&t(n.font),n.theme!==o&&y(n.theme),j(n)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:a()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:l()}),(0,M.jsx)(D,{children:s()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:e.value,onValueChange:e.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:r()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:n()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:i()})]})})}function F(){return(0,M.jsx)(A,{description:t(),title:e(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`arrow-left-right`,[[`path`,{d:`M8 3 4 7l4 4`,key:`9rb6wj`}],[`path`,{d:`M4 7h16`,key:`6tx8e3`}],[`path`,{d:`m16 21 4-4-4-4`,key:`siv7j2`}],[`path`,{d:`M20 17H4`,key:`h6l3hr`}]]);export{t};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Kp as n}from"./messages-CokTNgkr.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{tm as n}from"./messages-BOatyqUm.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Kp as e}from"./messages-CokTNgkr.js";import{i as t,o as n,r}from"./button-DdD9bP3z.js";var i=e(),a=r(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:r=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?n:`span`,{className:t(a({variant:r}),e),"data-slot":`badge`,"data-variant":r,...s})}export{o as t};
|
import{tm as e}from"./messages-BOatyqUm.js";import{i as t,o as n,r}from"./button-C_NDYaz8.js";var i=e(),a=r(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:r=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?n:`span`,{className:t(a({variant:r}),e),"data-slot":`badge`,"data-variant":r,...s})}export{o as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Kp as e}from"./messages-CokTNgkr.js";import{i as t}from"./button-DdD9bP3z.js";var n=e();function r({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm`,e),"data-slot":`card`,...r})}function i({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),"data-slot":`card-header`,...r})}function a({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`font-semibold leading-none`,e),"data-slot":`card-title`,...r})}function o({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`card-description`,...r})}function s({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`px-6`,e),"data-slot":`card-content`,...r})}export{a,i,s as n,o as r,r as t};
|
import{tm as e}from"./messages-BOatyqUm.js";import{i as t}from"./button-C_NDYaz8.js";var n=e();function r({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm`,e),"data-slot":`card`,...r})}function i({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),"data-slot":`card-header`,...r})}function a({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`font-semibold leading-none`,e),"data-slot":`card-title`,...r})}function o({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`card-description`,...r})}function s({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`px-6`,e),"data-slot":`card-content`,...r})}export{a,i,s as n,o as r,r as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{a as t,d as n,f as r}from"./auth-store-C6nFFVPp.js";import{t as i}from"./react-CO2uhaBc.js";import{t as a}from"./createLucideIcon-Br0Bd5k2.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(i(),1),c=new n({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=r(c,e=>e.chains),n=r(c,e=>e.loading),i=t.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:n,refetch:i.refetch}}export{o as n,d as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{a as t,d as n,f as r}from"./auth-store-De4Y7PFV.js";import{t as i}from"./react-CO2uhaBc.js";import{t as a}from"./createLucideIcon-Br0Bd5k2.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(i(),1),c=new n({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=r(c,e=>e.chains),n=r(c,e=>e.loading),i=t.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:n,refetch:i.refetch}}export{o as n,d as t};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user