17 Commits

Author SHA1 Message Date
line-6000 6589ad10a6 feat(epctl): add upgrade restart controls and rollback-safe deploy 2026-06-29 22:55:36 +08:00
line-6000 33b39e9771 Merge pull request #92 from GMWalletApp/dev
Enhance ePay support and improve installation process
2026-06-29 18:09:33 +08:00
github-actions[bot] 642ea0ebe2 chore(www): sync frontend build 2026-06-29 09:35:39 +00:00
github-actions[bot] 3621a02414 chore(www): sync frontend build 2026-06-29 08:00:05 +00:00
github-actions[bot] 5e4dcfb539 chore(www): sync frontend build 2026-06-29 06:27:32 +00:00
line-6000 1cdb1edd86 feat: add epctl installer, docker validation script, and bilingual docs 2026-06-29 14:26:54 +08:00
line-6000 7d09adff20 feat: return initial admin password from install flow 2026-06-29 13:58:16 +08:00
line-6000 15de0e6940 auth: keep init password available until password change 2026-06-28 18:14:17 +08:00
line-6000 73aef80499 fix(epay): restrict submit type to alipay or supported selectors 2026-06-28 17:33:49 +08:00
line-6000 2dc538c0e1 feat(epay): support type selectors and preserve callback type 2026-06-28 11:56:58 +08:00
line-6000 b4d1a4d8d4 Merge pull request #91 from GMWalletApp/dev
Add Aptos USDT/USDC payment support and drive token RPC routing
2026-06-20 14:31:03 +08:00
line-6000 8216c91739 fix(aptos): drive token support and RPC routing from config 2026-06-20 14:17:42 +08:00
line-6000 de96a5d3e7 feat: add Aptos USDT/USDC payment support 2026-06-20 11:08:19 +08:00
line-6000 79ca1730cd Merge pull request #90 from GMWalletApp/dev
Add ePay return relay and simplify multi-architecture builds
2026-06-17 18:12:59 +08:00
github-actions[bot] ad7679b7fd chore(www): sync frontend build 2026-06-17 10:01:44 +00:00
line-6000 0215b2787e feat: add epay return relay and simplify multi-arch builds 2026-06-17 18:01:09 +08:00
line-6000 75dfeced01 Merge pull request #89 from GMWalletApp/dev
Support placeholder orders for GMPay and EPay with asset sync
2026-06-16 19:36:14 +08:00
199 changed files with 7233 additions and 534 deletions
+10 -2
View File
@@ -10,7 +10,7 @@
<p align="center">
<a href="./README.en.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a>
<a href="./README.md">简体中文</a>
</p>
<p align="center">
@@ -45,6 +45,7 @@ Deploy it privately, integrate with the HTTP API, and start receiving crypto pay
| **Solana** | USDT, USDC |
| **BEP20** (BSC) | USDT, USDC, BNB |
| **Polygon** | USDT, USDC |
| **Aptos** | USDC, USDT |
| **More** | Expanding continuously |
> 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
- **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
- **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
@@ -87,17 +88,24 @@ Quick-start links:
| 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 |
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
| [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
```text
Epusdt
├── epctl Linux binary installation and operations script
├── src/ Core project source code
└── wiki/ Documentation assets and knowledge base
```
+9 -1
View File
@@ -45,6 +45,7 @@
| **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC |
| **Aptos** | USDC、USDT |
| **更多** | 持续扩展中… |
> 具体支持的链与代币以 [最新版本](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
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
@@ -93,17 +94,24 @@ Epusdt 已完成第三方安全审计。
| 教程 | 说明 |
|------|------|
| [仓库内:epctl 安装脚本](wiki/EPCTL.md) | Linux 二进制安装、升级、状态查看与 Docker 验收脚本 |
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.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
Epusdt
├── epctl Linux 二进制安装与运维脚本
├── src/ 项目核心代码
└── wiki/ 文档与知识库
```
Executable
+1684
View File
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly CONTAINER_NAME="epctl-systemd-test-$$"
readonly BASE_IMAGE="${EPCTL_DOCKER_IMAGE:-ubuntu:24.04}"
ACTIVE_LANG=""
POSITIONAL_ARGS=()
detect_default_language() {
local raw normalized
raw="${EPCTL_LANG:-}"
if [[ -z "${raw}" ]]; then
printf 'zh\n'
return
fi
normalized="${raw,,}"
case "${normalized}" in
zh|zh-*|zh_*|cn|chs|cht|中文)
printf 'zh\n'
;;
en|en-*|en_*|english)
printf 'en\n'
;;
*)
printf '%s\n' "${raw}"
;;
esac
}
set_language() {
local requested normalized fallback_lang
requested="${1:-$(detect_default_language)}"
normalized="${requested,,}"
fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}"
case "${normalized}" in
zh|zh-*|zh_*|cn|chs|cht|中文)
ACTIVE_LANG="zh"
;;
en|en-*|en_*|english)
ACTIVE_LANG="en"
;;
*)
ACTIVE_LANG="${fallback_lang}"
die "$(trf unsupported_language "${requested}")"
;;
esac
export EPCTL_LANG="${ACTIVE_LANG}"
}
trf() {
local key="$1"
shift || true
local template
case "${ACTIVE_LANG}:${key}" in
en:usage) template='Usage:' ;;
zh:usage) template='用法:' ;;
en:example) template='Example:' ;;
zh:example) template='示例:' ;;
en:missing_dependency) template='missing dependency: %s' ;;
zh:missing_dependency) template='缺少依赖:%s' ;;
en:arg_requires_value) template='%s requires a value' ;;
zh:arg_requires_value) template='%s 需要一个参数值' ;;
en:unsupported_language) template='unsupported language: %s' ;;
zh:unsupported_language) template='不支持的语言:%s' ;;
en:unsupported_arch) template='unsupported architecture: %s' ;;
zh:unsupported_arch) template='不支持的架构:%s' ;;
en:test_failed_collecting) template='test failed; collecting container diagnostics' ;;
zh:test_failed_collecting) template='测试失败,正在收集容器诊断信息' ;;
en:install_tag_invalid) template='install tag must be a real GitHub release tag such as v1.0.6' ;;
zh:install_tag_invalid) template='安装 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
en:upgrade_tag_invalid) template='upgrade tag must be a real GitHub release tag such as v1.0.6' ;;
zh:upgrade_tag_invalid) template='升级 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
en:base_image) template='base image: %s' ;;
zh:base_image) template='基础镜像:%s' ;;
en:starting_container) template='starting the Ubuntu systemd test container directly from %s' ;;
zh:starting_container) template='正在直接从 %s 启动 Ubuntu systemd 测试容器' ;;
en:systemd_not_ready) template='systemd did not become ready inside the container' ;;
zh:systemd_not_ready) template='容器内的 systemd 未能及时就绪' ;;
en:self_installing) template='installing epctl into PATH' ;;
zh:self_installing) template='正在将 epctl 安装到 PATH' ;;
en:downloading_release) template='downloading release %s' ;;
zh:downloading_release) template='正在下载 release %s' ;;
en:installing_release) template='installing epusdt from release %s' ;;
zh:installing_release) template='正在从 release %s 安装 epusdt' ;;
en:waiting_assets) template='waiting for epusdt to start and release www assets' ;;
zh:waiting_assets) template='正在等待 epusdt 启动并释放 www 静态文件' ;;
en:upgrading_release) template='upgrading epusdt from %s to %s' ;;
zh:upgrading_release) template='正在将 epusdt 从 %s 升级到 %s' ;;
en:verifying_upgrade_skip_restart) template='verifying that `upgrade --no-restart` skips the restart and keeps the current process running' ;;
zh:verifying_upgrade_skip_restart) template='正在验证 `upgrade --no-restart` 会跳过重启,并保持当前进程继续运行' ;;
en:verifying_upgrade_default_restart) template='verifying that a non-interactive upgrade restarts the service by default' ;;
zh:verifying_upgrade_default_restart) template='正在验证非交互升级默认会重启服务' ;;
en:verifying_upgrade_prompt_skip) template='verifying that `upgrade --prompt-restart` can skip the restart when `n` is entered' ;;
zh:verifying_upgrade_prompt_skip) template='正在验证 `upgrade --prompt-restart` 在输入 `n` 时会跳过重启' ;;
en:verifying_upgrade_prompt_restart) template='verifying that `upgrade --prompt-restart` restarts the service by default when Enter is pressed' ;;
zh:verifying_upgrade_prompt_restart) template='正在验证 `upgrade --prompt-restart` 在直接回车时默认会重启服务' ;;
en:checking_init_password) template='checking init-password behavior' ;;
zh:checking_init_password) template='正在检查 init-password 行为' ;;
en:expected_second_failure) template='expected init-password to fail after the password was changed' ;;
zh:expected_second_failure) template='密码修改后,init-password 本应失败,但它却成功了' ;;
en:verification_succeeded) template='docker verification succeeded for install=%s%s' ;;
zh:verification_succeeded) template='Docker 验证通过,install=%s%s' ;;
*)
template="missing translation: ${key}"
;;
esac
printf -- "${template}" "$@"
}
log() {
printf '[epctl-docker-test] %s\n' "$*" >&2
}
die() {
printf '[epctl-docker-test] error: %s\n' "$*" >&2
exit 1
}
usage() {
if [[ "${ACTIVE_LANG}" == "zh" ]]; then
cat <<'EOF'
用法:
./epctl-docker-test.sh [--lang zh|en] <install-tag> [upgrade-tag]
示例:
./epctl-docker-test.sh v1.0.6
./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8
EOF
else
cat <<'EOF'
Usage:
./epctl-docker-test.sh [--lang zh|en] <install-tag> [upgrade-tag]
Example:
./epctl-docker-test.sh v1.0.6
./epctl-docker-test.sh --lang en v1.0.6 v1.0.8
EOF
fi
}
parse_global_options() {
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--lang)
[[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")"
set_language "$2"
shift 2
;;
--lang=*)
set_language "${1#*=}"
shift
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(trf missing_dependency "$1")"
}
map_arch() {
case "$(uname -m)" in
x86_64|amd64)
printf 'amd64\n'
;;
aarch64|arm64)
printf 'arm64\n'
;;
*)
die "$(trf unsupported_arch "$(uname -m)")"
;;
esac
}
docker_bash() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "$1"
}
cleanup() {
local exit_code="$?"
if [[ "${exit_code}" -ne 0 ]]; then
log "$(trf test_failed_collecting)"
docker logs "${CONTAINER_NAME}" >/dev/null 2>&1 && docker logs "${CONTAINER_NAME}" >&2 || true
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl status epusdt --no-pager || true' || true
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'journalctl -u epusdt --no-pager -n 100 || true' || true
fi
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
}
wait_for_systemd() {
local attempt
for attempt in $(seq 1 120); do
if docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=Version --value >/dev/null 2>&1'; then
return 0
fi
sleep 1
done
return 1
}
wait_for_service_assets() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
for _ in $(seq 1 30); do
if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then
exit 0
fi
sleep 1
done
systemctl status epusdt --no-pager || true
journalctl -u epusdt --no-pager -n 100 || true
exit 1
'
}
service_main_pid() {
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=MainPID --value epusdt' | tr -d '\r\n'
}
set_language ""
parse_global_options "$@"
INSTALL_TAG="${POSITIONAL_ARGS[0]:-}"
UPGRADE_TAG="${POSITIONAL_ARGS[1]:-}"
[[ -n "${INSTALL_TAG}" ]] || {
usage
exit 1
}
[[ "${INSTALL_TAG}" == v* ]] || die "$(trf install_tag_invalid)"
if [[ -n "${UPGRADE_TAG}" && "${UPGRADE_TAG}" != v* ]]; then
die "$(trf upgrade_tag_invalid)"
fi
require_command docker
require_command curl
ASSET_ARCH="$(map_arch)"
ASSET_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/epusdt-${INSTALL_TAG#v}-linux-${ASSET_ARCH}.tar.gz"
SUMS_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/SHA256SUMS"
curl --fail --silent --show-error --head "${ASSET_URL}" >/dev/null
curl --fail --silent --show-error --head "${SUMS_URL}" >/dev/null
if [[ -n "${UPGRADE_TAG}" ]]; then
curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/epusdt-${UPGRADE_TAG#v}-linux-${ASSET_ARCH}.tar.gz" >/dev/null
curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/SHA256SUMS" >/dev/null
fi
trap cleanup EXIT
log "$(trf base_image "${BASE_IMAGE}")"
log "$(trf starting_container "${BASE_IMAGE}")"
docker run -d \
--name "${CONTAINER_NAME}" \
--privileged \
--cgroupns=host \
-e container=docker \
--tmpfs /run \
--tmpfs /run/lock \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
-v "${SCRIPT_DIR}:/work" \
-w /work \
"${BASE_IMAGE}" \
bash -euo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
ca-certificates \
curl \
expect \
jq \
sudo \
systemd \
systemd-sysv \
tar
apt-get clean
rm -rf /var/lib/apt/lists/*
if ! id -u tester >/dev/null 2>&1; then
useradd -m -s /bin/bash tester
fi
mkdir -p /etc/sudoers.d
echo "tester ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/tester
chmod 0440 /etc/sudoers.d/tester
exec /sbin/init
' >/dev/null
wait_for_systemd || die "$(trf systemd_not_ready)"
log "$(trf self_installing)"
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "su - tester -c 'cd /work && ./epctl self-install'"
docker_bash '[[ -x /usr/local/bin/epctl ]]'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""'
log "$(trf downloading_release "${INSTALL_TAG}")"
docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl download --tag ${TAG}"'
log "$(trf installing_release "${INSTALL_TAG}")"
docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"'
log "$(trf waiting_assets)"
wait_for_service_assets
docker_bash '[[ -x /opt/epusdt/epusdt ]]'
docker_bash '[[ -f /opt/epusdt/.env ]]'
docker_bash 'systemctl is-active --quiet epusdt'
docker_bash '[[ -f /opt/epusdt/www/index.html ]]'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl status >/tmp/epctl-status.out 2>/tmp/epctl-status.err"
! grep -q "systemd-journal" /tmp/epctl-status.err
'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl logs --lines 50 >/tmp/epctl-logs.out 2>/tmp/epctl-logs.err"
! grep -q "systemd-journal" /tmp/epctl-logs.err
'
if [[ -n "${UPGRADE_TAG}" ]]; then
log "$(trf upgrading_release "${INSTALL_TAG}" "${UPGRADE_TAG}")"
env_checksum_before="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')"
pid_before_skip_restart="$(service_main_pid)"
log "$(trf verifying_upgrade_skip_restart)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --no-restart" >"${output_file}" 2>&1
grep -q "systemctl restart epusdt" "${output_file}"
! grep -q "Restart .*\[Y/n\]" "${output_file}"
rm -f "${output_file}"
'
pid_after_skip_restart="$(service_main_pid)"
[[ "${pid_before_skip_restart}" == "${pid_after_skip_restart}" ]]
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '/opt/epusdt/epusdt version | grep -q "^version: ${TAG}$"'
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c 'cmp -s /opt/epusdt/.env.example "/tmp/epusdt/${TAG}/extract/.env.example"'
env_checksum_after_skip="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')"
[[ "${env_checksum_before}" == "${env_checksum_after_skip}" ]]
log "$(trf verifying_upgrade_default_restart)"
pid_before_default_restart="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG}" >"${output_file}" 2>&1
! grep -q "Restart .*\[Y/n\]" "${output_file}"
rm -f "${output_file}"
'
wait_for_service_assets
pid_after_default_restart="$(service_main_pid)"
[[ "${pid_before_default_restart}" != "${pid_after_default_restart}" ]]
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
if su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --prompt-restart" </dev/null >"${output_file}" 2>&1; then
cat "${output_file}" >&2
rm -f "${output_file}"
exit 1
fi
grep -q "interactive terminal" "${output_file}"
rm -f "${output_file}"
'
log "$(trf verifying_upgrade_prompt_skip)"
pid_before_prompt_skip="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
output_file="$(mktemp)"
OUTPUT_FILE="${output_file}" expect <<'"'"'EOF'"'"'
set timeout 120
log_file -noappend $env(OUTPUT_FILE)
spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart"
expect {
-re {Restart .*\[Y/n\]} { send "n\r" }
timeout { exit 1 }
}
expect eof
EOF
grep -q "systemctl restart epusdt" "${output_file}"
rm -f "${output_file}"
'
pid_after_prompt_skip="$(service_main_pid)"
[[ "${pid_before_prompt_skip}" == "${pid_after_prompt_skip}" ]]
log "$(trf verifying_upgrade_prompt_restart)"
pid_before_prompt_restart="$(service_main_pid)"
docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '
expect <<'"'"'EOF'"'"'
set timeout 120
spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart"
expect {
-re {Restart .*\[Y/n\]} { send "\r" }
timeout { exit 1 }
}
expect eof
EOF
'
wait_for_service_assets
pid_after_prompt_restart="$(service_main_pid)"
[[ "${pid_before_prompt_restart}" != "${pid_after_prompt_restart}" ]]
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
su - tester -c "epctl status >/tmp/epctl-status-upgrade.out 2>/tmp/epctl-status-upgrade.err"
! grep -q "systemd-journal" /tmp/epctl-status-upgrade.err
'
fi
log "$(trf checking_init_password)"
docker exec "${CONTAINER_NAME}" bash -euo pipefail -c '
response="$(su - tester -c "epctl init-password")"
password="$(printf "%s\n" "${response}" | jq -r ".data.password")"
printf "%s\n" "${response}" | jq -e ".status_code == 200 and (.data.password | type == \"string\" and length > 0)" >/dev/null
login_payload="$(jq -nc --arg username admin --arg password "${password}" "{username:\$username,password:\$password}")"
token="$(curl --fail --silent --show-error \
-H "Content-Type: application/json" \
-d "${login_payload}" \
http://127.0.0.1:8000/admin/api/v1/auth/login | jq -r ".data.token")"
[[ -n "${token}" && "${token}" != "null" ]]
change_payload="$(jq -nc --arg old_password "${password}" --arg new_password "new-pass-789" "{old_password:\$old_password,new_password:\$new_password}")"
curl --fail --silent --show-error \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
-d "${change_payload}" \
http://127.0.0.1:8000/admin/api/v1/auth/password | jq -e ".status_code == 200" >/dev/null
failure_output="$(mktemp)"
if su - tester -c "epctl init-password" >"${failure_output}" 2>&1; then
cat "${failure_output}" >&2
rm -f "${failure_output}"
echo "'"$(trf expected_second_failure)"'" >&2
exit 1
fi
grep -Eq "\"status_code\"[[:space:]]*:[[:space:]]*10040" "${failure_output}"
rm -f "${failure_output}"
'
log "$(trf verification_succeeded "${INSTALL_TAG}" "${UPGRADE_TAG:+ upgrade=${UPGRADE_TAG}}")"
+2 -1
View File
@@ -1,3 +1,4 @@
.DS_Store
dist/
*.log
*.log
bin/
+41
View File
@@ -0,0 +1,41 @@
APP := epusdt
TARGET_DIR := bin
BUILD_TAG ?= 0.0.0-dev
VERSION_PKG := github.com/GMWalletApp/epusdt/config
LDFLAGS := -s -w -X $(VERSION_PKG).BuildVersion=$(BUILD_TAG)
.PHONY: build release build-all swagger \
linux-amd64 linux-arm64 linux-armv7 \
darwin-amd64 darwin-arm64 \
windows-amd64 windows-arm64
build:
go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP) .
release: build
linux-amd64:
GOOS=linux GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-amd64 .
linux-arm64:
GOOS=linux GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-arm64 .
linux-armv7:
GOOS=linux GOARCH=arm GOARM=7 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-armv7 .
darwin-amd64:
GOOS=darwin GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-amd64 .
darwin-arm64:
GOOS=darwin GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-arm64 .
windows-amd64:
GOOS=windows GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-amd64.exe .
windows-arm64:
GOOS=windows GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-arm64.exe .
build-all: linux-amd64 linux-arm64 linux-armv7 darwin-amd64 darwin-arm64 windows-amd64 windows-arm64
swagger:
swag init --outputTypes json,yaml --parseInternal --parseDependency -o docs
-2
View File
@@ -55,8 +55,6 @@ func InitApp() {
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
color.Yellow.Printf("║ Username: %-54s║\n", "admin")
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("╚════════════════════════════════════════════════════════════════════════╝")
}
if _, err := appjwt.EnsureSecret(); err != nil {
-30
View File
@@ -1,8 +1,6 @@
package admin
import (
"errors"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
@@ -35,12 +33,6 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"`
}
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login
// @Description Verify credentials and return a signed JWT token
@@ -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
// frontend can warn until the password is changed.
// @Summary Get initial admin password hash
+1 -1
View File
@@ -40,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
// @Security AdminJWT
// @Accept 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"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
+51 -5
View File
@@ -12,8 +12,10 @@ import (
// CreateChainTokenRequest is the payload for creating a chain token.
type CreateChainTokenRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
Network string `json:"network" validate:"required" example:"tron"`
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"`
Decimals int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
@@ -22,6 +24,8 @@ type CreateChainTokenRequest struct {
// UpdateChainTokenRequest is the payload for updating a chain token.
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"`
Decimals *int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
@@ -33,6 +37,17 @@ type ChangeChainTokenStatusRequest struct {
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.
// @Summary List chain tokens
// @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.
// @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
// @Security AdminJWT
// @Accept json
@@ -87,6 +102,9 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
Enabled: enabled,
MinAmount: req.MinAmount,
}
if err := validateChainTokenPaymentConfig(row); err != nil {
return c.FailJson(ctx, err)
}
if err := data.CreateChainToken(row); err != nil {
return c.FailJson(ctx, err)
}
@@ -95,7 +113,7 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
// UpdateChainToken patches mutable columns.
// @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
// @Security AdminJWT
// @Accept json
@@ -115,18 +133,35 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
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 {
fields["contract_address"] = *req.ContractAddress
next.ContractAddress = strings.TrimSpace(*req.ContractAddress)
fields["contract_address"] = next.ContractAddress
}
if req.Decimals != nil {
next.Decimals = *req.Decimals
fields["decimals"] = *req.Decimals
}
if req.Enabled != nil {
next.Enabled = *req.Enabled
fields["enabled"] = *req.Enabled
}
if req.MinAmount != nil {
next.MinAmount = *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 {
return c.FailJson(ctx, err)
}
@@ -154,6 +189,17 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
row, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
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 {
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)
}
}
+2
View File
@@ -141,6 +141,8 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
// @Summary Mark order paid
// @Description Manually mark a waiting order as paid (operator补单)
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @Tags Admin Orders
// @Security AdminJWT
// @Accept json
+4 -4
View File
@@ -26,9 +26,9 @@ import (
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
//
// - group=epay:
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
// epay.default_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) — 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) — 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:
// 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
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
// @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders).
// @Description epay group keys: epay.default_token (e.g. "usdt" 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 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.
+13 -3
View File
@@ -3,6 +3,7 @@ package comm
import (
"encoding/json"
"net/http"
"strings"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb"
@@ -13,6 +14,8 @@ import (
"github.com/labstack/echo/v4"
)
const EPayTypeContextKey = "epay_type"
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
// Returns nil when the middleware didn't run (should not happen on authed routes).
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
@@ -65,6 +68,7 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
// @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 select on-chain payment; the special value okpay selects OkPay hosted payment.
// @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
// @Accept json
// @Produce json
@@ -100,7 +104,10 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// POST (form) per the legacy EPAY protocol; swagger documents POST as
// the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @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
// @Accept x-www-form-urlencoded
// @Produce html
@@ -110,7 +117,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (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_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID"
@@ -119,7 +126,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment"
// @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_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter"
@@ -132,6 +139,9 @@ func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err
log.Sugar.Errorf("bind request error: %v", err)
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 {
log.Sugar.Errorf("validate request error: %v", err)
return c.FailJson(ctx, err)
+29 -1
View File
@@ -1,6 +1,7 @@
package comm
import (
"net/http"
"strings"
"github.com/GMWalletApp/epusdt/model/request"
@@ -13,7 +14,8 @@ import (
// CheckoutCounter 收银台
// @Summary Checkout counter page
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection).
// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
// @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
// @Produce json
// @Param trade_id path string true "Trade ID"
@@ -30,6 +32,30 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
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 支付状态检测
// @Summary Check payment status
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
@@ -56,6 +82,8 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
// @Summary Submit transaction hash
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @Tags Payment
// @Accept json
// @Produce json
@@ -10,6 +10,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4"
)
@@ -44,6 +45,9 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
}
symbols := make([]string, 0, len(tokens))
for _, t := range tokens {
if !service.ChainTokenReadyForPayment(t) {
continue
}
sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
if sym == "" {
continue
@@ -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
View File
@@ -3,23 +3,25 @@
// 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
// 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
// POST /install — validate + write .env, then shut down
// GET /api/install/defaults — default field values for the form
// POST /api/install — validate + initialize install state, then shut down
//
// 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
// 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.
//
// Once the .env is written the install server stops and normal bootstrap
// proceeds on the same port without a restart.
// Once install state is initialized and the .env is finalized with
// install=false, the install server stops and normal bootstrap proceeds on the
// same port without a restart.
package install
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -28,10 +30,15 @@ import (
"text/template"
"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"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
)
// 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"`
}
// 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.
func InstallDefaults() InstallRequest {
return InstallRequest{
@@ -149,28 +164,20 @@ func (h *installHandler) GetDefaults(c echo.Context) error {
return c.JSON(http.StatusOK, InstallDefaults())
}
// Submit validates the install payload, writes the .env file, and signals
// the install server to shut down so the main bootstrap can proceed.
// Submit validates the install payload, writes the .env file, initializes the
// 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
// the http_listen config key (e.g. 0.0.0.0:8000).
//
// @Summary Install — submit configuration
// @Description Validates the submitted configuration and writes the .env file.
//
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
// http_bind_port must be in the range 165535 if provided.
// app_uri is required. All other fields are optional and fall back to
// the defaults returned by GET /api/install/defaults.
// Sets install=false in the written .env, then shuts down the install
// server so that normal application bootstrap starts on the same port.
// After installation completes this route is no longer served.
//
// @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.
// @Tags Install
// @Accept json
// @Produce json
// @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 500 {object} map[string]string "error"
// @Router /api/install [post]
@@ -210,16 +217,26 @@ func (h *installHandler) Submit(c echo.Context) error {
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()})
}
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)
// under the /install path and blocks until the .env file has been written.
// RunInstallServer starts the install UI and REST API on listenAddr
// (default :8000), then blocks until installation has been finalized.
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
func RunInstallServer(listenAddr, envFilePath string) {
if listenAddr == "" {
@@ -264,13 +281,18 @@ var formControlledKeys = map[string]bool{
"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
// install form are preserved from the existing file so that operator-specific
// settings (tg_bot_token, db_type, etc.) survive a re-install.
// Keys that the form controls (app_uri, http_listen, …) always use the
// 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.
existingValues := map[string]string{}
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.
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)
}
@@ -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 {
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}}
@@ -360,5 +472,5 @@ order_notice_max_retry={{.OrderNoticeMaxRetry}}
api_rate_url=
# Set to true to re-run the install wizard on next startup.
install=false
install={{.InstallValue}}
`))
+192 -31
View File
@@ -10,9 +10,89 @@ import (
"testing"
"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/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) {
d := InstallDefaults()
if d.AppName != "epusdt" {
@@ -43,7 +123,7 @@ func TestWriteEnvFile(t *testing.T) {
OrderExpirationTime: 15,
OrderNoticeMaxRetry: 3,
}
if err := writeEnvFile(path, req); err != nil {
if err := writeEnvFile(path, req, false); err != nil {
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()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
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,"order_expiration_time":10,"order_notice_max_retry":1}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
rec := submitInstallRequest(t, e, payload)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
// done channel should be closed after successful submit
select {
case <-h.done:
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
body := decodeBody(t, rec)
if got, _ := body["message"].(string); got == "" {
t.Fatalf("expected success message, got body=%v", body)
}
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)
if err != nil {
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") {
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) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
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)
e, _ := newInstallTestAPI(envPath)
rec := submitInstallRequest(t, e, `{"app_name":"x"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
@@ -205,15 +373,8 @@ func TestInstallAPISubmitInvalidPort(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
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)
e, _ := newInstallTestAPI(envPath)
rec := submitInstallRequest(t, e, `{"app_uri":"http://example.com","http_bind_port":99999}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
+3
View File
@@ -27,6 +27,9 @@ const (
func CheckAdminJWT() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
if ctx.Path() == "" {
return next(ctx)
}
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
if err != nil {
return err
+10
View File
@@ -14,6 +14,12 @@ import (
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
// the minimum static rows: chains, chain tokens, default settings, and
// 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.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkAptos, DisplayName: "Aptos", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chains err=%s\n", err)
@@ -102,6 +109,8 @@ func seedChainTokens() {
// TON
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
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.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkAptos, Url: "https://aptos-rest.publicnode.com/", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
+91
View File
@@ -2,6 +2,7 @@ package dao
import (
"path/filepath"
"strings"
"testing"
"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) {
db := setupSeedSettingsTestDB(t)
Mdb = db
@@ -194,3 +269,19 @@ func setupSeedSettingsTestDB(t *testing.T) *gorm.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
}
+128 -85
View File
@@ -40,39 +40,47 @@ type InitialAdminPasswordHashInfo struct {
// can print it to the console. Idempotent — subsequent calls return
// ("", false, nil).
func EnsureDefaultAdmin() (password string, created bool, err error) {
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil {
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
}
var count int64
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
return "", false, err
if created {
cacheInitialAdminPasswordState(password)
}
if count > 0 {
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
return password, created, nil
}
func purgeDeletedInitialAdminPasswordPlain() error {
return dao.Mdb.Unscoped().
func purgeDeletedInitialAdminPasswordPlainTx(tx *gorm.DB) error {
return tx.Unscoped().
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
Delete(&mdb.Setting{}).Error
}
@@ -91,12 +99,20 @@ func HashInitialAdminPassword(plain string) string {
}
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)
settings := []mdb.Setting{
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
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,
@@ -106,7 +122,7 @@ func initAdminPasswordState(plain string) error {
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
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,
@@ -115,18 +131,21 @@ func initAdminPasswordState(plain string) error {
},
}
for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil {
if err := upsertSettingRow(tx, row); err != nil {
return err
}
}
// Keep in-process cache coherent for the current process.
return nil
}
func cacheInitialAdminPasswordState(plain string) {
hash := HashInitialAdminPassword(plain)
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock()
return nil
}
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
@@ -138,56 +157,51 @@ func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
}).Create(&row).Error
}
// ConsumeInitialAdminPassword returns the one-time initial admin password.
// After a successful read, the plaintext is deleted and cannot be fetched
// again.
func ConsumeInitialAdminPassword() (string, error) {
var password string
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
row := new(mdb.Setting)
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.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 {
// GetInitialAdminPassword returns the initial admin password plaintext while
// it is still available. The plaintext remains readable until the admin
// password is changed, after which the stored plaintext is deleted.
func GetInitialAdminPassword() (string, error) {
row := new(mdb.Setting)
if err := dao.Mdb.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(row).Error; err != nil {
return "", err
}
settingsCacheMu.Lock()
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
settingsCacheMu.Unlock()
return password, nil
if row.ID != 0 && row.Value != "" {
return row.Value, 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
@@ -250,12 +264,15 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
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 {
hash, err := HashPassword(newPlain)
if err != nil {
return err
}
clearedPlaintext := false
changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}).
@@ -264,8 +281,18 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
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
// 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
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
@@ -275,6 +302,12 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
}
initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" {
if hasPlaintext {
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
}
return nil
}
@@ -293,15 +326,25 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
}); err != nil {
return err
}
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
changedCacheValue = changedValue
return nil
})
if err != nil {
return err
}
if changedCacheValue != "" {
if clearedPlaintext || changedCacheValue != "" {
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
if clearedPlaintext {
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
}
if changedCacheValue != "" {
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
}
settingsCacheMu.Unlock()
}
return nil
+150 -5
View File
@@ -42,7 +42,7 @@ func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
}
}
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
func TestGetInitialAdminPasswordKeepsPlaintext(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -51,14 +51,58 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("seed initial password state: %v", err)
}
got, err := ConsumeInitialAdminPassword()
got, err := GetInitialAdminPassword()
if err != nil {
t.Fatalf("consume initial password: %v", err)
t.Fatalf("get initial password: %v", err)
}
if got != password {
t.Fatalf("password = %q, want %q", got, password)
}
var count int64
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&count).Error; err != nil {
t.Fatalf("count plaintext setting: %v", err)
}
if count != 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
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
@@ -67,10 +111,59 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("count plaintext setting: %v", err)
}
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) {
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)
}
}
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)
}
}
+5
View File
@@ -49,6 +49,11 @@ func normalizeLockAddress(network, address string) string {
return raw
}
}
if network == mdb.NetworkAptos {
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
return normalized
}
}
return address
}
+16
View File
@@ -184,3 +184,19 @@ func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
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)
}
}
+3
View File
@@ -41,6 +41,9 @@ func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
if network == mdb.NetworkTon {
return addressutil.NormalizeTonAddress(address)
}
if network == mdb.NetworkAptos {
return addressutil.NormalizeMoveAddress(address)
}
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)
}
}
func TestAddWalletAddressWithNetworkNormalizesMoveAddressVariants(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
want := "0x000000000000000000000000000000000000000000000000000000000000000a"
row, err := AddWalletAddressWithNetwork(mdb.NetworkAptos, " A ")
if err != nil {
t.Fatalf("add aptos wallet: %v", err)
}
if row.Address != want {
t.Fatalf("stored Aptos address = %q, want %q", row.Address, want)
}
if _, err = AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x0A"); err != constant.WalletAddressAlreadyExists {
t.Fatalf("add equivalent aptos wallet error = %v, want already exists", err)
}
}
+5 -4
View File
@@ -5,10 +5,11 @@ const (
ChainTokenStatusDisable = 2
)
// ChainToken describes a token to watch on a given chain. Replaces the
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
// load this table on startup (and refresh periodically) to learn which
// contracts to subscribe to.
// ChainToken describes a token to watch on a given chain. For EVM/TRON rows
// ContractAddress is the token contract; for Solana it is the mint; for
// TON it is the jetton master; for Aptos it is the Move asset identifier
// (coin type, metadata address or object type). Native rows use an empty
// ContractAddress.
type ChainToken struct {
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"`
+1
View File
@@ -51,6 +51,7 @@ type Orders struct {
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"`
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"`
// 回调确认状态 1=回调成功 2=未回调/回调失败
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
+1
View File
@@ -13,6 +13,7 @@ const (
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
NetworkTon = "ton"
NetworkAptos = "aptos"
)
const (
+3 -2
View File
@@ -7,7 +7,7 @@ type CreateTransactionRequest struct {
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
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
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"`
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..."`
@@ -18,6 +18,7 @@ type CreateTransactionRequest struct {
// 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 {
@@ -58,7 +59,7 @@ func (r ManualPaymentRequest) Translates() map[string]string {
type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum,aptos"`
}
func (r SwitchNetworkRequest) Translates() map[string]string {
+11 -11
View File
@@ -1,17 +1,17 @@
package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 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"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
+3 -3
View File
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
}
type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
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"` // 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:""` // 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 {
+526
View File
@@ -0,0 +1,526 @@
package service
import (
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/tidwall/gjson"
)
func AptosGetLedgerVersion() (int64, error) {
body, err := aptosGet("/v1")
if err != nil {
return 0, err
}
latest := gjson.GetBytes(body, "ledger_version").Int()
log.Sugar.Debugf("[APTOS] latest ledger version=%d", latest)
return latest, nil
}
func AptosGetTransactionByHash(txID string) ([]byte, error) {
txID = normalizeAptosTxID(txID)
log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s", txID)
return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
}
func AptosGetTransactions(start int64, limit int64) ([]byte, error) {
if start < 0 {
start = 0
}
if limit <= 0 {
limit = 1
}
log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d", start, limit)
return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit))
}
func ParseAptosTransfers(body []byte, address string, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
receive, err := addressutil.NormalizeMoveAddress(address)
if err != nil {
return nil, err
}
return ParseAptosTransfersForWallets(body, map[string]struct{}{receive: {}}, tokens)
}
func ParseAptosTransfersForWallets(body []byte, wallets map[string]struct{}, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
root := gjson.ParseBytes(body)
if !root.IsArray() {
return nil, fmt.Errorf("unexpected Aptos transactions response")
}
var out []MoveObservedTransfer
for _, tx := range root.Array() {
if tx.Get("type").String() != "" && tx.Get("type").String() != "user_transaction" {
continue
}
if !tx.Get("success").Bool() {
continue
}
txID := strings.TrimSpace(tx.Get("hash").String())
if txID == "" {
continue
}
blockTimeMs := parseAptosTimestampMs(tx.Get("timestamp"))
version := tx.Get("version").Int()
txctx := buildAptosTransferContext(tx)
events := buildAptosFungibleEvents(tx, txctx, tokens)
deposits := make([]aptosFungibleEvent, 0)
withdrawals := make(map[string]int)
for _, event := range events {
if event.token == nil {
continue
}
switch event.action {
case "withdraw":
withdrawals[aptosTransferMatchKey(event.token.Symbol, event.rawAmount)]++
case "deposit":
deposits = append(deposits, event)
}
}
usedDeposits := make([]bool, len(deposits))
for i, event := range deposits {
matchKey := aptosTransferMatchKey(event.token.Symbol, event.rawAmount)
if withdrawals[matchKey] <= 0 {
continue
}
if appendAptosObservedTransfer(&out, txID, version, blockTimeMs, event, wallets) {
withdrawals[matchKey]--
usedDeposits[i] = true
} else {
logAptosUnwatchedDeposit(txID, version, event, "direct")
}
}
for i := 0; i < len(deposits); i++ {
if usedDeposits[i] {
continue
}
for j := i + 1; j < len(deposits); j++ {
if usedDeposits[j] || !aptosDepositPairCanMatch(deposits[i], deposits[j]) {
continue
}
sum := new(big.Int).Add(deposits[i].rawAmount, deposits[j].rawAmount)
matchKey := aptosTransferMatchKey(deposits[i].token.Symbol, sum)
if withdrawals[matchKey] <= 0 {
continue
}
appendedFirst := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[i], wallets)
appendedSecond := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[j], wallets)
if !appendedFirst && !appendedSecond {
logAptosUnwatchedDeposit(txID, version, deposits[i], "split")
logAptosUnwatchedDeposit(txID, version, deposits[j], "split")
continue
}
withdrawals[matchKey]--
usedDeposits[i] = true
usedDeposits[j] = true
break
}
}
}
return out, nil
}
func appendAptosObservedTransfer(out *[]MoveObservedTransfer, txID string, version int64, blockTimeMs int64, event aptosFungibleEvent, wallets map[string]struct{}) bool {
recipient := event.owner
if !moveWalletWatched(recipient, wallets) {
return false
}
*out = append(*out, MoveObservedTransfer{
Network: mdb.NetworkAptos,
ReceiveAddress: recipient,
Token: NormalizeAptosPaymentSymbol(event.token.Symbol),
RawAmount: event.rawAmount,
Decimals: event.token.Decimals,
MinAmount: event.token.MinAmount,
Amount: rawToFloat(event.rawAmount, event.token.Decimals),
TxID: normalizeAptosTxID(txID),
BlockTimeMs: blockTimeMs,
Version: version,
TransferKey: moveAptosTransferKey(version, event.eventIndex, recipient, event.token.Symbol, event.rawAmount),
})
return true
}
func logAptosUnwatchedDeposit(txID string, version int64, event aptosFungibleEvent, matchMode string) {
log.Sugar.Debugf(
"[APTOS] matched deposit skipped owner_not_watched mode=%s version=%d tx=%s token=%s amount=%.8f raw=%s owner=%s",
matchMode,
version,
normalizeAptosTxID(txID),
NormalizeAptosPaymentSymbol(event.token.Symbol),
rawToFloat(event.rawAmount, event.token.Decimals),
event.rawAmount.String(),
event.owner,
)
}
func aptosDepositPairCanMatch(first, second aptosFungibleEvent) bool {
if first.token == nil || second.token == nil || first.rawAmount == nil || second.rawAmount == nil {
return false
}
if NormalizeAptosPaymentSymbol(first.token.Symbol) != NormalizeAptosPaymentSymbol(second.token.Symbol) {
return false
}
return first.token.ContractAddress == second.token.ContractAddress
}
type aptosFungibleEvent struct {
eventIndex int
action string
owner string
rawAmount *big.Int
token *mdb.ChainToken
}
func buildAptosFungibleEvents(tx gjson.Result, ctx aptosTransferContext, tokens []mdb.ChainToken) []aptosFungibleEvent {
out := make([]aptosFungibleEvent, 0)
for eventIndex, event := range tx.Get("events").Array() {
action := aptosFungibleEventAction(event)
if action == "" {
continue
}
rawAmount, ok := parseSignedBigInt(aptosEventRawAmount(event, ctx))
if !ok || rawAmount.Sign() <= 0 {
continue
}
token := resolveAptosPaymentToken(aptosEventAssetID(event, ctx), tokens)
if token == nil {
continue
}
out = append(out, aptosFungibleEvent{
eventIndex: eventIndex,
action: action,
owner: aptosEventRecipient(event, ctx),
rawAmount: rawAmount,
token: token,
})
}
return out
}
func aptosFungibleEventAction(event gjson.Result) string {
typ := strings.ToLower(strings.TrimSpace(event.Get("type").String()))
switch typ {
case "0x1::fungible_asset::deposit":
return "deposit"
case "0x1::fungible_asset::withdraw":
return "withdraw"
default:
return ""
}
}
func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string {
raw := ""
if rawAmount != nil {
raw = rawAmount.String()
}
return NormalizeAptosPaymentSymbol(symbol) + ":" + raw
}
func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
return resolveMoveToken(mdb.NetworkAptos, assetID, tokens)
}
func NormalizeAptosPaymentSymbol(symbol string) string {
return strings.ToUpper(strings.TrimSpace(symbol))
}
type aptosTransferContext struct {
storeOwners map[string]string
storeMetadata map[string]string
}
func buildAptosTransferContext(tx gjson.Result) aptosTransferContext {
ctx := aptosTransferContext{
storeOwners: make(map[string]string),
storeMetadata: make(map[string]string),
}
for _, change := range tx.Get("changes").Array() {
if change.Get("type").String() != "write_resource" {
continue
}
store, err := addressutil.NormalizeMoveAddress(change.Get("address").String())
if err != nil {
continue
}
changeType := strings.TrimSpace(change.Get("data.type").String())
switch changeType {
case "0x1::fungible_asset::FungibleStore":
if metadata := firstNonEmpty(
change.Get("data.data.metadata.inner").String(),
change.Get("data.data.metadata").String(),
); metadata != "" {
ctx.storeMetadata[store] = normalizeAptosObservedAssetID(metadata)
}
case "0x1::object::ObjectCore":
if owner := firstMoveAddress(change.Get("data.data.owner").String()); owner != "" {
ctx.storeOwners[store] = owner
}
}
}
return ctx
}
func aptosEventRecipient(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if owner := ctx.storeOwners[store]; owner != "" {
return owner
}
}
return ""
}
func aptosEventRawAmount(event gjson.Result, ctx aptosTransferContext) string {
return event.Get("data.amount").String()
}
func aptosEventAssetID(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if metadata := ctx.storeMetadata[store]; metadata != "" {
return metadata
}
}
return ""
}
func normalizeAptosObservedAssetID(assetID string) string {
return addressutil.NormalizeMoveAssetID(assetID)
}
func parseAptosTimestampMs(value gjson.Result) int64 {
raw := strings.TrimSpace(value.String())
if raw == "" {
return 0
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return n / 1000
}
func normalizeAptosTxID(txID string) string {
txID = strings.ToLower(strings.TrimSpace(txID))
if strings.HasPrefix(txID, "0x") {
return txID
}
return "0x" + txID
}
func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) {
txID = normalizeAptosTxID(txID)
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue
}
if _, err = validateManualAptosPaymentWithNode(order, txID, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", data.RpcNodeLogLabel(node), err))
continue
}
return txID, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual Aptos verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
func validateManualAptosPaymentWithNode(order *mdb.Orders, txID string, node mdb.RpcNode) (string, error) {
txID = normalizeAptosTxID(txID)
body, err := aptosGetWithNode(node, fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
if err != nil {
return "", fmt.Errorf("fetch aptos transaction: %w", err)
}
if err = validateManualAptosTransactionWithConfirm(order, txID, body, func(version int64) error {
return ensureAptosTransferConfirmedWithNode(version, node)
}); err != nil {
return "", err
}
return txID, nil
}
func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error {
return validateManualAptosTransactionWithConfirm(order, txID, body, EnsureAptosTransferConfirmed)
}
func validateManualAptosTransactionWithConfirm(order *mdb.Orders, txID string, body []byte, confirm func(int64) error) error {
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
transfers, err := ParseAptosTransfers([]byte("["+string(body)+"]"), order.ReceiveAddress, tokens)
if err != nil {
return err
}
amountMismatch := false
for _, transfer := range transfers {
if !strings.EqualFold(transfer.TxID, txID) {
continue
}
if err = confirm(transfer.Version); err != nil {
return err
}
if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil {
return nil
}
if strings.Contains(err.Error(), "amount mismatch") {
amountMismatch = true
} else {
return err
}
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching aptos transfer to order address not found")
}
func aptosGet(path string) (body []byte, err error) {
network := mdb.NetworkAptos
defer recordMoveRPCResult(network, &err)
tried := make([]uint64, 0, 3)
var lastErr error
for attempts := 0; attempts < 3; attempts++ {
node, resolveErr := resolveAptosRpcNode(tried...)
if resolveErr != nil {
if lastErr != nil {
return nil, lastErr
}
return nil, resolveErr
}
if len(tried) > 0 {
log.Sugar.Warnf("[APTOS] trying alternate RPC node path=%s node=%s", path, data.RpcNodeLogLabel(*node))
}
body, err = aptosGetWithNode(*node, path)
if err == nil {
data.RecordRpcNodeSuccess(node.ID)
return body, nil
}
lastErr = err
failures, cooling := data.RecordRpcNodeFailure(node.ID)
nodeLabel := data.RpcNodeLogLabel(*node)
if cooling {
log.Sugar.Warnf("[APTOS] RPC node reached fail threshold path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
} else {
log.Sugar.Warnf("[APTOS] RPC node failed path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
}
tried = append(tried, node.ID)
}
return nil, lastErr
}
func resolveAptosRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkAptos, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 {
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
node.Url = rpcURL
return node, nil
}
func aptosGetWithNode(node mdb.RpcNode, path string) (body []byte, err error) {
endpoint, err := aptosJoinURL(node.Url, path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: manualVerifyRequestTimeout}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ = io.ReadAll(resp.Body)
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("aptos rpc HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func aptosJoinURL(base, path string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
return "", fmt.Errorf("empty aptos rpc url")
}
if strings.TrimSpace(path) == "" {
return strings.TrimRight(base, "/"), nil
}
path = strings.TrimSpace(path)
trimmedBase := strings.TrimRight(base, "/")
lowerBase := strings.ToLower(trimmedBase)
if strings.HasSuffix(lowerBase, "/v1") && strings.HasPrefix(path, "/v1") {
path = strings.TrimPrefix(path, "/v1")
if path == "" {
path = "/"
}
}
return trimmedBase + path, nil
}
func EnsureAptosTransferConfirmed(version int64) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
latest, err := AptosGetLedgerVersion()
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
return ensureAptosConfirmations(version, latest)
}
func ensureAptosTransferConfirmedWithNode(version int64, node mdb.RpcNode) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
body, err := aptosGetWithNode(node, "/v1")
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
latest := gjson.GetBytes(body, "ledger_version").Int()
return ensureAptosConfirmations(version, latest)
}
func ensureAptosConfirmations(version, latest int64) error {
chain, err := data.GetChainByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
minConfirmations := int64(1)
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = int64(chain.MinConfirmations)
}
if latest-version+1 < minConfirmations {
return fmt.Errorf("aptos transaction confirmations insufficient")
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
)
type EPayReturnRedirect struct {
TargetURL string
IsMerchantRedirect bool
}
func isEPayOrder(order *mdb.Orders) bool {
return order != nil && strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay)
}
func buildCheckoutCounterPath(tradeID string) string {
return "/pay/checkout-counter/" + tradeID
}
func buildAbsoluteAppURL(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
base := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if base == "" {
return path
}
return base + path
}
func buildEPayReturnPath(tradeID string) string {
return "/pay/return/" + tradeID
}
func buildPublicRedirectURL(order *mdb.Orders) string {
if order == nil {
return ""
}
raw := strings.TrimSpace(order.RedirectUrl)
if raw == "" || !isEPayOrder(order) {
return raw
}
return buildAbsoluteAppURL(buildEPayReturnPath(order.TradeId))
}
func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order == nil || order.ApiKeyID == 0 {
return nil, constant.OrderApiKeyUnavailableErr
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, err
}
if row == nil || row.ID == 0 || row.Status != mdb.ApiKeyStatusEnable {
return nil, constant.OrderApiKeyUnavailableErr
}
return row, nil
}
func epayResultType(order *mdb.Orders) string {
if order == nil {
return "alipay"
}
if epayType := strings.TrimSpace(order.EpayType); epayType != "" {
return epayType
}
return "alipay"
}
func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) {
if order == nil || apiKeyRow == nil {
return nil, constant.EPayReturnSignatureErr
}
pidInt, err := strconv.Atoi(strings.TrimSpace(apiKeyRow.Pid))
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
notifyData := response.OrderNotifyResponseEpay{
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: epayResultType(order),
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
return map[string]string{
"pid": strconv.Itoa(pidInt),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr,
"sign_type": "MD5",
}, nil
}
func appendQueryParams(rawURL string, params map[string]string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "", constant.OrderRedirectURLErr
}
targetURL, err := url.Parse(rawURL)
if err != nil {
return "", constant.OrderRedirectURLErr
}
query := targetURL.Query()
for key, value := range params {
query.Set(key, value)
}
targetURL.RawQuery = query.Encode()
return targetURL.String(), nil
}
func ResolveEPayReturnRedirect(tradeID string) (*EPayReturnRedirect, error) {
order, err := GetOrderInfoByTradeId(tradeID)
if err != nil {
return nil, err
}
if !isEPayOrder(order) || order.Status != mdb.StatusPaySuccess {
return &EPayReturnRedirect{
TargetURL: buildCheckoutCounterPath(order.TradeId),
}, nil
}
rawMerchantURL := strings.TrimSpace(order.RedirectUrl)
if rawMerchantURL == "" {
return nil, constant.OrderRedirectURLErr
}
apiKeyRow, err := ResolveOrderApiKey(order)
if err != nil {
return nil, err
}
params, err := BuildEPayResultParams(order, apiKeyRow)
if err != nil {
return nil, err
}
targetURL, err := appendQueryParams(rawMerchantURL, params)
if err != nil {
return nil, err
}
return &EPayReturnRedirect{
TargetURL: targetURL,
IsMerchantRedirect: true,
}, nil
}
+174
View File
@@ -0,0 +1,174 @@
package service
import (
"net/url"
"testing"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
)
func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
epayOrder := &mdb.Orders{
TradeId: "trade_epay_redirect",
PaymentType: mdb.PaymentTypeEpay,
RedirectUrl: "https://merchant.example/return",
}
wantRedirect := config.GetAppUri() + "/pay/return/trade_epay_redirect"
if got := buildPublicRedirectURL(epayOrder); got != wantRedirect {
t.Fatalf("epay redirect_url = %q, want internal return route", got)
}
gmpayOrder := &mdb.Orders{
TradeId: "trade_gmpay_redirect",
PaymentType: mdb.PaymentTypeGmpay,
RedirectUrl: "https://merchant.example/return",
}
if got := buildPublicRedirectURL(gmpayOrder); got != "https://merchant.example/return" {
t.Fatalf("gmpay redirect_url = %q, want merchant raw redirect_url", got)
}
if got := buildPublicRedirectURL(&mdb.Orders{TradeId: "trade_empty", PaymentType: mdb.PaymentTypeEpay}); got != "" {
t.Fatalf("empty redirect_url = %q, want empty", got)
}
}
func TestBuildEPayResultParamsUsesOriginalType(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_params",
OrderId: "order_epay_params",
Name: "VIP",
Amount: 1,
EpayType: "usdt.tron",
}, &mdb.ApiKey{
Pid: "1001",
SecretKey: "epay-secret",
})
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
expected := map[string]string{
"pid": "1001",
"trade_no": "trade_epay_params",
"out_trade_no": "order_epay_params",
"type": "usdt.tron",
"name": "VIP",
"money": "1.0000",
"trade_status": "TRADE_SUCCESS",
"sign_type": "MD5",
}
for key, want := range expected {
if got := params[key]; got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
signParams := map[string]interface{}{
"pid": params["pid"],
"trade_no": params["trade_no"],
"out_trade_no": params["out_trade_no"],
"type": params["type"],
"name": params["name"],
"money": params["money"],
"trade_status": params["trade_status"],
}
wantSign, err := sign.Get(signParams, "epay-secret")
if err != nil {
t.Fatalf("sign.Get(): %v", err)
}
if got := params["sign"]; got != wantSign {
t.Fatalf("sign = %q, want %q", got, wantSign)
}
}
func TestBuildEPayResultParamsFallsBackToAlipayWhenTypeMissing(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_fallback",
OrderId: "order_epay_fallback",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "1001",
SecretKey: "epay-secret",
})
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
if got := params["type"]; got != "alipay" {
t.Fatalf("type = %q, want alipay", got)
}
}
func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) {
_, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_bad_pid",
OrderId: "order_bad_pid",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "not-a-number",
SecretKey: "epay-secret",
})
if err != constant.EPayReturnSignatureErr {
t.Fatalf("error = %v, want %v", err, constant.EPayReturnSignatureErr)
}
}
func TestResolveOrderApiKeyRejectsUnavailableRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "missing_api_key_id"}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("missing api_key_id error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
row := &mdb.ApiKey{
Name: "disabled",
Pid: "1201",
SecretKey: "disabled-secret",
Status: mdb.ApiKeyStatusDisable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
t.Fatalf("create disabled api key: %v", err)
}
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "disabled_api_key", ApiKeyID: row.ID}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("disabled api key error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
}
func TestAppendQueryParamsPreservesMerchantQuery(t *testing.T) {
target, err := appendQueryParams("https://merchant.example/return?from=merchant", map[string]string{
"pid": "1001",
"trade_no": "trade_epay_redirect",
"trade_status": "TRADE_SUCCESS",
})
if err != nil {
t.Fatalf("appendQueryParams(): %v", err)
}
parsed, err := url.Parse(target)
if err != nil {
t.Fatalf("url.Parse(): %v", err)
}
query := parsed.Query()
if got := query.Get("from"); got != "merchant" {
t.Fatalf("from = %q, want merchant", got)
}
if got := query.Get("pid"); got != "1001" {
t.Fatalf("pid = %q, want 1001", got)
}
if got := query.Get("trade_no"); got != "trade_epay_redirect" {
t.Fatalf("trade_no = %q, want trade_epay_redirect", got)
}
if got := query.Get("trade_status"); got != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", got)
}
}
+4 -2
View File
@@ -89,6 +89,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
canonicalTxID, err = validateManualSolanaPayment(order, txID)
case mdb.NetworkTon:
canonicalTxID, err = validateManualTonPayment(order, txID)
case mdb.NetworkAptos:
canonicalTxID, err = ValidateManualAptosPayment(order, txID)
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
canonicalTxID, err = validateManualEvmPayment(order, txID)
default:
@@ -123,7 +125,7 @@ func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string)
func manualBlockTransactionIDIsHex(network string) bool {
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
default:
return false
@@ -149,7 +151,7 @@ func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string
add(canonicalTxID)
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.ToLower(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) {
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
hash := strings.Repeat("a", 64)
+203
View File
@@ -0,0 +1,203 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/dromara/carbon/v2"
)
func seedTestAptosRPCNode(t *testing.T, url, purpose, status string, weight int) mdb.RpcNode {
t.Helper()
node := mdb.RpcNode{
Network: mdb.NetworkAptos,
Url: url,
Type: mdb.RpcNodeTypeHttp,
Weight: weight,
Enabled: true,
Purpose: purpose,
Status: status,
}
if node.Purpose == "" {
node.Purpose = mdb.RpcNodePurposeGeneral
}
if node.Status == "" {
node.Status = mdb.RpcNodeStatusOk
}
if err := dao.Mdb.Create(&node).Error; err != nil {
t.Fatalf("seed aptos rpc_nodes: %v", err)
}
return node
}
func TestAptosGetLedgerVersionUsesRpcNodes(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
var disabledCalls int
disabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
disabledCalls++
_, _ = w.Write([]byte(`{"ledger_version":"999"}`))
}))
defer disabled.Close()
disabledNode := mdb.RpcNode{
Network: mdb.NetworkAptos,
Url: disabled.URL,
Type: mdb.RpcNodeTypeHttp,
Weight: 1000,
Enabled: false,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(&disabledNode).Error; err != nil {
t.Fatalf("seed disabled aptos rpc_nodes: %v", err)
}
if err := dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", disabledNode.ID).Update("enabled", false).Error; err != nil {
t.Fatalf("disable aptos rpc_nodes: %v", err)
}
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/v1" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ledger_version":"123"}`))
}))
defer server.Close()
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
got, err := AptosGetLedgerVersion()
if err != nil {
t.Fatalf("AptosGetLedgerVersion(): %v", err)
}
if got != 123 {
t.Fatalf("ledger version = %d, want 123", got)
}
if calls != 1 {
t.Fatalf("rpc calls = %d, want 1", calls)
}
if disabledCalls != 0 {
t.Fatalf("disabled rpc calls = %d, want 0", disabledCalls)
}
}
func TestAptosGetTransactionsFallsBackToAlternateRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
var primaryCalls int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
primaryCalls++
http.Error(w, "temporary failure", http.StatusBadGateway)
}))
defer primary.Close()
var backupCalls int
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupCalls++
if r.URL.Path != "/v1/transactions" || r.URL.Query().Get("start") != "10" || r.URL.Query().Get("limit") != "2" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer backup.Close()
seedTestAptosRPCNode(t, primary.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
seedTestAptosRPCNode(t, backup.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 1)
body, err := AptosGetTransactions(10, 2)
if err != nil {
t.Fatalf("AptosGetTransactions(): %v", err)
}
if string(body) != "[]" {
t.Fatalf("transactions body = %s, want []", string(body))
}
if primaryCalls != 1 || backupCalls != 1 {
t.Fatalf("rpc calls primary=%d backup=%d, want 1/1", primaryCalls, backupCalls)
}
}
func TestValidateManualAptosPaymentUsesManualRpcFallback(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
const usdc = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
upsertTestChainToken(t, mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "USDC",
ContractAddress: usdc,
Decimals: 6,
Enabled: true,
})
receive, err := addressutil.NormalizeMoveAddress("0xa")
if err != nil {
t.Fatalf("normalize aptos receive: %v", err)
}
store, err := addressutil.NormalizeMoveAddress("0x31")
if err != nil {
t.Fatalf("normalize aptos store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress("0x32")
if err != nil {
t.Fatalf("normalize aptos sender store: %v", err)
}
var generalCalls int
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
generalCalls++
http.Error(w, "general unavailable", http.StatusBadGateway)
}))
defer general.Close()
var manualCalls int
manual := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
manualCalls++
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
case r.URL.Path == "/v1":
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
default:
http.NotFound(w, r)
}
}))
defer manual.Close()
seedTestAptosRPCNode(t, general.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
seedTestAptosRPCNode(t, manual.URL, mdb.RpcNodePurposeManualVerify, mdb.RpcNodeStatusOk, 1)
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 4.2,
ReceiveAddress: receive,
}
got, err := ValidateManualAptosPayment(order, "ABC")
if err != nil {
t.Fatalf("ValidateManualAptosPayment(): %v", err)
}
if got != "0xabc" {
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
}
if generalCalls != 1 || manualCalls != 2 {
t.Fatalf("rpc calls general=%d manual=%d, want 1/2", generalCalls, manualCalls)
}
}
+232
View File
@@ -0,0 +1,232 @@
package service
import (
"errors"
"fmt"
"math/big"
"strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
)
var gProcessedMoveTx sync.Map // transfer-level key -> unix timestamp
var processMoveOrder = OrderProcessing
type MoveObservedTransfer struct {
Network string
ReceiveAddress string
Token string
RawAmount *big.Int
Decimals int
MinAmount float64
Amount float64
TxID string
BlockTimeMs int64
Version int64
TransferKey string
}
func ProcessMoveObservedTransferResult(transfer MoveObservedTransfer) error {
net := moveLogLabel(transfer.Network)
cacheKey := MoveTransferCacheKey(transfer)
now := time.Now().Unix()
if _, loaded := gProcessedMoveTx.LoadOrStore(cacheKey, now); loaded {
return nil
}
clearCache := func() {
gProcessedMoveTx.Delete(cacheKey)
}
if transfer.RawAmount == nil || transfer.RawAmount.Sign() <= 0 || transfer.Amount <= 0 {
return nil
}
if transfer.MinAmount > 0 && transfer.Amount < transfer.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip below min amount tx=%s amount=%.2f min=%.2f", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, transfer.MinAmount)
return nil
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(transfer.Network, transfer.ReceiveAddress, transfer.Token, transfer.Amount)
if err != nil {
clearCache()
log.Sugar.Errorf("[%s-%s][%s] lock lookup failed tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, err)
return err
}
if tradeID == "" {
raw := ""
if transfer.RawAmount != nil {
raw = transfer.RawAmount.String()
}
log.Sugar.Infof("[%s-%s][%s] skip unmatched tx=%s amount=%.8f raw=%s decimals=%d", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, raw, transfer.Decimals)
return nil
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
clearCache()
log.Sugar.Errorf("[%s-%s][%s] load order trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return err
}
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
log.Sugar.Warnf("[%s-%s][%s] skip tx=%s trade_id=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, tradeID, err)
return nil
}
req := &request.OrderProcessingRequest{
ReceiveAddress: transfer.ReceiveAddress,
Token: transfer.Token,
Network: transfer.Network,
TradeId: tradeID,
Amount: transfer.Amount,
BlockTransactionId: transfer.TxID,
}
if err = processMoveOrder(req); err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[%s-%s][%s] already resolved trade_id=%s tx=%s reason=%v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return nil
}
clearCache()
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing failed trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
return err
}
sendPaymentNotification(order)
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s tx=%s", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID)
return nil
}
func MoveTransferCacheKey(transfer MoveObservedTransfer) string {
if transfer.TransferKey != "" {
return strings.ToLower(strings.TrimSpace(transfer.TransferKey))
}
receive := strings.ToLower(strings.TrimSpace(transfer.ReceiveAddress))
token := strings.ToUpper(strings.TrimSpace(transfer.Token))
raw := ""
if transfer.RawAmount != nil {
raw = transfer.RawAmount.String()
}
switch strings.ToLower(strings.TrimSpace(transfer.Network)) {
case mdb.NetworkAptos:
return fmt.Sprintf("aptos:%d:%s:%s:%s", transfer.Version, receive, token, raw)
default:
return strings.ToLower(strings.TrimSpace(transfer.Network)) + ":" + strings.TrimSpace(transfer.TxID)
}
}
func EnsureMoveTransferMatchesOrder(order *mdb.Orders, transfer MoveObservedTransfer) error {
if order == nil || order.ID == 0 {
return fmt.Errorf("order not found")
}
if strings.ToLower(strings.TrimSpace(order.Network)) != strings.ToLower(strings.TrimSpace(transfer.Network)) {
return fmt.Errorf("network mismatch")
}
orderAddr, err := addressutil.NormalizeMoveAddress(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
transferAddr, err := addressutil.NormalizeMoveAddress(transfer.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid transfer receive address: %w", err)
}
if orderAddr != transferAddr {
return fmt.Errorf("transaction recipient mismatch")
}
if !strings.EqualFold(order.Token, transfer.Token) {
return fmt.Errorf("token mismatch")
}
if transfer.BlockTimeMs <= 0 {
return fmt.Errorf("transaction block time missing")
}
if transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Decimals) {
return fmt.Errorf("transaction amount mismatch")
}
return nil
}
func moveWalletWatched(address string, wallets map[string]struct{}) bool {
if address == "" || len(wallets) == 0 {
return false
}
_, ok := wallets[address]
return ok
}
func moveAptosTransferKey(version int64, eventIndex int, receive, token string, raw *big.Int) string {
rawText := ""
if raw != nil {
rawText = raw.String()
}
return fmt.Sprintf("aptos:%d:%d:%s:%s:%s", version, eventIndex, strings.ToLower(strings.TrimSpace(receive)), strings.ToUpper(strings.TrimSpace(token)), rawText)
}
func resolveMoveToken(network, assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
assetID = addressutil.NormalizeMoveAssetID(assetID)
for i := range tokens {
contract := strings.TrimSpace(tokens[i].ContractAddress)
if contract == "" {
continue
}
if addressutil.MoveAssetMatches(assetID, contract) {
return &tokens[i]
}
}
return nil
}
func firstMoveAddress(values ...string) string {
for _, v := range values {
if normalized, err := addressutil.NormalizeMoveAddress(v); err == nil {
return normalized
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func parseSignedBigInt(raw string) (*big.Int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, false
}
out, ok := new(big.Int).SetString(raw, 10)
return out, ok
}
func rawToFloat(raw *big.Int, decimals int) float64 {
if raw == nil {
return 0
}
value, _ := new(big.Rat).SetFrac(new(big.Int).Set(raw), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)).Float64()
return value
}
func recordMoveRPCResult(network string, errp *error) {
if errp != nil && *errp != nil {
data.RecordRpcFailure(network)
return
}
data.RecordRpcSuccess(network)
}
func moveLogLabel(network string) string {
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkAptos:
return "APTOS"
default:
return strings.ToUpper(network)
}
}
+284
View File
@@ -0,0 +1,284 @@
package service
import (
"math/big"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/dromara/carbon/v2"
)
func TestParseAptosTransfersUSDTAndUSDCFungibleEvents(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
usdcStore, _ := addressutil.NormalizeMoveAddress("0x12")
usdcSenderStore, _ := addressutil.NormalizeMoveAddress("0x13")
usdtStore, _ := addressutil.NormalizeMoveAddress("0x14")
usdtSenderStore, _ := addressutil.NormalizeMoveAddress("0x15")
noOwnerStore, _ := addressutil.NormalizeMoveAddress("0x16")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"abc",
"version":"101",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"2500000","store":"` + usdcSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2500000","store":"` + usdcStore + `"}}
],
"changes":[
{"address":"` + usdcSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
{"address":"` + usdcStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
{"address":"` + usdcStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"0xa"}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"def",
"version":"102",
"timestamp":"1700000001123456",
"payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdt + `"},"` + receive2 + `","3100000"],"type":"entry_function_payload"},
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3100000","store":"` + usdtSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"3100000","store":"` + usdtStore + `"}}
],
"changes":[
{"address":"` + usdtSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + usdtStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + usdtStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-withdraw-only",
"version":"103",
"timestamp":"1700000002123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4100000","metadata_address":"` + usdc + `"}}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-no-owner",
"version":"104",
"timestamp":"1700000003123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"5100000","store":"` + usdcSenderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"5100000","store":"` + noOwnerStore + `"}}
],
"changes":[
{"address":"` + noOwnerStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"}
]
},
{
"type":"user_transaction",
"success":false,
"hash":"skip-failed",
"events":[
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"1","metadata_address":"` + usdc + `"}}
]
},
{
"type":"user_transaction",
"success":true,
"hash":"skip-non-store-owner-fallback",
"version":"105",
"timestamp":"1700000004123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"6100000","metadata_address":"` + usdc + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"6100000","metadata_address":"` + usdc + `"}}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: usdc, Decimals: 6, Enabled: true, MinAmount: 1},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true, MinAmount: 3},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].Token != "USDC" || got[0].Amount != 2.5 || got[0].TxID != "0xabc" || got[0].ReceiveAddress != receive {
t.Fatalf("usdc transfer = %#v", got[0])
}
if got[0].TransferKey == "" {
t.Fatalf("transfer key should be non-empty: %#v", got[0])
}
if got[0].MinAmount != 1 {
t.Fatalf("usdc min amount = %.2f, want 1", got[0].MinAmount)
}
gotSecondWallet, err := ParseAptosTransfers(body, receive2, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers second wallet: %v", err)
}
if len(gotSecondWallet) != 1 || gotSecondWallet[0].Token != "USDT" || gotSecondWallet[0].Amount != 3.1 || gotSecondWallet[0].TxID != "0xdef" {
t.Fatalf("usdt transfer = %#v", gotSecondWallet)
}
}
func TestParseAptosTransfersConfiguredCustomFungibleEvent(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
custom := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
store, _ := addressutil.NormalizeMoveAddress("0x21")
senderStore, _ := addressutil.NormalizeMoveAddress("0x22")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"custom",
"version":"201",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"7500000","store":"` + senderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"7500000","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: custom, Decimals: 6, Enabled: true},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].Token != "MOVEUSD" || got[0].Amount != 7.5 || got[0].TxID != "0xcustom" {
t.Fatalf("custom transfer = %#v", got[0])
}
}
func TestParseAptosTransfersSplitDepositsMatchSingleWithdraw(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
withdrawStore, _ := addressutil.NormalizeMoveAddress("0x21")
depositStore, _ := addressutil.NormalizeMoveAddress("0x22")
depositStore2, _ := addressutil.NormalizeMoveAddress("0x23")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"split",
"version":"201",
"timestamp":"1700000000123456",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3000000","store":"` + withdrawStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"1000000","store":"` + depositStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2000000","store":"` + depositStore2 + `"}}
],
"changes":[
{"address":"` + withdrawStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"},
{"address":"` + depositStore2 + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + depositStore2 + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
}
wallets := map[string]struct{}{receive: {}, receive2: {}}
got, err := ParseAptosTransfersForWallets(body, wallets, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfersForWallets: %v", err)
}
if len(got) != 2 {
t.Fatalf("transfers len = %d, want 2: %#v", len(got), got)
}
if got[0].Token != "USDT" || got[0].Amount != 1 || got[0].ReceiveAddress != receive || got[0].TxID != "0xsplit" {
t.Fatalf("first split transfer = %#v", got[0])
}
if got[1].Token != "USDT" || got[1].Amount != 2 || got[1].ReceiveAddress != receive2 || got[1].TxID != "0xsplit" {
t.Fatalf("second split transfer = %#v", got[1])
}
}
func TestParseAptosTransfersUsesStoreOwnerWhenGuidAccountIsZero(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xa")
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
store, _ := addressutil.NormalizeMoveAddress("0x31")
senderStore, _ := addressutil.NormalizeMoveAddress("0x32")
body := []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"decimal",
"version":"301",
"timestamp":"1700000000123456",
"events":[
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Withdraw","data":{"amount":"10000","store":"` + senderStore + `"}},
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Deposit","data":{"amount":"10000","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
tokens := []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
}
got, err := ParseAptosTransfers(body, receive, tokens)
if err != nil {
t.Fatalf("ParseAptosTransfers: %v", err)
}
if len(got) != 1 {
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
}
if got[0].ReceiveAddress != receive || got[0].Decimals != 6 || got[0].Amount != 0.01 || got[0].RawAmount.String() != "10000" {
t.Fatalf("transfer amount = %#v", got[0])
}
}
func TestEnsureMoveTransferMatchesOrder(t *testing.T) {
receive, _ := addressutil.NormalizeMoveAddress("0xc")
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 3.5,
ReceiveAddress: strings.ToUpper(receive),
}
transfer := MoveObservedTransfer{
Network: mdb.NetworkAptos,
ReceiveAddress: receive,
Token: "USDC",
RawAmount: big.NewInt(3_500_000),
Decimals: 6,
BlockTimeMs: time.Now().UnixMilli(),
}
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
t.Fatalf("EnsureMoveTransferMatchesOrder: %v", err)
}
transfer.RawAmount = big.NewInt(3_400_000)
if err := EnsureMoveTransferMatchesOrder(order, transfer); err == nil || !strings.Contains(err.Error(), "amount") {
t.Fatalf("amount mismatch err = %v, want amount error", err)
}
}
+18 -2
View File
@@ -54,6 +54,11 @@ func normalizeOrderAddressByNetwork(network, address string) string {
return normalized
}
return address
case mdb.NetworkAptos:
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
return normalized
}
return address
default:
return address
}
@@ -67,6 +72,9 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
if tokenRow == nil || tokenRow.ID == 0 {
return nil, constant.SupportedAssetNotFound
}
if !ChainTokenReadyForPayment(*tokenRow) {
return nil, constant.SupportedAssetNotFound
}
return tokenRow, nil
}
@@ -108,6 +116,10 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
epayType := ""
if paymentType == mdb.PaymentTypeEpay {
epayType = strings.TrimSpace(req.EpayType)
}
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
@@ -138,6 +150,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
EpayType: epayType,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
@@ -197,6 +210,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
EpayType: epayType,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
@@ -629,6 +643,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
NotifyUrl: "",
RedirectUrl: parent.RedirectUrl,
Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
@@ -706,7 +721,7 @@ func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
if isEPayOrder(order) {
paymentType = mdb.PaymentTypeEpay
}
return &response.CheckoutCounterResponse{
@@ -720,7 +735,7 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Status: order.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl,
RedirectUrl: buildPublicRedirectURL(order),
CreatedAt: order.CreatedAt.TimestampMilli(),
IsSelected: order.IsSelected,
}
@@ -908,6 +923,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
NotifyUrl: "",
RedirectUrl: parent.RedirectUrl,
Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOkPay,
+50
View File
@@ -469,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) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
+1 -23
View File
@@ -1,9 +1,6 @@
package service
import (
"strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
@@ -23,26 +20,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
if orderInfo.ID <= 0 {
return nil, ErrOrder
}
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(orderInfo.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token,
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
Status: orderInfo.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
}
resp := buildCheckoutResponse(orderInfo)
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
if rowErr != nil {
+32
View File
@@ -0,0 +1,32 @@
package service
import (
"strings"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func ChainTokenReadyForPayment(token mdb.ChainToken) bool {
if strings.TrimSpace(token.Symbol) == "" {
return false
}
if chainTokenRequiresAssetID(token) && strings.TrimSpace(token.ContractAddress) == "" {
return false
}
return true
}
func chainTokenRequiresAssetID(token mdb.ChainToken) bool {
network := strings.ToLower(strings.TrimSpace(token.Network))
symbol := strings.ToUpper(strings.TrimSpace(token.Symbol))
switch network {
case mdb.NetworkTron:
return symbol != "TRX"
case mdb.NetworkSolana:
return symbol != "SOL"
case mdb.NetworkTon:
return symbol != TonNativeSymbol
default:
return true
}
}
+8 -42
View File
@@ -2,9 +2,7 @@ package mq
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -13,6 +11,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
@@ -25,18 +24,12 @@ import (
// other secret would produce a signature the merchant can't verify.
// The admin can resend the callback after fixing the key.
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order.ApiKeyID == 0 {
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
}
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)
row, err := service.ResolveOrderApiKey(order)
if err != nil || row == nil || row.ID == 0 {
if err != nil {
return nil, err
}
return nil, errors.New("api key unavailable")
}
return row, nil
}
@@ -207,38 +200,11 @@ func sendOrderCallback(order *mdb.Orders) error {
switch {
case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
// EPAY uses pid (integer) and secret_key as "key".
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)
formData, err := service.BuildEPayResultParams(order, apiKeyRow)
if err != nil {
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)
if err != nil {
return err
+74 -2
View File
@@ -573,6 +573,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_sign",
EpayType: "usdt.tron",
PaymentType: "epay",
ApiKeyID: key.ID,
}
@@ -587,8 +588,8 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
if formPayload["sign_type"] != "MD5" {
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
}
if formPayload["type"] != "alipay" {
t.Fatalf("type = %q, want alipay", formPayload["type"])
if formPayload["type"] != "usdt.tron" {
t.Fatalf("type = %q, want usdt.tron", formPayload["type"])
}
if formPayload["trade_status"] != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
@@ -629,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) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
+19 -30
View File
@@ -290,9 +290,9 @@ func TestAdminChangePassword(t *testing.T) {
}
}
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
// and hash-based "password changed" detection flow.
func TestAdminInitPasswordFlow(t *testing.T) {
// TestAdminInitialPasswordMetadataFlow verifies that the public plaintext route
// is gone while the hash-based default-password detection flow still works.
func TestAdminInitialPasswordMetadataFlow(t *testing.T) {
e := setupTestEnv(t)
const initPassword = "init-pass-123456"
@@ -320,14 +320,11 @@ func TestAdminInitPasswordFlow(t *testing.T) {
t.Fatalf("expected password_changed=false before change, got true")
}
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{})
if fetchData["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
token := adminLogin(t, e, testAdminUsername, initPassword)
recFetch := doGetAdmin(e, "/admin/api/v1/auth/init-password", token)
if recFetch.Code != http.StatusNotFound {
t.Fatalf("expected removed init-password route to return 404, got %d body=%s", recFetch.Code, recFetch.Body.String())
}
var plaintextRows int64
if err := dao.Mdb.Unscoped().
@@ -336,27 +333,10 @@ func TestAdminInitPasswordFlow(t *testing.T) {
Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows: %v", err)
}
if plaintextRows != 0 {
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows)
if plaintextRows != 1 {
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)
respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{})
@@ -376,6 +356,15 @@ func TestAdminInitPasswordFlow(t *testing.T) {
if got, _ := hashData2["password_changed"].(bool); !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)
respMe2 := assertOK(t, recMe2)
+51 -6
View File
@@ -15,12 +15,43 @@ import (
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
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 路由注册
func RegisterRoute(e *echo.Echo) {
e.POST("/", func(c echo.Context) error {
@@ -37,6 +68,7 @@ func RegisterRoute(e *echo.Echo) {
})
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.POST("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
@@ -129,16 +161,29 @@ func RegisterRoute(e *echo.Echo) {
money := getString(params, "money")
name := getString(params, "name")
epayType := strings.TrimSpace(getString(params, "type"))
notifyURL := getString(params, "notify_url")
outTradeNo := getString(params, "out_trade_no")
returnURL := getString(params, "return_url")
token := strings.TrimSpace(getString(params, "token"))
if token == "" {
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
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 network == "" {
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
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 == "" {
@@ -167,6 +212,7 @@ func RegisterRoute(e *echo.Echo) {
}
ctx.Set("request_body", body)
ctx.Set(comm.EPayTypeContextKey, epayType)
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
@@ -217,7 +263,6 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated
+587
View File
@@ -224,6 +224,37 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
return rec
}
func mustCreateEPayOrder(t *testing.T, e *echo.Echo, orderID string, returnURL string, extra url.Values) string {
t.Helper()
values := url.Values{
"pid": {"1"},
"name": {orderID},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {orderID},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {returnURL},
}
for key, items := range extra {
clone := append([]string(nil), items...)
values[key] = clone
}
values = signEpayValues(values)
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("create epay order failed: %d %s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "/pay/checkout-counter/") {
t.Fatalf("expected checkout redirect, got %q", location)
}
return strings.TrimPrefix(location, "/pay/checkout-counter/")
}
func TestRootPostRoute(t *testing.T) {
e := setupTestEnv(t)
@@ -1241,10 +1272,19 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("epay payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
if order.EpayType != "alipay" {
t.Fatalf("epay_type = %q, want alipay", order.EpayType)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" {
t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"])
}
if got, _ := checkoutData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("epay checkout redirect_url = %q, want internal return route", got)
}
if order.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", order.RedirectUrl)
}
}
func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) {
@@ -1290,6 +1330,100 @@ func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) {
}
}
func TestEpaySubmitPhpTypeSelectorOverridesRequestAndDefaults(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdc", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultCurrency, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_currency: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-type-selector-001"},
"type": {"usdt.tron"},
"money": {"1.00"},
"out_trade_no": {"epay-type-selector-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
"token": {"usdc"},
"network": {"solana"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay selector order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.Currency != "CNY" {
t.Fatalf("currency = %q, want CNY", order.Currency)
}
if order.EpayType != "usdt.tron" {
t.Fatalf("epay_type = %q, want usdt.tron", order.EpayType)
}
}
func TestEpaySubmitPhpTypeSelectorUsesDefaultCurrencyWhenMissing(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdc", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultCurrency, "usd", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_currency: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupRate, "rate.forced_rate_list", `{"cny":{"usdt":0.14285714285714285},"usd":{"usdt":1}}`, mdb.SettingTypeJSON); err != nil {
t.Fatalf("seed rate.forced_rate_list for usd: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-type-default-cur-001"},
"type": {"usdt.tron"},
"money": {"1.00"},
"out_trade_no": {"epay-type-default-cur-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
"token": {"usdc"},
"network": {"solana"},
})
rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay selector order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.Currency != "USD" {
t.Fatalf("currency = %q, want USD", order.Currency)
}
}
func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T) {
e := setupTestEnv(t)
@@ -1328,12 +1462,120 @@ func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
if order.EpayType != "alipay" {
t.Fatalf("epay_type = %q, want alipay", order.EpayType)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect {
t.Fatalf("checkout placeholder data = %#v", checkoutData)
}
}
func TestEpaySubmitPhpWithoutTypeKeepsCurrentBehavior(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-empty-type-001"},
"money": {"1.00"},
"out_trade_no": {"epay-empty-type-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay empty-type order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("empty-type order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.EpayType != "" {
t.Fatalf("epay_type = %q, want empty", order.EpayType)
}
}
func TestEpaySubmitPhpRejectsNonSelectorType(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-non-selector-001"},
"type": {"usdt-tron"},
"money": {"1.00"},
"out_trade_no": {"epay-non-selector-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009", got)
}
}
func TestEpaySubmitPhpRejectsUnsupportedSelectorType(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-unsupported-selector-001"},
"type": {"usdc.tron"},
"money": {"1.00"},
"out_trade_no": {"epay-unsupported-selector-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009", got)
}
}
func TestEpaySubmitPhpRejectsPartialResolvedTokenNetwork(t *testing.T) {
e := setupTestEnv(t)
@@ -1391,6 +1633,40 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
}
}
func TestEpaySubmitPhpTypeSelectorPostFormCompatible(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-post-selector-001"},
"type": {"usdt.tron"},
"money": {"1.00"},
"out_trade_no": {"epay-post-selector-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay post selector order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("post selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.EpayType != "usdt.tron" {
t.Fatalf("epay_type = %q, want usdt.tron", order.EpayType)
}
}
// TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a
// graceful JSON error (not 500) when the trade_id doesn't exist.
func TestCheckStatus_NotFound(t *testing.T) {
@@ -1526,6 +1802,261 @@ func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
}
}
func TestPayReturn_NotFound(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/return/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayRedirectsToMerchantWithSignedParams(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-paid-001", "https://merchant.example/return?from=merchant", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("cache-control = %q, want no-store", got)
}
location := rec.Header().Get("Location")
targetURL, err := url.Parse(location)
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if targetURL.Scheme != "https" || targetURL.Host != "merchant.example" || targetURL.Path != "/return" {
t.Fatalf("unexpected redirect target: %q", location)
}
query := targetURL.Query()
if query.Get("from") != "merchant" {
t.Fatalf("preserved query = %q, want merchant", query.Get("from"))
}
if query.Get("pid") != "1" {
t.Fatalf("pid = %q, want 1", query.Get("pid"))
}
if query.Get("trade_no") != tradeID {
t.Fatalf("trade_no = %q, want %q", query.Get("trade_no"), tradeID)
}
if query.Get("out_trade_no") != "epay-return-paid-001" {
t.Fatalf("out_trade_no = %q", query.Get("out_trade_no"))
}
if query.Get("type") != "alipay" {
t.Fatalf("type = %q, want alipay", query.Get("type"))
}
if query.Get("name") != "epay-return-paid-001" {
t.Fatalf("name = %q", query.Get("name"))
}
if query.Get("money") != "1.0000" {
t.Fatalf("money = %q, want 1.0000", query.Get("money"))
}
if query.Get("trade_status") != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", query.Get("trade_status"))
}
if query.Get("sign_type") != "MD5" {
t.Fatalf("sign_type = %q, want MD5", query.Get("sign_type"))
}
signParams := map[string]interface{}{
"pid": query.Get("pid"),
"trade_no": query.Get("trade_no"),
"out_trade_no": query.Get("out_trade_no"),
"type": query.Get("type"),
"name": query.Get("name"),
"money": query.Get("money"),
"trade_status": query.Get("trade_status"),
}
calcSig, err := sign.Get(signParams, testAPIToken)
if err != nil {
t.Fatalf("calc epay return signature: %v", err)
}
if got := query.Get("sign"); got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig)
}
}
func TestPayReturn_PaidEpayRedirectsWithOriginalType(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-type-001", "https://merchant.example/return", url.Values{
"type": {"usdt.tron"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
targetURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if got := targetURL.Query().Get("type"); got != "usdt.tron" {
t.Fatalf("type = %q, want usdt.tron", got)
}
}
func TestPayReturn_UnpaidEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/pay/checkout-counter/"+tradeID {
t.Fatalf("location = %q, want checkout counter", got)
}
}
func TestPayReturn_PaidNonEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "pay-return-gmpay-001")
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/pay/checkout-counter/"+tradeID {
t.Fatalf("location = %q, want checkout counter", got)
}
}
func TestPayReturn_PaidEpayWithEmptyRedirectReturnsError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-empty-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Updates(map[string]interface{}{
"status": mdb.StatusPaySuccess,
"redirect_url": "",
}).Error; err != nil {
t.Fatalf("update order: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10044 {
t.Fatalf("status_code = %d, want 10044; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayWithDisabledApiKeyReturnsError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-disabled-key-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
if err := dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", order.ApiKeyID).
Update("status", mdb.ApiKeyStatusDisable).Error; err != nil {
t.Fatalf("disable api key: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10045 {
t.Fatalf("status_code = %d, want 10045; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayWithNonNumericPidReturnsSignatureError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-bad-pid-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
if err := dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", order.ApiKeyID).
Update("pid", "not-a-number").Error; err != nil {
t.Fatalf("break pid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10046 {
t.Fatalf("status_code = %d, want 10046; response=%v", got, resp)
}
}
func TestSubmitTxHash_VerificationFailureAllowsRetrySameHash(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-retry-001")
@@ -2225,6 +2756,9 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
if got, _ := respData["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
if got, _ := respData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("switch redirect_url = %q, want internal return route", got)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
@@ -2233,6 +2767,12 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
if parent.Status != mdb.StatusWaitPay || parent.PaymentType != mdb.PaymentTypeEpay || parent.Network != mdb.NetworkTron || parent.Token != "USDT" || parent.ReceiveAddress == "" {
t.Fatalf("parent fields = status %d payment_type %q network %q token %q address %q", parent.Status, parent.PaymentType, parent.Network, parent.Token, parent.ReceiveAddress)
}
if parent.EpayType != "alipay" {
t.Fatalf("parent epay_type = %q, want alipay", parent.EpayType)
}
if parent.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
@@ -2315,6 +2855,9 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
if respData["payment_url"] != "https://pay.okaypay.test/epay-placeholder" {
t.Fatalf("payment_url = %v, want okpay url", respData["payment_url"])
}
if got, _ := respData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("switch redirect_url = %q, want internal return route", got)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
@@ -2323,6 +2866,9 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
if parent.PaymentType != mdb.PaymentTypeEpay || parent.PayProvider != mdb.PaymentProviderOkPay || parent.Network != mdb.PaymentProviderOkPay || parent.ReceiveAddress != "OKPAY" {
t.Fatalf("parent fields = payment_type %q provider %q network %q address %q", parent.PaymentType, parent.PayProvider, parent.Network, parent.ReceiveAddress)
}
if parent.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
@@ -2339,6 +2885,47 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
}
}
func TestSwitchNetwork_EpayChildInheritsOriginalType(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-switch-child-type-001", "http://localhost/return", url.Values{
"type": {"usdt.tron"},
})
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "solana",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch epay parent to solana failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, _ := resp["data"].(map[string]interface{})
subTradeID, _ := respData["trade_id"].(string)
if subTradeID == "" || subTradeID == tradeID {
t.Fatalf("expected child trade_id, got %q", subTradeID)
}
subOrder, err := data.GetOrderInfoByTradeId(subTradeID)
if err != nil {
t.Fatalf("load sub-order: %v", err)
}
if subOrder.EpayType != "usdt.tron" {
t.Fatalf("sub-order epay_type = %q, want usdt.tron", subOrder.EpayType)
}
apiKeyRow, err := data.GetApiKeyByID(subOrder.ApiKeyID)
if err != nil {
t.Fatalf("load api key: %v", err)
}
params, err := service.BuildEPayResultParams(subOrder, apiKeyRow)
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
if params["type"] != "usdt.tron" {
t.Fatalf("callback type = %q, want usdt.tron", params["type"])
}
}
func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
if shopID == "" {
+1
View File
@@ -15,6 +15,7 @@ func Start() {
go StartPlasmaWebSocketListener()
go StartTronBlockScannerListener()
go StartTonBlockScannerListener()
go StartAptosLedgerScannerListener()
c := cron.New()
+255
View File
@@ -0,0 +1,255 @@
package task
import (
"context"
"fmt"
"sync"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
)
const (
aptosLedgerChunkSize int64 = 100
aptosLedgerWorkerCount = 3
aptosLedgerQueueLimit = 100
)
type aptosRuntimeCursor struct {
initialized bool
lastSeenVersion int64
}
type aptosLedgerRange struct {
start int64
limit int64
state moveWatchState
}
type aptosChainProvider interface {
LatestLedgerVersion() (int64, error)
Transactions(start int64, limit int64) ([]byte, error)
}
type serviceAptosProvider struct{}
func (serviceAptosProvider) LatestLedgerVersion() (int64, error) {
return service.AptosGetLedgerVersion()
}
func (serviceAptosProvider) Transactions(start int64, limit int64) ([]byte, error) {
return service.AptosGetTransactions(start, limit)
}
var (
parseAptosTransfersForWallets = service.ParseAptosTransfersForWallets
processAptosObservedTransferFunc = service.ProcessMoveObservedTransferResult
)
func StartAptosLedgerScannerListener() {
provider := serviceAptosProvider{}
cursor := &aptosRuntimeCursor{}
log.Sugar.Infof("[APTOS] ledger scanner starting mode=full_ledger chunk_size=%d workers=%d queue_limit=%d",
aptosLedgerChunkSize,
aptosLedgerWorkerCount,
aptosLedgerQueueLimit,
)
for {
if err := runAptosLedgerScanner(context.Background(), provider, cursor); err != nil {
log.Sugar.Warnf("[APTOS] scanner stopped: %v", err)
sleepOrDone(context.Background(), moveScannerRetryDelay)
continue
}
}
}
func runAptosLedgerScanner(ctx context.Context, provider aptosChainProvider, cursor *aptosRuntimeCursor) error {
if cursor == nil {
cursor = &aptosRuntimeCursor{}
}
watchSignature := ""
for {
chain, interval := moveChainConfig(mdb.NetworkAptos)
if chain == nil || !chain.Enabled {
log.Sugar.Debug("[APTOS] chain disabled or not configured, idling")
if !sleepOrDone(ctx, interval) {
return nil
}
continue
}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
return err
}
if len(state.wallets) == 0 || len(state.tokens) == 0 {
log.Sugar.Debug("[APTOS] no enabled wallets or tokens, idling")
if !sleepOrDone(ctx, interval) {
return nil
}
continue
}
if sig := moveWatchStateSignature(state); sig != watchSignature {
watchSignature = sig
log.Sugar.Infof("[APTOS] ledger scanner watching wallets=%d tokens=%d", len(state.wallets), len(state.tokens))
}
latest, err := provider.LatestLedgerVersion()
if err != nil {
return fmt.Errorf("get latest ledger version: %w", err)
}
data.RecordRpcBlockHeight(mdb.NetworkAptos, latest)
confirmed := moveConfirmedCursor(latest, chain.MinConfirmations)
if !cursor.initialized {
cursor.initialized = true
cursor.lastSeenVersion = confirmed - aptosLedgerChunkSize
if cursor.lastSeenVersion < -1 {
cursor.lastSeenVersion = -1
}
log.Sugar.Infof(
"[APTOS] initialized ledger cursor at version=%d confirmed_head=%d chunk_size=%d mode=full_ledger_watch",
cursor.lastSeenVersion,
confirmed,
aptosLedgerChunkSize,
)
}
catchup, err := processAptosLedgerRound(ctx, provider, state, cursor, confirmed)
if err != nil {
return err
}
if !sleepOrDone(ctx, chooseAptosIdleDelay(catchup, interval)) {
return nil
}
}
}
func processAptosLedgerRound(ctx context.Context, provider aptosChainProvider, state moveWatchState, cursor *aptosRuntimeCursor, confirmedVersion int64) (bool, error) {
ranges := buildAptosLedgerRanges(cursor.lastSeenVersion, confirmedVersion, state)
if len(ranges) == 0 {
return false, nil
}
lastRange := ranges[len(ranges)-1]
log.Sugar.Infof(
"[APTOS] queued ledger ranges count=%d from=%d to=%d cursor=%d confirmed_head=%d workers=%d",
len(ranges),
ranges[0].start,
lastRange.start+lastRange.limit-1,
cursor.lastSeenVersion,
confirmedVersion,
aptosLedgerWorkerCount,
)
type rangeResult struct {
item aptosLedgerRange
err error
}
jobs := make(chan aptosLedgerRange, len(ranges))
results := make(chan rangeResult, len(ranges))
workers := aptosLedgerWorkerCount
if len(ranges) < workers {
workers = len(ranges)
}
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
results <- rangeResult{item: item, err: processAptosLedgerRange(ctx, provider, item, confirmedVersion)}
}
}()
}
for _, item := range ranges {
jobs <- item
}
close(jobs)
wg.Wait()
close(results)
errByStart := make(map[int64]error, len(ranges))
for result := range results {
if result.err != nil {
errByStart[result.item.start] = result.err
}
}
for _, item := range ranges {
if err := errByStart[item.start]; err != nil {
return cursor.lastSeenVersion < confirmedVersion, err
}
cursor.lastSeenVersion = item.start + item.limit - 1
}
return cursor.lastSeenVersion < confirmedVersion, nil
}
func buildAptosLedgerRanges(lastSeenVersion int64, confirmedVersion int64, state moveWatchState) []aptosLedgerRange {
if confirmedVersion <= lastSeenVersion {
return nil
}
ranges := make([]aptosLedgerRange, 0, aptosLedgerQueueLimit)
next := lastSeenVersion + 1
if next < 0 {
next = 0
}
for next <= confirmedVersion && len(ranges) < aptosLedgerQueueLimit {
limit := confirmedVersion - next + 1
if limit > aptosLedgerChunkSize {
limit = aptosLedgerChunkSize
}
ranges = append(ranges, aptosLedgerRange{start: next, limit: limit, state: state})
next += limit
}
return ranges
}
func processAptosLedgerRange(ctx context.Context, provider aptosChainProvider, item aptosLedgerRange, confirmedVersion int64) error {
if err := ctx.Err(); err != nil {
return err
}
body, err := provider.Transactions(item.start, item.limit)
if err != nil {
return fmt.Errorf("get transactions start=%d limit=%d: %w", item.start, item.limit, err)
}
transfers, err := parseAptosTransfersForWallets(body, item.state.wallets, item.state.tokens)
if err != nil {
return fmt.Errorf("parse transactions start=%d limit=%d: %w", item.start, item.limit, err)
}
for _, transfer := range transfers {
log.Sugar.Infof(
"[APTOS] observed transfer version=%d tx=%s token=%s amount=%.8f raw=%s decimals=%d receive=%s key=%s",
transfer.Version,
transfer.TxID,
transfer.Token,
transfer.Amount,
transfer.RawAmount.String(),
transfer.Decimals,
transfer.ReceiveAddress,
transfer.TransferKey,
)
if err = processAptosObservedTransferFunc(transfer); err != nil {
return err
}
}
if len(transfers) > 0 {
log.Sugar.Infof(
"[APTOS] ledger range processed start=%d limit=%d to=%d transfers=%d confirmed_head=%d",
item.start,
item.limit,
item.start+item.limit-1,
len(transfers),
confirmedVersion,
)
}
return nil
}
func chooseAptosIdleDelay(catchup bool, interval time.Duration) time.Duration {
if catchup {
return moveCatchupYield
}
return interval
}
+440
View File
@@ -0,0 +1,440 @@
package task
import (
"context"
"errors"
"fmt"
"sort"
"sync"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
addressutil "github.com/GMWalletApp/epusdt/util/address"
)
type fakeAptosProvider struct {
mu sync.Mutex
latest int64
bodiesByStart map[int64][]byte
errByStart map[int64]error
calls []aptosRangeCall
}
type aptosRangeCall struct {
start int64
limit int64
}
func (f *fakeAptosProvider) LatestLedgerVersion() (int64, error) {
return f.latest, nil
}
func (f *fakeAptosProvider) Transactions(start int64, limit int64) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, aptosRangeCall{start: start, limit: limit})
if err := f.errByStart[start]; err != nil {
return nil, err
}
if body := f.bodiesByStart[start]; len(body) > 0 {
return append([]byte(nil), body...), nil
}
return []byte("[]"), nil
}
func (f *fakeAptosProvider) rangeCalls() []aptosRangeCall {
f.mu.Lock()
defer f.mu.Unlock()
calls := append([]aptosRangeCall(nil), f.calls...)
sort.Slice(calls, func(i, j int) bool {
if calls[i].start == calls[j].start {
return calls[i].limit < calls[j].limit
}
return calls[i].start < calls[j].start
})
return calls
}
func resetAptosScannerHooks(t *testing.T) {
t.Helper()
oldParse := parseAptosTransfersForWallets
oldProcess := processAptosObservedTransferFunc
t.Cleanup(func() {
parseAptosTransfersForWallets = oldParse
processAptosObservedTransferFunc = oldProcess
})
}
func seedAptosScannerChain(t *testing.T, wallets ...string) []string {
t.Helper()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1, ScanIntervalSec: 1}).Error; err != nil {
t.Fatalf("seed Aptos chain: %v", err)
}
if err := dao.Mdb.Create(&[]mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true},
}).Error; err != nil {
t.Fatalf("seed Aptos tokens: %v", err)
}
if len(wallets) == 0 {
wallets = []string{"0xa"}
}
out := make([]string, 0, len(wallets))
for _, wallet := range wallets {
receive, err := addressutil.NormalizeMoveAddress(wallet)
if err != nil {
t.Fatalf("normalize Aptos address %q: %v", wallet, err)
}
if _, err = data.AddWalletAddressWithNetwork(mdb.NetworkAptos, receive); err != nil {
t.Fatalf("seed Aptos wallet %s: %v", receive, err)
}
out = append(out, receive)
}
return out
}
func TestProcessAptosLedgerRangePassesConfiguredTokens(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
var gotSymbols []string
parseAptosTransfersForWallets = func(_ []byte, _ map[string]struct{}, tokens []mdb.ChainToken) ([]service.MoveObservedTransfer, error) {
for _, token := range tokens {
gotSymbols = append(gotSymbols, token.Symbol)
}
sort.Strings(gotSymbols)
return nil, nil
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}}
err = processAptosLedgerRange(context.Background(), provider, aptosLedgerRange{start: 100, limit: 1, state: state}, 100)
if err != nil {
t.Fatalf("processAptosLedgerRange(): %v", err)
}
want := []string{"MOVEUSD", "USDC", "USDT"}
if len(gotSymbols) != len(want) {
t.Fatalf("tokens = %#v, want %#v", gotSymbols, want)
}
for i := range want {
if gotSymbols[i] != want[i] {
t.Fatalf("tokens = %#v, want %#v", gotSymbols, want)
}
}
}
func TestRunAptosLedgerScannerIdlesWhenChainDisabled(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: false, MinConfirmations: 1, ScanIntervalSec: 1}).Error; err != nil {
t.Fatalf("seed disabled Aptos chain: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
provider := &fakeAptosProvider{latest: 999}
cursor := &aptosRuntimeCursor{}
if err := runAptosLedgerScanner(ctx, provider, cursor); err != nil {
t.Fatalf("runAptosLedgerScanner(): %v", err)
}
if cursor.initialized {
t.Fatalf("cursor initialized while chain disabled: %#v", cursor)
}
if calls := provider.rangeCalls(); len(calls) != 0 {
t.Fatalf("range calls = %#v, want none", calls)
}
}
func TestRunAptosLedgerScannerInitializesAtTrailingConfirmedChunk(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
provider := &fakeAptosProvider{latest: 999}
cursor := &aptosRuntimeCursor{}
if err := runAptosLedgerScanner(ctx, provider, cursor); err != nil {
t.Fatalf("runAptosLedgerScanner(): %v", err)
}
if !cursor.initialized || cursor.lastSeenVersion != 999 {
t.Fatalf("cursor = %#v, want initialized and advanced to confirmed head 999", cursor)
}
calls := provider.rangeCalls()
if len(calls) != 1 || calls[0].start != 900 || calls[0].limit != 100 {
t.Fatalf("range calls = %#v, want start=900 limit=100", calls)
}
}
func TestProcessAptosLedgerRoundDoesNotAdvanceUnconfirmedVersion(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
provider := &fakeAptosProvider{latest: 100}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 99)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 99 {
t.Fatalf("lastSeenVersion = %d, want 99", cursor.lastSeenVersion)
}
if calls := provider.rangeCalls(); len(calls) != 0 {
t.Fatalf("range calls = %#v, want none", calls)
}
}
func TestProcessAptosLedgerRoundAdvancesAfterSuccessfulRange(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 250)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 250 {
t.Fatalf("lastSeenVersion = %d, want 250", cursor.lastSeenVersion)
}
calls := provider.rangeCalls()
if len(calls) != 2 || calls[0].start != 100 || calls[0].limit != 100 || calls[1].start != 200 || calls[1].limit != 51 {
t.Fatalf("range calls = %#v, want [100/100 200/51]", calls)
}
}
func TestProcessAptosLedgerRoundDoesNotAdvanceOnRangeFailure(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{errByStart: map[int64]error{100: errors.New("temporary rpc failure")}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 150); err == nil {
t.Fatal("processAptosLedgerRound() error = nil, want error")
}
if cursor.lastSeenVersion != 99 {
t.Fatalf("lastSeenVersion = %d, want 99", cursor.lastSeenVersion)
}
}
func TestProcessAptosLedgerRoundProcessesMultipleChunks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
seedAptosScannerChain(t, "0xa")
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{
bodiesByStart: map[int64][]byte{
100: []byte("[]"),
200: []byte("[]"),
},
}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99}
catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 250)
if err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
if catchup {
t.Fatal("catchup = true, want false")
}
if cursor.lastSeenVersion != 250 {
t.Fatalf("lastSeenVersion = %d, want 250", cursor.lastSeenVersion)
}
calls := provider.rangeCalls()
if len(calls) != 2 || calls[0].start != 100 || calls[0].limit != 100 || calls[1].start != 200 || calls[1].limit != 51 {
t.Fatalf("range calls = %#v, want [100/100 200/51]", calls)
}
}
func TestBuildAptosLedgerRangesStopsAtQueueLimit(t *testing.T) {
state := moveWatchState{}
ranges := buildAptosLedgerRanges(0, aptosLedgerChunkSize*aptosLedgerQueueLimit+500, state)
if len(ranges) != aptosLedgerQueueLimit {
t.Fatalf("ranges len = %d, want %d", len(ranges), aptosLedgerQueueLimit)
}
first := ranges[0]
last := ranges[len(ranges)-1]
if first.start != 1 || first.limit != aptosLedgerChunkSize {
t.Fatalf("first range = %#v, want start=1 limit=%d", first, aptosLedgerChunkSize)
}
if last.start != 1+aptosLedgerChunkSize*int64(aptosLedgerQueueLimit-1) || last.limit != aptosLedgerChunkSize {
t.Fatalf("last range = %#v", last)
}
}
func TestProcessAptosLedgerRoundMarksMatchingUSDTOrderPaid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
wallets := seedAptosScannerChain(t, "0xa")
receive := wallets[0]
amount := 3.1
tradeID := "aptos_trade_1"
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
body := aptosFungibleTransferBody(t, "0xabc", 101, receive, usdt, "3100000")
order := &mdb.Orders{
TradeId: tradeID,
OrderId: "aptos_order_1",
Amount: amount,
ActualAmount: amount,
ReceiveAddress: receive,
Token: "USDT",
Network: mdb.NetworkAptos,
Status: mdb.StatusWaitPay,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkAptos, receive, "USDT", tradeID, amount, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{101: body}}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 101); err != nil {
t.Fatalf("processAptosLedgerRound(): %v", err)
}
paid, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if paid.Status != mdb.StatusPaySuccess || paid.CallBackConfirm != mdb.CallBackConfirmNo || paid.BlockTransactionId != "0xabc" {
t.Fatalf("paid order = %#v", paid)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, receive, "USDT", amount)
if err != nil {
t.Fatalf("lookup lock: %v", err)
}
if lockTradeID != "" {
t.Fatalf("transaction lock still exists: %s", lockTradeID)
}
}
func TestProcessAptosLedgerRoundDoesNotReprocessDuplicateTransferKey(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
resetAptosScannerHooks(t)
wallets := seedAptosScannerChain(t, "0xa")
receive := wallets[0]
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
state, err := loadMoveWatchState(mdb.NetworkAptos)
if err != nil {
t.Fatalf("load state: %v", err)
}
provider := &fakeAptosProvider{
bodiesByStart: map[int64][]byte{
101: aptosFungibleTransferBody(t, "0xabc", 101, receive, usdc, "1200000"),
102: aptosFungibleTransferBody(t, "0xabc", 101, receive, usdc, "1200000"),
},
}
var processed int
processAptosObservedTransferFunc = func(transfer service.MoveObservedTransfer) error {
processed++
return service.ProcessMoveObservedTransferResult(transfer)
}
cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 101); err != nil {
t.Fatalf("first processAptosLedgerRound(): %v", err)
}
if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 102); err != nil {
t.Fatalf("second processAptosLedgerRound(): %v", err)
}
if processed != 2 {
t.Fatalf("scanner processed callback count = %d, want 2", processed)
}
if cursor.lastSeenVersion != 102 {
t.Fatalf("lastSeenVersion = %d, want 102", cursor.lastSeenVersion)
}
}
func aptosFungibleTransferBody(t *testing.T, hash string, version int64, receive string, metadata string, rawAmount string) []byte {
t.Helper()
store, err := addressutil.NormalizeMoveAddress(fmt.Sprintf("0x%x", version+1000))
if err != nil {
t.Fatalf("normalize store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress(fmt.Sprintf("0x%x", version+2000))
if err != nil {
t.Fatalf("normalize sender store: %v", err)
}
return []byte(`[
{
"type":"user_transaction",
"success":true,
"hash":"` + hash + `",
"version":"` + fmt.Sprintf("%d", version) + `",
"timestamp":"` + fmt.Sprintf("%d", time.Now().UnixMicro()) + `",
"events":[
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"` + rawAmount + `","store":"` + senderStore + `"}},
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"` + rawAmount + `","store":"` + store + `"}}
],
"changes":[
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + metadata + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + metadata + `"}}},"type":"write_resource"},
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
]
}
]`)
}
+77
View File
@@ -0,0 +1,77 @@
package task
import (
"fmt"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/log"
)
const (
moveScannerRetryDelay = 3 * time.Second
moveCatchupYield = 250 * time.Millisecond
)
type moveWatchState struct {
wallets map[string]struct{}
tokens []mdb.ChainToken
}
func moveChainConfig(network string) (*mdb.Chain, time.Duration) {
row, err := data.GetChainByNetwork(network)
if err != nil || row == nil || row.ID == 0 {
if err != nil {
log.Sugar.Warnf("[%s] load chain config failed: %v", strings.ToUpper(network), err)
}
return nil, 5 * time.Second
}
interval := time.Duration(row.ScanIntervalSec) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
return row, interval
}
func loadMoveWatchState(network string) (moveWatchState, error) {
rows, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return moveWatchState{}, err
}
state := moveWatchState{wallets: make(map[string]struct{})}
for _, row := range rows {
address, err := addressutil.NormalizeMoveAddress(row.Address)
if err != nil {
log.Sugar.Warnf("[%s] skip invalid wallet address=%s err=%v", strings.ToUpper(network), row.Address, err)
continue
}
state.wallets[address] = struct{}{}
}
tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
return moveWatchState{}, err
}
state.tokens = tokens
return state, nil
}
func moveWatchStateSignature(state moveWatchState) string {
return fmt.Sprintf("wallets=%d/tokens=%d", len(state.wallets), len(state.tokens))
}
func moveConfirmedCursor(latest int64, minConfirmations int) int64 {
if latest <= 0 {
return 0
}
if minConfirmations <= 1 {
return latest
}
confirmed := latest - int64(minConfirmations) + 1
if confirmed < 0 {
return 0
}
return confirmed
}
+67
View File
@@ -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)
}
+42
View File
@@ -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)
}
}
+6
View File
@@ -47,6 +47,9 @@ var Errno = map[int]string{
10041: "invalid notify url",
10042: "payment provider order creation failed",
10043: "invalid setting item",
10044: "invalid order redirect url",
10045: "order api key unavailable",
10046: "failed to build epay return signature",
}
var (
@@ -94,6 +97,9 @@ var (
NotifyURLErr = Err(10041)
PaymentProviderCreateErr = Err(10042)
SettingItemErr = Err(10043)
OrderRedirectURLErr = Err(10044)
OrderApiKeyUnavailableErr = Err(10045)
EPayReturnSignatureErr = Err(10046)
)
type RspError struct {
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-CASnS-6J.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./unauthorized-error-TaUhj35c.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./forbidden-peSSCojP.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./forbidden-BCyOYrFV.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./not-found-error-CTygh2Q3.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./not-found-error-D4F_Uu-C.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./general-error-CDe5w_m4.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./general-error-Drsf0vjz.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./maintenance-error-lgcDljP-.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./maintenance-error-C9XjssOV.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{Zp as n}from"./messages-BLwDcEAr.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-CWlB586P.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-CeXMJwDZ.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-CL4J6BaJ.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-DHd0jlDY.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-onTP_fNL.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
View File
@@ -1 +0,0 @@
import{Zp as e}from"./messages-BLwDcEAr.js";import{m as t}from"./search-provider-B67mlhed.js";import{n,t as r}from"./theme-switch-B3KcLEky.js";import{t as i}from"./general-error-CDe5w_m4.js";import{t as a}from"./not-found-error-CTygh2Q3.js";import{t as o}from"./_error-wx-ubgoM.js";import{t as s}from"./unauthorized-error-CASnS-6J.js";import{t as c}from"./forbidden-peSSCojP.js";import{t as l}from"./maintenance-error-C9XjssOV.js";import{n as u,r as d,t as f}from"./header-NBnXYgM_.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
View File
@@ -0,0 +1 @@
import{tm as e}from"./messages-CL4J6BaJ.js";import{m as t}from"./search-provider-CTRbHqNx.js";import{n,t as r}from"./theme-switch-CdEcHkcU.js";import{t as i}from"./general-error-Drsf0vjz.js";import{t as a}from"./not-found-error-D4F_Uu-C.js";import{t as o}from"./_error-DypydLD1.js";import{t as s}from"./unauthorized-error-TaUhj35c.js";import{t as c}from"./forbidden-BCyOYrFV.js";import{t as l}from"./maintenance-error-lgcDljP-.js";import{n as u,r as d,t as f}from"./header-vUJd_CA5.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};
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-D2USOC7M.js","assets/search-provider-CTRbHqNx.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-DmeeHi7K.js","assets/messages-CL4J6BaJ.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-Crc1Jrzv.js","assets/button-NLSeBFht.js","assets/clsx-D9aGYY3V.js","assets/dist-DPYswSIO.js","assets/dist-esC74fSg.js","assets/dist-SR6sl-WU.js","assets/dist-D3WFT-Yo.js","assets/es2015-3J9GnV9P.js","assets/dist-BZMqLvO-.js","assets/dist-CGRahgI2.js","assets/dist-DA1qWiix.js","assets/dist-DhcQhr4B.js","assets/dist-UaPzzPYf.js","assets/dist-BixeH3nX.js","assets/dist-B0pRVbY9.js","assets/separator-CqhQIQ-B.js","assets/tooltip-QxnX5RKP.js","assets/dist-DB-jLX48.js","assets/dist-CjidLXaw.js","assets/auth-store-8ocF_FHU.js","assets/dist-Dd-sCJIt.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-vy8966UO.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-CtyiwzAl.js","assets/skeleton-B4TLI5_E.js","assets/wallet-Cdhl16hF.js","assets/sun-JU8p4FoE.js","assets/shield-check-MAPDL1u8.js","assets/logo-CBibDHP9.js","assets/input-DAqep8ZY.js","assets/font-provider-C4_iupSb.js","assets/theme-provider-ixHkEVGI.js","assets/theme-switch-CdEcHkcU.js","assets/dropdown-menu-D72UcvWG.js","assets/check-CHp0nSkR.js","assets/header-vUJd_CA5.js","assets/link-6i3yGmK_.js","assets/ClientOnly-DHd0jlDY.js","assets/forbidden-BCyOYrFV.js","assets/general-error-Drsf0vjz.js","assets/maintenance-error-lgcDljP-.js","assets/not-found-error-D4F_Uu-C.js","assets/unauthorized-error-TaUhj35c.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CleTUoKA.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-D2USOC7M.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};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-CvIS-wQi.js","assets/search-provider-B67mlhed.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-D343v8gR.js","assets/messages-BLwDcEAr.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-GGoKuiu9.js","assets/button-C0SPdqvs.js","assets/clsx-D9aGYY3V.js","assets/dist-BEGLjtzw.js","assets/dist-Cd7-TPaR.js","assets/dist-BphrRiSn.js","assets/dist-XiAcd8rT.js","assets/es2015-hwcIKy2q.js","assets/dist-C9d8iJDv.js","assets/dist-CYQVWHzW.js","assets/dist-CVKHDB42.js","assets/dist-DNb3IZ7I.js","assets/dist-THZWKIyP.js","assets/dist-BixeH3nX.js","assets/dist-DJTK1B2D.js","assets/separator-Di9zEmE_.js","assets/tooltip-8Hby-rrx.js","assets/dist-B1bXEkK2.js","assets/dist-Cm0GVWLR.js","assets/auth-store-paR9oIR_.js","assets/dist-CffCYhqm.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-vg2xchuv.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-iqE0q8bN.js","assets/skeleton-rs-vmKNc.js","assets/wallet-Bn5Vi2Ko.js","assets/sun-JU8p4FoE.js","assets/shield-check-MAPDL1u8.js","assets/logo-GNJA7dBE.js","assets/input-D-qaLZKc.js","assets/font-provider-BogPqUvU.js","assets/theme-provider-Cs4UKh_6.js","assets/theme-switch-B3KcLEky.js","assets/dropdown-menu-CDbt-i_J.js","assets/check-CHp0nSkR.js","assets/header-NBnXYgM_.js","assets/link-uW89koiO.js","assets/ClientOnly-CWlB586P.js","assets/forbidden-peSSCojP.js","assets/general-error-CDe5w_m4.js","assets/maintenance-error-C9XjssOV.js","assets/not-found-error-CTygh2Q3.js","assets/unauthorized-error-CASnS-6J.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-BeVeaHRY.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-CvIS-wQi.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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-BUpVePSs.js","assets/chunk-DECur_0Z.js","assets/button-NLSeBFht.js","assets/clsx-D9aGYY3V.js","assets/messages-CL4J6BaJ.js","assets/react-CO2uhaBc.js","assets/select-D2uO5-De.js","assets/dist-DB-jLX48.js","assets/dist-DmeeHi7K.js","assets/dist-SR6sl-WU.js","assets/dist-D3WFT-Yo.js","assets/dist-B0pRVbY9.js","assets/dist-CRowTfGU.js","assets/dist-UaPzzPYf.js","assets/dist-CGRahgI2.js","assets/dist-esC74fSg.js","assets/es2015-3J9GnV9P.js","assets/dist-BixeH3nX.js","assets/dist-CjidLXaw.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-CHp0nSkR.js","assets/chevron-down-BB5h1CsX.js","assets/auth-store-8ocF_FHU.js","assets/dist-Dd-sCJIt.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-yaBPIkVa.js","assets/dist-DA1qWiix.js","assets/dist-BZMqLvO-.js","assets/dist-DhcQhr4B.js","assets/loader-circle-DpEz1fQv.js","assets/send-BNvceEpO.js","assets/triangle-alert-DB5v_9sS.js","assets/dialog-CtyiwzAl.js","assets/dist-DPYswSIO.js","assets/crypto-icon-BPgcAvNF.js","assets/labels-CQIGkPR0.js","assets/input-DAqep8ZY.js","assets/checkout-model-CQ3aqlob.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CleTUoKA.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-BUpVePSs.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};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Cd6oGTRE.js","assets/chunk-DECur_0Z.js","assets/button-C0SPdqvs.js","assets/clsx-D9aGYY3V.js","assets/messages-BLwDcEAr.js","assets/react-CO2uhaBc.js","assets/select-CNrSwHJR.js","assets/dist-B1bXEkK2.js","assets/dist-D343v8gR.js","assets/dist-BphrRiSn.js","assets/dist-XiAcd8rT.js","assets/dist-DJTK1B2D.js","assets/dist-CRowTfGU.js","assets/dist-THZWKIyP.js","assets/dist-CYQVWHzW.js","assets/dist-Cd7-TPaR.js","assets/es2015-hwcIKy2q.js","assets/dist-BixeH3nX.js","assets/dist-Cm0GVWLR.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-CHp0nSkR.js","assets/chevron-down-BB5h1CsX.js","assets/auth-store-paR9oIR_.js","assets/dist-CffCYhqm.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-D__IFVa8.js","assets/dist-CVKHDB42.js","assets/dist-C9d8iJDv.js","assets/dist-DNb3IZ7I.js","assets/loader-circle-DpEz1fQv.js","assets/send-BNvceEpO.js","assets/triangle-alert-DB5v_9sS.js","assets/dialog-iqE0q8bN.js","assets/dist-BEGLjtzw.js","assets/crypto-icon-CJd7sdrZ.js","assets/labels-BiExVADV.js","assets/input-D-qaLZKc.js","assets/checkout-model-CQ3aqlob.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-BeVeaHRY.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Cd6oGTRE.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
@@ -1 +1 @@
import{Bp as e,Cn as t,Sn as n,Zp as r,_n as i,bn as a,vn as o,xn as s,yn as c,zp as l}from"./messages-BLwDcEAr.js";import{i as u,n as d,t as f}from"./button-C0SPdqvs.js";import{n as p,r as m}from"./font-provider-BogPqUvU.js";import{n as h}from"./theme-provider-Cs4UKh_6.js";import{t as g}from"./chevron-down-BB5h1CsX.js";import{n as _,t as v}from"./radio-group-D__IFVa8.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-fulEvgfz.js";import{t as A}from"./page-header-5Y8t-L2D.js";import{t as j}from"./show-submitted-data-COdV4uz4.js";var M=r(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:n}=p(),{theme:r,setTheme:y}=h(),b={theme:r,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&n(e.font),e.theme!==r&&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:s()}),(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:t})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(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:l()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:i()})]})})}function F(){return(0,M.jsx)(A,{description:n(),title:t(),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-CL4J6BaJ.js";import{i as u,n as d,t as f}from"./button-NLSeBFht.js";import{n as p,r as m}from"./font-provider-C4_iupSb.js";import{n as h}from"./theme-provider-ixHkEVGI.js";import{t as g}from"./chevron-down-BB5h1CsX.js";import{n as _,t as v}from"./radio-group-yaBPIkVa.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-C_YekIvK.js";import{t as A}from"./page-header-B7O10aw9.js";import{t as j}from"./show-submitted-data-DwaVTW9K.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};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.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-CL4J6BaJ.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{Zp as e}from"./messages-BLwDcEAr.js";import{i as t,o as n,r}from"./button-C0SPdqvs.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-CL4J6BaJ.js";import{i as t,o as n,r}from"./button-NLSeBFht.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{Zp as e}from"./messages-BLwDcEAr.js";import{i as t}from"./button-C0SPdqvs.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-CL4J6BaJ.js";import{i as t}from"./button-NLSeBFht.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-paR9oIR_.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-8ocF_FHU.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};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.js";import{t as r}from"./dist-XiAcd8rT.js";import{i,u as a}from"./button-C0SPdqvs.js";import{a as o,r as s,t as c}from"./dist-D343v8gR.js";import{t as l}from"./dist-C9d8iJDv.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-DJTK1B2D.js";import{t as f}from"./check-CHp0nSkR.js";var p=e(t(),1),m=n(),h=`Checkbox`,[g,_]=o(h),[v,y]=g(h);function b(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:l,required:u,value:d=`on`,internal_do_not_use_render:f}=e,[g,_]=c({prop:n,defaultProp:i??!1,onChange:l,caller:h}),[y,b]=p.useState(null),[x,S]=p.useState(null),C=p.useRef(!1),w=y?!!o||!!y.closest(`form`):!0,T={checked:g,disabled:a,setChecked:_,control:y,setControl:b,name:s,form:o,value:d,hasConsumerStoppedPropagationRef:C,required:u,defaultChecked:k(i)?!1:i,isFormControl:w,bubbleInput:x,setBubbleInput:S};return(0,m.jsx)(v,{scope:t,...T,children:O(f)?f(T):r})}var x=`CheckboxTrigger`,S=p.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...i},o)=>{let{control:c,value:l,disabled:u,checked:d,required:f,setControl:h,setChecked:g,hasConsumerStoppedPropagationRef:_,isFormControl:v,bubbleInput:b}=y(x,e),S=a(o,h),C=p.useRef(d);return p.useEffect(()=>{let e=c?.form;if(e){let t=()=>g(C.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[c,g]),(0,m.jsx)(r.button,{type:`button`,role:`checkbox`,"aria-checked":k(d)?`mixed`:d,"aria-required":f,"data-state":A(d),"data-disabled":u?``:void 0,disabled:u,value:l,...i,ref:S,onKeyDown:s(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:s(n,e=>{g(e=>k(e)?!0:!e),b&&v&&(_.current=e.isPropagationStopped(),_.current||e.stopPropagation())})})});S.displayName=x;var C=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,m.jsx)(b,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(S,{...d,ref:t,__scopeCheckbox:n}),e&&(0,m.jsx)(D,{__scopeCheckbox:n})]})})});C.displayName=h;var w=`CheckboxIndicator`,T=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:i,...a}=e,o=y(w,n);return(0,m.jsx)(l,{present:i||k(o.checked)||o.checked===!0,children:(0,m.jsx)(r.span,{"data-state":A(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t,style:{pointerEvents:`none`,...e.style}})})});T.displayName=w;var E=`CheckboxBubbleInput`,D=p.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:i,hasConsumerStoppedPropagationRef:o,checked:s,defaultChecked:c,required:l,disabled:f,name:h,value:g,form:_,bubbleInput:v,setBubbleInput:b}=y(E,e),x=a(n,b),S=u(s),C=d(i);p.useEffect(()=>{let e=v;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!o.current;if(S!==s&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=k(s),n.call(e,k(s)?!1:s),e.dispatchEvent(t)}},[v,S,s,o]);let w=p.useRef(k(s)?!1:s);return(0,m.jsx)(r.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:c??w.current,required:l,disabled:f,name:h,value:g,form:_,...t,tabIndex:-1,ref:x,style:{...t.style,...C,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});D.displayName=E;function O(e){return typeof e==`function`}function k(e){return e===`indeterminate`}function A(e){return k(e)?`indeterminate`:e?`checked`:`unchecked`}function j({className:e,...t}){return(0,m.jsx)(C,{className:i(`peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40`,e),"data-slot":`checkbox`,...t,children:(0,m.jsx)(T,{className:`grid place-content-center text-current transition-none`,"data-slot":`checkbox-indicator`,children:(0,m.jsx)(f,{className:`size-3.5`})})})}export{j as t};
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{tm as n}from"./messages-CL4J6BaJ.js";import{t as r}from"./dist-D3WFT-Yo.js";import{i,u as a}from"./button-NLSeBFht.js";import{a as o,r as s,t as c}from"./dist-DmeeHi7K.js";import{t as l}from"./dist-BZMqLvO-.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-B0pRVbY9.js";import{t as f}from"./check-CHp0nSkR.js";var p=e(t(),1),m=n(),h=`Checkbox`,[g,_]=o(h),[v,y]=g(h);function b(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:l,required:u,value:d=`on`,internal_do_not_use_render:f}=e,[g,_]=c({prop:n,defaultProp:i??!1,onChange:l,caller:h}),[y,b]=p.useState(null),[x,S]=p.useState(null),C=p.useRef(!1),w=y?!!o||!!y.closest(`form`):!0,T={checked:g,disabled:a,setChecked:_,control:y,setControl:b,name:s,form:o,value:d,hasConsumerStoppedPropagationRef:C,required:u,defaultChecked:k(i)?!1:i,isFormControl:w,bubbleInput:x,setBubbleInput:S};return(0,m.jsx)(v,{scope:t,...T,children:O(f)?f(T):r})}var x=`CheckboxTrigger`,S=p.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...i},o)=>{let{control:c,value:l,disabled:u,checked:d,required:f,setControl:h,setChecked:g,hasConsumerStoppedPropagationRef:_,isFormControl:v,bubbleInput:b}=y(x,e),S=a(o,h),C=p.useRef(d);return p.useEffect(()=>{let e=c?.form;if(e){let t=()=>g(C.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[c,g]),(0,m.jsx)(r.button,{type:`button`,role:`checkbox`,"aria-checked":k(d)?`mixed`:d,"aria-required":f,"data-state":A(d),"data-disabled":u?``:void 0,disabled:u,value:l,...i,ref:S,onKeyDown:s(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:s(n,e=>{g(e=>k(e)?!0:!e),b&&v&&(_.current=e.isPropagationStopped(),_.current||e.stopPropagation())})})});S.displayName=x;var C=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,m.jsx)(b,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(S,{...d,ref:t,__scopeCheckbox:n}),e&&(0,m.jsx)(D,{__scopeCheckbox:n})]})})});C.displayName=h;var w=`CheckboxIndicator`,T=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:i,...a}=e,o=y(w,n);return(0,m.jsx)(l,{present:i||k(o.checked)||o.checked===!0,children:(0,m.jsx)(r.span,{"data-state":A(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t,style:{pointerEvents:`none`,...e.style}})})});T.displayName=w;var E=`CheckboxBubbleInput`,D=p.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:i,hasConsumerStoppedPropagationRef:o,checked:s,defaultChecked:c,required:l,disabled:f,name:h,value:g,form:_,bubbleInput:v,setBubbleInput:b}=y(E,e),x=a(n,b),S=u(s),C=d(i);p.useEffect(()=>{let e=v;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!o.current;if(S!==s&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=k(s),n.call(e,k(s)?!1:s),e.dispatchEvent(t)}},[v,S,s,o]);let w=p.useRef(k(s)?!1:s);return(0,m.jsx)(r.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:c??w.current,required:l,disabled:f,name:h,value:g,form:_,...t,tabIndex:-1,ref:x,style:{...t.style,...C,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});D.displayName=E;function O(e){return typeof e==`function`}function k(e){return e===`indeterminate`}function A(e){return k(e)?`indeterminate`:e?`checked`:`unchecked`}function j({className:e,...t}){return(0,m.jsx)(C,{className:i(`peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40`,e),"data-slot":`checkbox`,...t,children:(0,m.jsx)(T,{className:`grid place-content-center text-current transition-none`,"data-slot":`checkbox-indicator`,children:(0,m.jsx)(f,{className:`size-3.5`})})})}export{j as t};
@@ -1 +1 @@
import{Mp as e,Np as t,Pp as n,Zp as r}from"./messages-BLwDcEAr.js";import{t as i}from"./telescope-DU0wiTDw.js";import{i as a,o,r as s,s as c,t as l}from"./dialog-iqE0q8bN.js";var u=r();function d({open:r,onOpenChange:d}){return(0,u.jsx)(l,{onOpenChange:d,open:r,children:(0,u.jsx)(s,{className:`sm:max-w-sm`,children:(0,u.jsxs)(o,{className:`items-center text-center`,children:[(0,u.jsx)(i,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(c,{children:n()}),(0,u.jsxs)(a,{children:[t(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:e()})]})]})})})}export{d as t};
import{Ip as e,Lp as t,Rp as n,tm as r}from"./messages-CL4J6BaJ.js";import{t as i}from"./telescope-DU0wiTDw.js";import{i as a,o,r as s,s as c,t as l}from"./dialog-CtyiwzAl.js";var u=r();function d({open:r,onOpenChange:d}){return(0,u.jsx)(l,{onOpenChange:d,open:r,children:(0,u.jsx)(s,{className:`sm:max-w-sm`,children:(0,u.jsxs)(o,{className:`items-center text-center`,children:[(0,u.jsx)(i,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(c,{children:n()}),(0,u.jsxs)(a,{children:[t(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:e()})]})]})})})}export{d as t};
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Gp as n,Kp as r,Zp as i}from"./messages-BLwDcEAr.js";import{c as a,i as o,t as s,u as c}from"./button-C0SPdqvs.js";import{a as l,r as u}from"./dist-D343v8gR.js";import{a as d,c as f,i as p,l as m,n as h,o as g,r as _,s as ee,t as v,u as y}from"./dist-BEGLjtzw.js";var b=e(t(),1),x=i(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=a(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,a=w(n),o=b.useRef(null),s=c(t,o),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...a,...i,ref:s,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:o})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),a=w(n),o=c(t,i);return(0,x.jsx)(v,{...a,...r,ref:o})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users.
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Xp as n,Yp as r,tm as i}from"./messages-CL4J6BaJ.js";import{c as a,i as o,t as s,u as c}from"./button-NLSeBFht.js";import{a as l,r as u}from"./dist-DmeeHi7K.js";import{a as d,c as f,i as p,l as m,n as h,o as g,r as _,s as ee,t as v,u as y}from"./dist-DPYswSIO.js";var b=e(t(),1),x=i(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=a(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,a=w(n),o=b.useRef(null),s=c(t,o),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...a,...i,ref:s,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:o})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),a=w(n),o=c(t,i);return(0,x.jsx)(v,{...a,...r,ref:o})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users.
You can add a description to the \`${M}\` by passing a \`${z}\` component as a child, which also benefits sighted users by adding visible context to the dialog.
Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${M}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component.
For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(s,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(e){let{title:t,desc:i,children:a,className:c,confirmText:l,cancelBtnText:u,destructive:d,isLoading:f,disabled:p=!1,form:m,handleConfirm:h,...g}=e;return(0,x.jsx)(ne,{...g,children:(0,x.jsxs)(ae,{className:o(c&&c),children:[(0,x.jsxs)(oe,{className:`text-start`,children:[(0,x.jsx)(se,{children:t}),(0,x.jsx)(ce,{asChild:!0,children:(0,x.jsx)(`div`,{children:i})})]}),a,(0,x.jsxs)($,{children:[(0,x.jsx)(le,{disabled:f,children:u??r()}),(0,x.jsx)(s,{disabled:p||f,form:m,onClick:h,type:m?`submit`:`button`,variant:d?`destructive`:`default`,children:l??n()})]})]})})}export{ue as t};
For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(s,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(e){let{title:t,desc:i,children:a,className:c,confirmText:l,cancelBtnText:u,destructive:d,isLoading:f,disabled:p=!1,form:m,handleConfirm:h,...g}=e;return(0,x.jsx)(ne,{...g,children:(0,x.jsxs)(ae,{className:o(c&&c),children:[(0,x.jsxs)(oe,{className:`text-start`,children:[(0,x.jsx)(se,{children:t}),(0,x.jsx)(ce,{asChild:!0,children:(0,x.jsx)(`div`,{children:i})})]}),a,(0,x.jsxs)($,{children:[(0,x.jsx)(le,{disabled:f,children:u??n()}),(0,x.jsx)(s,{disabled:p||f,form:m,onClick:h,type:m?`submit`:`button`,variant:d?`destructive`:`default`,children:l??r()})]})]})})}export{ue as t};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.js";var r=e(t(),1);function i(e){let t=new Map,n=new Map;return{get(e){return t.get(e)},load(r){let i=t.get(r);if(i)return Promise.resolve(i);let a=n.get(r);if(a)return a;let o=e(r).then(e=>(t.set(r,e),e)).finally(()=>{n.delete(r)});return n.set(r,o),o}}}async function a(e,t,n){for(let n of e){let e=`${n.base}/${t}`;try{let t=await fetch(e,{signal:AbortSignal.timeout(3e3)});if(t.ok)return await t.json()}catch{}}return n}function o({cdnList:e,fallback:t,path:n}){return i(r=>a(e,n(r),t))}var s=`GMWalletApp/crypto-icons`,c=`latest`,l=[{base:`https://cdn.jsdmirror.com/gh/${s}@${c}`,name:`jsdmirror`},{base:`https://cdn.jsdelivr.net/gh/${s}@${c}`,name:`jsdelivr`},{base:`https://fastly.jsdelivr.net/gh/${s}@${c}`,name:`fastly.jsdelivr`},{base:`https://gcore.jsdelivr.net/gh/${s}@${c}`,name:`gcore.jsdelivr`}],u={exchange:`exchanges`,network:`networks`,token:`tokens`,wallet:`wallets`},d=o({cdnList:l,fallback:{},path:e=>`aliases/${u[e]}.json`}),f=o({cdnList:l,fallback:[],path:e=>`maps/${u[e]}.json`});function p(e){return String(e??``).trim().toLowerCase()}function m(e){return p(e).replace(/\s+/g,`-`)}function h(e){let t=e?.split(`:`).pop();return t?t.trim():``}function g(e){return[e.id,e.symbol,e.name,e.shortName,h(e.filePath)].map(e=>m(e??``)).filter(Boolean)}function _(e,t){let n=e.find(e=>g(e).includes(t));return n&&(h(n.filePath)||n.id||n.symbol)||null}async function v(e,t){let n=m(t);if(!n)return``;let[r,i]=await Promise.all([f.load(e),d.load(e)]),a=m(i[n]??``);return _(r,n)||(a?_(r,a):null)||i[n]||n}function y(e,t,n={}){let{variant:r=`branded`}=n,i=m(t);return i?`${l[0].base}/assets/${u[e]}/${r}/${i}.svg`:null}async function b(e,t,n={}){let{variant:r=`branded`}=n,i=await v(e,t),a=`assets/${u[e]}/${r}/${i}.svg`;for(let e of l){let t=`${e.base}/${a}`;try{if((await fetch(t,{method:`HEAD`,signal:AbortSignal.timeout(2500)})).ok)return{url:t}}catch{}}return{url:`${l[0].base}/${a}`}}function x(e,t){return t?.trim()||e||`--`}var S=n();function C({type:e,name:t,variant:n=`branded`,size:i=16,width:a,height:o,className:s,alt:c,style:l,onError:u,...d}){let f=y(e,t,{variant:n}),[p,m]=(0,r.useState)(null),[h,g]=(0,r.useState)(f),_=h??f,v=a??i,x=o??i;return(0,r.useEffect)(()=>{let r=!1;return m(null),g(y(e,t,{variant:n})),b(e,t,{variant:n}).then(e=>{r||g(e.url)}),()=>{r=!0}},[t,e,n]),!(_&&t)||p===_?null:(0,S.jsx)(`img`,{alt:c??``,className:s,height:x,onError:e=>{m(_),u?.(e)},src:_,style:{display:`inline-block`,...l},width:v,...d})}export{p as a,l as i,x as n,v as o,u as r,o as s,C as t};
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{tm as n}from"./messages-CL4J6BaJ.js";var r=e(t(),1);function i(e){let t=new Map,n=new Map;return{get(e){return t.get(e)},load(r){let i=t.get(r);if(i)return Promise.resolve(i);let a=n.get(r);if(a)return a;let o=e(r).then(e=>(t.set(r,e),e)).finally(()=>{n.delete(r)});return n.set(r,o),o}}}async function a(e,t,n){for(let n of e){let e=`${n.base}/${t}`;try{let t=await fetch(e,{signal:AbortSignal.timeout(3e3)});if(t.ok)return await t.json()}catch{}}return n}function o({cdnList:e,fallback:t,path:n}){return i(r=>a(e,n(r),t))}var s=`GMWalletApp/crypto-icons`,c=`latest`,l=[{base:`https://cdn.jsdmirror.com/gh/${s}@${c}`,name:`jsdmirror`},{base:`https://cdn.jsdelivr.net/gh/${s}@${c}`,name:`jsdelivr`},{base:`https://fastly.jsdelivr.net/gh/${s}@${c}`,name:`fastly.jsdelivr`},{base:`https://gcore.jsdelivr.net/gh/${s}@${c}`,name:`gcore.jsdelivr`}],u={exchange:`exchanges`,network:`networks`,token:`tokens`,wallet:`wallets`},d=o({cdnList:l,fallback:{},path:e=>`aliases/${u[e]}.json`}),f=o({cdnList:l,fallback:[],path:e=>`maps/${u[e]}.json`});function p(e){return String(e??``).trim().toLowerCase()}function m(e){return p(e).replace(/\s+/g,`-`)}function h(e){let t=e?.split(`:`).pop();return t?t.trim():``}function g(e){return[e.id,e.symbol,e.name,e.shortName,h(e.filePath)].map(e=>m(e??``)).filter(Boolean)}function _(e,t){let n=e.find(e=>g(e).includes(t));return n&&(h(n.filePath)||n.id||n.symbol)||null}async function v(e,t){let n=m(t);if(!n)return``;let[r,i]=await Promise.all([f.load(e),d.load(e)]),a=m(i[n]??``);return _(r,n)||(a?_(r,a):null)||i[n]||n}function y(e,t,n={}){let{variant:r=`branded`}=n,i=m(t);return i?`${l[0].base}/assets/${u[e]}/${r}/${i}.svg`:null}async function b(e,t,n={}){let{variant:r=`branded`}=n,i=await v(e,t),a=`assets/${u[e]}/${r}/${i}.svg`;for(let e of l){let t=`${e.base}/${a}`;try{if((await fetch(t,{method:`HEAD`,signal:AbortSignal.timeout(2500)})).ok)return{url:t}}catch{}}return{url:`${l[0].base}/${a}`}}function x(e,t){return t?.trim()||e||`--`}var S=n();function C({type:e,name:t,variant:n=`branded`,size:i=16,width:a,height:o,className:s,alt:c,style:l,onError:u,...d}){let f=y(e,t,{variant:n}),[p,m]=(0,r.useState)(null),[h,g]=(0,r.useState)(f),_=h??f,v=a??i,x=o??i;return(0,r.useEffect)(()=>{let r=!1;return m(null),g(y(e,t,{variant:n})),b(e,t,{variant:n}).then(e=>{r||g(e.url)}),()=>{r=!0}},[t,e,n]),!(_&&t)||p===_?null:(0,S.jsx)(`img`,{alt:c??``,className:s,height:x,onError:e=>{m(_),u?.(e)},src:_,style:{display:`inline-block`,...l},width:v,...d})}export{p as a,l as i,x as n,v as o,u as r,o as s,C as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Zp as e}from"./messages-BLwDcEAr.js";import{i as t,t as n}from"./button-C0SPdqvs.js";import{a as r,c as i,i as a,n as o,o as s,r as c,s as l,t as u}from"./dist-BEGLjtzw.js";import{t as d}from"./createLucideIcon-Br0Bd5k2.js";var f=d(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),p=e();function m({...e}){return(0,p.jsx)(s,{"data-slot":`dialog`,...e})}function h({...e}){return(0,p.jsx)(i,{"data-slot":`dialog-trigger`,...e})}function g({...e}){return(0,p.jsx)(r,{"data-slot":`dialog-portal`,...e})}function _({...e}){return(0,p.jsx)(u,{"data-slot":`dialog-close`,...e})}function v({className:e,...n}){return(0,p.jsx)(a,{className:t(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`dialog-overlay`,...n})}function y({className:e,children:n,showCloseButton:r=!0,...i}){return(0,p.jsxs)(g,{"data-slot":`dialog-portal`,children:[(0,p.jsx)(v,{}),(0,p.jsxs)(o,{className:t(`data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg`,e),"data-slot":`dialog-content`,...i,children:[n,r&&(0,p.jsxs)(u,{className:`absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0`,"data-slot":`dialog-close`,children:[(0,p.jsx)(f,{}),(0,p.jsx)(`span`,{className:`sr-only`,children:`Close`})]})]})]})}function b({className:e,...n}){return(0,p.jsx)(`div`,{className:t(`flex flex-col gap-2 text-center sm:text-left`,e),"data-slot":`dialog-header`,...n})}function x({className:e,showCloseButton:r=!1,children:i,...a}){return(0,p.jsxs)(`div`,{className:t(`flex flex-col-reverse gap-2 sm:flex-row sm:justify-end`,e),"data-slot":`dialog-footer`,...a,children:[i,r&&(0,p.jsx)(u,{asChild:!0,children:(0,p.jsx)(n,{variant:`outline`,children:`Close`})})]})}function S({className:e,...n}){return(0,p.jsx)(l,{className:t(`font-semibold text-lg leading-none`,e),"data-slot":`dialog-title`,...n})}function C({className:e,...n}){return(0,p.jsx)(c,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`dialog-description`,...n})}export{x as a,h as c,C as i,f as l,_ as n,b as o,y as r,S as s,m as t};
import{tm as e}from"./messages-CL4J6BaJ.js";import{i as t,t as n}from"./button-NLSeBFht.js";import{a as r,c as i,i as a,n as o,o as s,r as c,s as l,t as u}from"./dist-DPYswSIO.js";import{t as d}from"./createLucideIcon-Br0Bd5k2.js";var f=d(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),p=e();function m({...e}){return(0,p.jsx)(s,{"data-slot":`dialog`,...e})}function h({...e}){return(0,p.jsx)(i,{"data-slot":`dialog-trigger`,...e})}function g({...e}){return(0,p.jsx)(r,{"data-slot":`dialog-portal`,...e})}function _({...e}){return(0,p.jsx)(u,{"data-slot":`dialog-close`,...e})}function v({className:e,...n}){return(0,p.jsx)(a,{className:t(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`dialog-overlay`,...n})}function y({className:e,children:n,showCloseButton:r=!0,...i}){return(0,p.jsxs)(g,{"data-slot":`dialog-portal`,children:[(0,p.jsx)(v,{}),(0,p.jsxs)(o,{className:t(`data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg`,e),"data-slot":`dialog-content`,...i,children:[n,r&&(0,p.jsxs)(u,{className:`absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0`,"data-slot":`dialog-close`,children:[(0,p.jsx)(f,{}),(0,p.jsx)(`span`,{className:`sr-only`,children:`Close`})]})]})]})}function b({className:e,...n}){return(0,p.jsx)(`div`,{className:t(`flex flex-col gap-2 text-center sm:text-left`,e),"data-slot":`dialog-header`,...n})}function x({className:e,showCloseButton:r=!1,children:i,...a}){return(0,p.jsxs)(`div`,{className:t(`flex flex-col-reverse gap-2 sm:flex-row sm:justify-end`,e),"data-slot":`dialog-footer`,...a,children:[i,r&&(0,p.jsx)(u,{asChild:!0,children:(0,p.jsx)(n,{variant:`outline`,children:`Close`})})]})}function S({className:e,...n}){return(0,p.jsx)(l,{className:t(`font-semibold text-lg leading-none`,e),"data-slot":`dialog-title`,...n})}function C({className:e,...n}){return(0,p.jsx)(c,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`dialog-description`,...n})}export{x as a,h as c,C as i,f as l,_ as n,b as o,y as r,S as s,m as t};
+1
View File
@@ -0,0 +1 @@
import{an as e,cn as t,dn as n,fn as r,gn as i,hn as a,ln as o,mn as s,on as c,pn as l,sn as u,tm as d,un as f}from"./messages-CL4J6BaJ.js";import{t as p}from"./button-NLSeBFht.js";import{t as m}from"./checkbox-DcRUgRHr.js";import{c as h,i as g,s as _}from"./zod-rwFXxHlg.js";import{a as v,c as y,i as b,l as x,n as S,o as C,r as w,s as T,t as E}from"./form-C_YekIvK.js";import{t as D}from"./page-header-B7O10aw9.js";import{t as O}from"./show-submitted-data-DwaVTW9K.js";var k=d(),A=[{id:`recents`,label:s()},{id:`home`,label:l()},{id:`applications`,label:r()},{id:`desktop`,label:n()},{id:`downloads`,label:f()},{id:`documents`,label:o()}],j=_({items:g(h()).refine(e=>e.some(e=>e),{message:t()})}),M={items:[`recents`,`home`]};function N(){let t=x({resolver:y(j),defaultValues:M});return(0,k.jsx)(E,{...t,children:(0,k.jsxs)(`form`,{className:`space-y-8`,onSubmit:t.handleSubmit(e=>O(e)),children:[(0,k.jsx)(b,{control:t.control,name:`items`,render:()=>(0,k.jsxs)(v,{children:[(0,k.jsxs)(`div`,{className:`mb-4`,children:[(0,k.jsx)(C,{className:`text-base`,children:u()}),(0,k.jsx)(w,{children:c()})]}),A.map(e=>(0,k.jsx)(b,{control:t.control,name:`items`,render:({field:t})=>(0,k.jsxs)(v,{className:`flex flex-row items-start`,children:[(0,k.jsx)(S,{children:(0,k.jsx)(m,{checked:t.value?.includes(e.id),onCheckedChange:n=>n?t.onChange([...t.value,e.id]):t.onChange(t.value?.filter(t=>t!==e.id))})}),(0,k.jsx)(C,{className:`font-normal`,children:e.label})]},e.id)},e.id)),(0,k.jsx)(T,{})]})}),(0,k.jsx)(p,{type:`submit`,children:e()})]})})}function P(){return(0,k.jsx)(D,{description:a(),title:i(),variant:`section`,children:(0,k.jsx)(N,{})})}var F=P;export{F as component};

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