14 Commits

Author SHA1 Message Date
line-6000 7bd21d1d79 Merge pull request #95 from GMWalletApp/dev
feat(epctl): add upgrade restart controls and rollback-safe deploy
2026-06-29 22:58:37 +08:00
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
189 changed files with 6503 additions and 422 deletions
+10 -2
View File
@@ -10,7 +10,7 @@
<p align="center"> <p align="center">
<a href="./README.en.md">English</a> | <a href="./README.en.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a> <a href="./README.md">简体中文</a>
</p> </p>
<p align="center"> <p align="center">
@@ -45,6 +45,7 @@ Deploy it privately, integrate with the HTTP API, and start receiving crypto pay
| **Solana** | USDT, USDC | | **Solana** | USDT, USDC |
| **BEP20** (BSC) | USDT, USDC, BNB | | **BEP20** (BSC) | USDT, USDC, BNB |
| **Polygon** | USDT, USDC | | **Polygon** | USDT, USDC |
| **Aptos** | USDC, USDT |
| **More** | Expanding continuously | | **More** | Expanding continuously |
> Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com). > Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com).
@@ -68,7 +69,7 @@ No matter what kind of platform you operate, Epusdt can fit into existing paymen
## Core Features ## Core Features
- **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, and more - **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, Aptos, and more
- **Self-hosted** — full control of funds and wallet infrastructure - **Self-hosted** — full control of funds and wallet infrastructure
- **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis - **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis
- **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac - **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac
@@ -87,17 +88,24 @@ Quick-start links:
| Guide | Description | | Guide | Description |
|-------|-------------| |-------|-------------|
| [Repo-local: epctl installer](wiki/EPCTL.en.md) | Linux binary install, upgrade, status inspection, and Docker validation |
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup | | [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users | | [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control | | [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
| [Developer API Docs](https://epusdt.com) | Integration reference | | [Developer API Docs](https://epusdt.com) | Integration reference |
The repository also ships these top-level scripts:
- [`./epctl`](./epctl) for Linux binary install, upgrade, config inspection, service status, logs, and initial password retrieval
- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) for real Ubuntu + systemd installation validation inside local Docker
--- ---
## Project Structure ## Project Structure
```text ```text
Epusdt Epusdt
├── epctl Linux binary installation and operations script
├── src/ Core project source code ├── src/ Core project source code
└── wiki/ Documentation assets and knowledge base └── wiki/ Documentation assets and knowledge base
``` ```
+9 -1
View File
@@ -45,6 +45,7 @@
| **Solana** | USDT、USDC | | **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB | | **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC | | **Polygon** | USDT、USDC |
| **Aptos** | USDC、USDT |
| **更多** | 持续扩展中… | | **更多** | 持续扩展中… |
> 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 > 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
@@ -74,7 +75,7 @@ Epusdt 已完成第三方安全审计。
## 核心特性 ## 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络 - **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon、Aptos 等主流网络
- **私有化部署** — 资金完全自主掌控 - **私有化部署** — 资金完全自主掌控
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis - **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac - **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
@@ -93,17 +94,24 @@ Epusdt 已完成第三方安全审计。
| 教程 | 说明 | | 教程 | 说明 |
|------|------| |------|------|
| [仓库内:epctl 安装脚本](wiki/EPCTL.md) | Linux 二进制安装、升级、状态查看与 Docker 验收脚本 |
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 | | [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 | | [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 | | [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 | | [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
仓库内还提供顶层脚本:
- [`./epctl`](./epctl) 用于 Linux 二进制安装、升级、查看配置、状态和初始化密码
- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) 用于在本机 Docker 里跑 Ubuntu + systemd 的真实安装验收
--- ---
## 项目结构 ## 项目结构
```text ```text
Epusdt Epusdt
├── epctl Linux 二进制安装与运维脚本
├── src/ 项目核心代码 ├── src/ 项目核心代码
└── wiki/ 文档与知识库 └── wiki/ 文档与知识库
``` ```
Executable
+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
View File
@@ -55,8 +55,6 @@ func InitApp() {
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║") color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
color.Yellow.Printf("║ Username: %-54s║\n", "admin") color.Yellow.Printf("║ Username: %-54s║\n", "admin")
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword) color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝") color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
} }
if _, err := appjwt.EnsureSecret(); err != nil { if _, err := appjwt.EnsureSecret(); err != nil {
-30
View File
@@ -1,8 +1,6 @@
package admin package admin
import ( import (
"errors"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
@@ -35,12 +33,6 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"` PasswordIsDefault bool `json:"password_is_default" example:"true"`
} }
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT. // Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login // @Summary Admin login
// @Description Verify credentials and return a signed JWT token // @Description Verify credentials and return a signed JWT token
@@ -84,28 +76,6 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
}) })
} }
// GetInitialPassword returns the one-time initial admin password.
// @Summary Get initial admin password (one-time)
// @Description Returns the initial random admin password once, then invalidates it.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password [get]
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword()
if err != nil {
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
}
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, InitialPasswordResponse{
Username: "admin",
Password: password,
})
}
// GetInitialPasswordHash returns the initial-password fingerprint so the // GetInitialPasswordHash returns the initial-password fingerprint so the
// frontend can warn until the password is changed. // frontend can warn until the password is changed.
// @Summary Get initial admin password hash // @Summary Get initial admin password hash
+1 -1
View File
@@ -40,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)" // @Param network path string true "Network name (e.g. ton, tron, ethereum, solana, aptos)"
// @Param request body admin.UpdateChainRequest true "Chain settings" // @Param request body admin.UpdateChainRequest true "Chain settings"
// @Success 200 {object} response.ApiResponse // @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse
+51 -5
View File
@@ -12,8 +12,10 @@ import (
// CreateChainTokenRequest is the payload for creating a chain token. // CreateChainTokenRequest is the payload for creating a chain token.
type CreateChainTokenRequest struct { type CreateChainTokenRequest struct {
Network string `json:"network" validate:"required" example:"tron"` Network string `json:"network" validate:"required" example:"tron"`
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"` Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
// Contract address or chain-native asset identifier. For Aptos this is
// the Move asset id (coin type, metadata address, or object type).
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"` ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `json:"decimals" example:"6"` Decimals int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
@@ -22,6 +24,8 @@ type CreateChainTokenRequest struct {
// UpdateChainTokenRequest is the payload for updating a chain token. // UpdateChainTokenRequest is the payload for updating a chain token.
type UpdateChainTokenRequest struct { type UpdateChainTokenRequest struct {
// Contract address or chain-native asset identifier. For Aptos this is
// the Move asset id (coin type, metadata address, or object type).
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"` ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals *int `json:"decimals" example:"6"` Decimals *int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
@@ -33,6 +37,17 @@ type ChangeChainTokenStatusRequest struct {
Enabled bool `json:"enabled" example:"true"` Enabled bool `json:"enabled" example:"true"`
} }
func validateChainTokenPaymentConfig(row *mdb.ChainToken) error {
if row == nil {
return nil
}
contractAddress := strings.TrimSpace(row.ContractAddress)
if row.Enabled && row.Network == mdb.NetworkAptos && contractAddress == "" {
return constant.ParamsMarshalErr
}
return nil
}
// ListChainTokens returns rows, optionally filtered by network. // ListChainTokens returns rows, optionally filtered by network.
// @Summary List chain tokens // @Summary List chain tokens
// @Description Returns chain tokens, optionally filtered by network // @Description Returns chain tokens, optionally filtered by network
@@ -54,7 +69,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed. // CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
// @Summary Create chain token // @Summary Create chain token
// @Description Create a new chain token entry // @Description Create a new chain token entry. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
// @Tags Admin Chain Tokens // @Tags Admin Chain Tokens
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -87,6 +102,9 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
Enabled: enabled, Enabled: enabled,
MinAmount: req.MinAmount, MinAmount: req.MinAmount,
} }
if err := validateChainTokenPaymentConfig(row); err != nil {
return c.FailJson(ctx, err)
}
if err := data.CreateChainToken(row); err != nil { if err := data.CreateChainToken(row); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -95,7 +113,7 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
// UpdateChainToken patches mutable columns. // UpdateChainToken patches mutable columns.
// @Summary Update chain token // @Summary Update chain token
// @Description Patch chain token fields // @Description Patch chain token fields. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
// @Tags Admin Chain Tokens // @Tags Admin Chain Tokens
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -115,18 +133,35 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
existing, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if existing == nil {
existing = &mdb.ChainToken{}
}
next := *existing
if req.ContractAddress != nil { if req.ContractAddress != nil {
fields["contract_address"] = *req.ContractAddress next.ContractAddress = strings.TrimSpace(*req.ContractAddress)
fields["contract_address"] = next.ContractAddress
} }
if req.Decimals != nil { if req.Decimals != nil {
next.Decimals = *req.Decimals
fields["decimals"] = *req.Decimals fields["decimals"] = *req.Decimals
} }
if req.Enabled != nil { if req.Enabled != nil {
next.Enabled = *req.Enabled
fields["enabled"] = *req.Enabled fields["enabled"] = *req.Enabled
} }
if req.MinAmount != nil { if req.MinAmount != nil {
next.MinAmount = *req.MinAmount
fields["min_amount"] = *req.MinAmount fields["min_amount"] = *req.MinAmount
} }
if next.ID > 0 {
if err = validateChainTokenPaymentConfig(&next); err != nil {
return c.FailJson(ctx, err)
}
}
if err := data.UpdateChainTokenFields(id, fields); err != nil { if err := data.UpdateChainTokenFields(id, fields); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -154,6 +189,17 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
row, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row != nil && row.ID > 0 {
next := *row
next.Enabled = req.Enabled
if err = validateChainTokenPaymentConfig(&next); err != nil {
return c.FailJson(ctx, err)
}
}
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil { if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -0,0 +1,25 @@
package admin
import (
"testing"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
)
func TestValidateChainTokenPaymentConfigRejectsEnabledAptosTokenWithoutAssetID(t *testing.T) {
for _, row := range []mdb.ChainToken{
{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: true},
} {
if err := validateChainTokenPaymentConfig(&row); err != constant.ParamsMarshalErr {
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want %v", row, err, constant.ParamsMarshalErr)
}
}
}
func TestValidateChainTokenPaymentConfigAllowsDisabledAptosTokenWithoutAssetID(t *testing.T) {
row := mdb.ChainToken{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: false}
if err := validateChainTokenPaymentConfig(&row); err != nil {
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want nil", row, err)
}
}
+2
View File
@@ -141,6 +141,8 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
// @Summary Mark order paid // @Summary Mark order paid
// @Description Manually mark a waiting order as paid (operator补单) // @Description Manually mark a waiting order as paid (operator补单)
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address. // @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @Tags Admin Orders // @Tags Admin Orders
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
+4 -4
View File
@@ -26,9 +26,9 @@ import (
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed // rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
// //
// - group=epay: // - group=epay:
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent // epay.default_token (string) — 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"; empty falls back to cny // 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"; empty allows status=4 placeholders when request token/network are also absent // epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; ignored when a supported type=token.network selector is supplied; empty allows status=4 placeholders when request token/network are also absent
// //
// - group=okpay: // - group=okpay:
// okpay.enabled (bool) — enable OkPay as a switch-network payment option // okpay.enabled (bool) — enable OkPay as a switch-network payment option
@@ -95,7 +95,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// @Summary Upsert settings // @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n. // @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
// @Description Supported groups: brand, rate, system, epay, okpay. // @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", 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 okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
+12 -3
View File
@@ -3,6 +3,7 @@ package comm
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
@@ -13,6 +14,8 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
const EPayTypeContextKey = "epay_type"
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign. // apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
// Returns nil when the middleware didn't run (should not happen on authed routes). // Returns nil when the middleware didn't run (should not happen on authed routes).
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
@@ -101,7 +104,10 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// POST (form) per the legacy EPAY protocol; swagger documents POST as // POST (form) per the legacy EPAY protocol; swagger documents POST as
// the canonical form — the GET variant is identical save the transport. // the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat) // @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint. // @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @Description After signature verification, type accepts only either alipay or a supported type=token.network selector (for example usdt.tron). Token/network resolution is: supported selector first; otherwise request token/network; otherwise epay.default_token / epay.default_network. If token and network are still both empty, the order is created as status=4 placeholder. Supplying only one of token/network remains invalid.
// @Description Currency resolution is unchanged: request currency -> epay.default_currency -> cny. Supported type selectors bypass only token/network defaults, not currency fallback.
// @Description Success return/notify reuse the stored request type. On this branch that means either alipay or a supported token.network selector; when the request omitted type, outbound fallback remains alipay. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Tags Payment // @Tags Payment
// @Accept x-www-form-urlencoded // @Accept x-www-form-urlencoded
// @Produce html // @Produce html
@@ -111,7 +117,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url query string false "Callback URL (GET query)" // @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)" // @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (GET query)" // @Param name query string false "Order name (GET query)"
// @Param type query string false "Payment type (e.g. alipay, GET query)" // @Param type query string false "Either alipay or a supported token.network selector such as usdt.tron (GET query)"
// @Param sign query string false "MD5 signature (GET query)" // @Param sign query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)" // @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID" // @Param pid formData integer true "API key PID"
@@ -120,7 +126,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url formData string true "Callback URL" // @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment" // @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name" // @Param name formData string false "Order name"
// @Param type formData string false "Payment type (e.g. alipay)" // @Param type formData string false "Either alipay or a supported token.network selector such as usdt.tron"
// @Param sign formData string true "MD5 signature" // @Param sign formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)" // @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter" // @Success 302 "Redirect to checkout counter"
@@ -133,6 +139,9 @@ func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err
log.Sugar.Errorf("bind request error: %v", err) log.Sugar.Errorf("bind request error: %v", err)
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if raw, ok := ctx.Get(EPayTypeContextKey).(string); ok {
req.EpayType = strings.TrimSpace(raw)
}
if err = c.ValidateStruct(ctx, req); err != nil { if err = c.ValidateStruct(ctx, req); err != nil {
log.Sugar.Errorf("validate request error: %v", err) log.Sugar.Errorf("validate request error: %v", err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
+4 -1
View File
@@ -14,7 +14,7 @@ import (
// CheckoutCounter 收银台 // CheckoutCounter 收银台
// @Summary Checkout counter page // @Summary Checkout counter page
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection). // @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. // @Description For EPay orders with a merchant return_url, the response redirect_url is rewritten to the internal /pay/return/{trade_id} hop; the database still stores the merchant's raw return_url.
// @Tags Payment // @Tags Payment
// @Produce json // @Produce json
@@ -36,6 +36,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
// Non-EPay or not-yet-paid orders are sent back to the checkout counter. // Non-EPay or not-yet-paid orders are sent back to the checkout counter.
// @Summary Return to merchant (EPAY compat) // @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 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. // @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 // @Tags Payment
// @Produce html // @Produce html
@@ -81,6 +82,8 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
// @Summary Submit transaction hash // @Summary Submit transaction hash
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported. // @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address. // @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
// @Description Aptos accepts a transaction hash.
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
// @Tags Payment // @Tags Payment
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -10,6 +10,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -44,6 +45,9 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
} }
symbols := make([]string, 0, len(tokens)) symbols := make([]string, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
if !service.ChainTokenReadyForPayment(t) {
continue
}
sym := strings.ToUpper(strings.TrimSpace(t.Symbol)) sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
if sym == "" { if sym == "" {
continue continue
@@ -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 // When the .env config file is absent (or has install=true) the HTTP start
// command calls RunInstallServer, which listens on the same address the main // command calls RunInstallServer, which listens on the same address the main
// server will eventually use (default :8000) and mounts two JSON endpoints // server will eventually use (default :8000) and mounts two JSON endpoints
// under /install consumed by the frontend install UI: // consumed by the frontend install UI:
// //
// GET /install/defaults — default field values for the form // GET /api/install/defaults — default field values for the form
// POST /install — validate + write .env, then shut down // POST /api/install — validate + initialize install state, then shut down
// //
// The HTTP listen address is submitted as two separate fields (http_bind_addr // The HTTP listen address is submitted as two separate fields (http_bind_addr
// and http_bind_port) and combined internally as "ADDR:PORT" before writing // and http_bind_port) and combined internally as "ADDR:PORT" before writing
// the http_listen key in .env. This makes the form easier for users who only // the http_listen key in .env. This makes the form easier for users who only
// want to change the port without touching the bind address. // want to change the port without touching the bind address.
// //
// Once the .env is written the install server stops and normal bootstrap // Once install state is initialized and the .env is finalized with
// proceeds on the same port without a restart. // install=false, the install server stops and normal bootstrap proceeds on the
// same port without a restart.
package install package install
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -28,10 +30,15 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
luluHttp "github.com/GMWalletApp/epusdt/util/http" luluHttp "github.com/GMWalletApp/epusdt/util/http"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
) )
// DefaultInstallAddr is the listen address used by the install API. // DefaultInstallAddr is the listen address used by the install API.
@@ -59,6 +66,14 @@ type InstallRequest struct {
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"` OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
} }
// InstallSubmitResponse is returned when the install request succeeds.
type InstallSubmitResponse struct {
// Completion message for the install request.
Message string `json:"message" example:"install complete, starting server…"`
// Initial admin password, returned only while the plaintext is still available.
InitPassword string `json:"init_password,omitempty" example:"a1b2c3d4e5f6"`
}
// InstallDefaults returns sensible default values for the install form. // InstallDefaults returns sensible default values for the install form.
func InstallDefaults() InstallRequest { func InstallDefaults() InstallRequest {
return InstallRequest{ return InstallRequest{
@@ -149,28 +164,20 @@ func (h *installHandler) GetDefaults(c echo.Context) error {
return c.JSON(http.StatusOK, InstallDefaults()) return c.JSON(http.StatusOK, InstallDefaults())
} }
// Submit validates the install payload, writes the .env file, and signals // Submit validates the install payload, writes the .env file, initializes the
// the install server to shut down so the main bootstrap can proceed. // minimum DB/admin state, and signals the install server to shut down so the
// main bootstrap can proceed.
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce // http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
// the http_listen config key (e.g. 0.0.0.0:8000). // the http_listen config key (e.g. 0.0.0.0:8000).
// //
// @Summary Install — submit configuration // @Summary Install — submit configuration
// @Description Validates the submitted configuration and writes the .env file. // @Description Validates the submitted configuration, writes install=true, performs the minimum DB setup needed to ensure the default admin exists, optionally returns init_password, then rewrites install=false before shutting down the install server.
// // @Description http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for http_listen. app_uri is required; other fields fall back to GET /api/install/defaults.
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
// http_bind_port must be in the range 165535 if provided.
// app_uri is required. All other fields are optional and fall back to
// the defaults returned by GET /api/install/defaults.
// Sets install=false in the written .env, then shuts down the install
// server so that normal application bootstrap starts on the same port.
// After installation completes this route is no longer served.
//
// @Tags Install // @Tags Install
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body InstallRequest true "Install configuration" // @Param body body InstallRequest true "Install configuration"
// @Success 200 {object} map[string]string "message" // @Success 200 {object} InstallSubmitResponse
// @Failure 400 {object} map[string]string "error" // @Failure 400 {object} map[string]string "error"
// @Failure 500 {object} map[string]string "error" // @Failure 500 {object} map[string]string "error"
// @Router /api/install [post] // @Router /api/install [post]
@@ -210,16 +217,26 @@ func (h *installHandler) Submit(c echo.Context) error {
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
} }
if err := writeEnvFile(h.envFilePath, req); err != nil { if err := writeEnvFile(h.envFilePath, req, true); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
initPassword, err := initializeInstallState(h.envFilePath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
if err := writeEnvFile(h.envFilePath, req, false); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
} }
go func() { close(h.done) }() go func() { close(h.done) }()
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"}) return c.JSON(http.StatusOK, InstallSubmitResponse{
Message: "install complete, starting server…",
InitPassword: initPassword,
})
} }
// RunInstallServer starts the install REST API on listenAddr (default :8000) // RunInstallServer starts the install UI and REST API on listenAddr
// under the /install path and blocks until the .env file has been written. // (default :8000), then blocks until installation has been finalized.
// The caller should then proceed with normal app initialisation (bootstrap.InitApp). // The caller should then proceed with normal app initialisation (bootstrap.InitApp).
func RunInstallServer(listenAddr, envFilePath string) { func RunInstallServer(listenAddr, envFilePath string) {
if listenAddr == "" { if listenAddr == "" {
@@ -264,13 +281,18 @@ var formControlledKeys = map[string]bool{
"install": true, "install": true,
} }
// writeEnvFile renders and writes a minimal .env file. type envTemplateData struct {
*InstallRequest
InstallValue string
}
// writeEnvFile renders and atomically writes a minimal .env file.
// If the file already exists, values for keys that are NOT controlled by the // If the file already exists, values for keys that are NOT controlled by the
// install form are preserved from the existing file so that operator-specific // install form are preserved from the existing file so that operator-specific
// settings (tg_bot_token, db_type, etc.) survive a re-install. // settings (tg_bot_token, db_type, etc.) survive a re-install.
// Keys that the form controls (app_uri, http_listen, …) always use the // Keys that the form controls (app_uri, http_listen, …) always use the
// submitted values. // submitted values.
func writeEnvFile(path string, r *InstallRequest) error { func writeEnvFile(path string, r *InstallRequest, installEnabled bool) error {
// Collect existing non-empty key→value pairs for non-form keys. // Collect existing non-empty key→value pairs for non-form keys.
existingValues := map[string]string{} existingValues := map[string]string{}
if data, err := os.ReadFile(path); err == nil { if data, err := os.ReadFile(path); err == nil {
@@ -295,7 +317,14 @@ func writeEnvFile(path string, r *InstallRequest) error {
// Render the template into a buffer first. // Render the template into a buffer first.
var buf bytes.Buffer var buf bytes.Buffer
if err := envTemplate.Execute(&buf, r); err != nil { renderData := envTemplateData{
InstallRequest: r,
InstallValue: "false",
}
if installEnabled {
renderData.InstallValue = "true"
}
if err := envTemplate.Execute(&buf, renderData); err != nil {
return fmt.Errorf("render env template: %w", err) return fmt.Errorf("render env template: %w", err)
} }
@@ -315,13 +344,96 @@ func writeEnvFile(path string, r *InstallRequest) error {
} }
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) f, err := os.CreateTemp(filepath.Dir(path), ".env.tmp.*")
if err != nil { if err != nil {
return fmt.Errorf("open config file: %w", err) return fmt.Errorf("open temp config file: %w", err)
}
tempPath := f.Name()
defer func() {
_ = os.Remove(tempPath)
}()
if _, err = fmt.Fprint(f, strings.Join(lines, "\n")); err != nil {
_ = f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(tempPath, path); err != nil {
return fmt.Errorf("replace config file: %w", err)
}
return nil
}
func initializeInstallState(envFilePath string) (string, error) {
if err := initInstallConfig(envFilePath); err != nil {
return "", err
}
if err := initInstallDatabases(); err != nil {
closeInstallDatabases()
return "", err
}
defer closeInstallDatabases()
password, created, err := data.EnsureDefaultAdmin()
if err != nil {
return "", err
}
if created {
password = strings.TrimSpace(password)
if password == "" {
return "", errors.New("default admin created without initial password")
}
return password, nil
}
password, err = data.GetInitialAdminPassword()
if err != nil {
if errors.Is(err, data.ErrInitAdminPasswordUnavailable) || errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) {
return "", nil
}
return "", err
}
return strings.TrimSpace(password), nil
}
func initInstallConfig(envFilePath string) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("init config: %v", recovered)
}
}()
config.SetConfigPath(envFilePath)
config.Init()
return nil
}
func initInstallDatabases() error {
if appLog.Sugar == nil {
appLog.Sugar = zap.NewNop().Sugar()
}
if err := dao.DBInit(); err != nil {
return fmt.Errorf("init store db: %w", err)
}
if err := dao.RuntimeInit(); err != nil {
return fmt.Errorf("init runtime db: %w", err)
}
return nil
}
func closeInstallDatabases() {
if dao.RuntimeDB != nil {
if db, err := dao.RuntimeDB.DB(); err == nil {
_ = db.Close()
}
dao.RuntimeDB = nil
}
if dao.Mdb != nil {
if db, err := dao.Mdb.DB(); err == nil {
_ = db.Close()
}
dao.Mdb = nil
} }
defer f.Close()
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
return err
} }
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}} var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
@@ -360,5 +472,5 @@ order_notice_max_retry={{.OrderNoticeMaxRetry}}
api_rate_url= api_rate_url=
# Set to true to re-run the install wizard on next startup. # Set to true to re-run the install wizard on next startup.
install=false install={{.InstallValue}}
`)) `))
+192 -31
View File
@@ -10,9 +10,89 @@ import (
"testing" "testing"
"time" "time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/spf13/viper"
) )
func resetInstallTestState(t *testing.T) {
t.Helper()
closeInstallDatabases()
dao.ResetMdbTableInitForTest()
config.SetConfigPath("")
viper.Reset()
t.Cleanup(func() {
closeInstallDatabases()
dao.ResetMdbTableInitForTest()
config.SetConfigPath("")
viper.Reset()
})
}
func newInstallTestAPI(envPath string) (*echo.Echo, *installHandler) {
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
return e, h
}
func submitInstallRequest(t *testing.T, e *echo.Echo, payload string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
return body
}
func assertDoneClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
}
}
func changeInstalledAdminPassword(t *testing.T, envPath, newPassword string) {
t.Helper()
if err := initInstallConfig(envPath); err != nil {
t.Fatalf("reopen install config: %v", err)
}
if err := initInstallDatabases(); err != nil {
t.Fatalf("reopen install databases: %v", err)
}
defer closeInstallDatabases()
user, err := data.GetAdminUserByUsername("admin")
if err != nil {
t.Fatalf("load admin user: %v", err)
}
if user.ID == 0 {
t.Fatal("expected seeded admin user")
}
if err := data.UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("change admin password: %v", err)
}
}
func TestInstallDefaults(t *testing.T) { func TestInstallDefaults(t *testing.T) {
d := InstallDefaults() d := InstallDefaults()
if d.AppName != "epusdt" { if d.AppName != "epusdt" {
@@ -43,7 +123,7 @@ func TestWriteEnvFile(t *testing.T) {
OrderExpirationTime: 15, OrderExpirationTime: 15,
OrderNoticeMaxRetry: 3, OrderNoticeMaxRetry: 3,
} }
if err := writeEnvFile(path, req); err != nil { if err := writeEnvFile(path, req, false); err != nil {
t.Fatalf("writeEnvFile: %v", err) t.Fatalf("writeEnvFile: %v", err)
} }
@@ -144,29 +224,30 @@ func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
} }
} }
func TestInstallAPISubmit(t *testing.T) { func TestInstallAPISubmitReturnsInitialPassword(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, h := newInstallTestAPI(envPath)
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}` payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload)) rec := submitInstallRequest(t, e, payload)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
} }
// done channel should be closed after successful submit body := decodeBody(t, rec)
select { if got, _ := body["message"].(string); got == "" {
case <-h.done: t.Fatalf("expected success message, got body=%v", body)
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
} }
initPassword, _ := body["init_password"].(string)
if strings.TrimSpace(initPassword) == "" {
t.Fatalf("expected non-empty init_password, got body=%v", body)
}
assertDoneClosed(t, h.done)
data, err := os.ReadFile(envPath) data, err := os.ReadFile(envPath)
if err != nil { if err != nil {
t.Fatalf("env file not written: %v", err) t.Fatalf("env file not written: %v", err)
@@ -178,20 +259,107 @@ func TestInstallAPISubmit(t *testing.T) {
if !strings.Contains(content, "http_listen=0.0.0.0:8000") { if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
t.Errorf("env file missing http_listen; content:\n%s", content) t.Errorf("env file missing http_listen; content:\n%s", content)
} }
if !strings.Contains(content, "install=false") {
t.Errorf("env file missing install=false; content:\n%s", content)
}
}
func TestInstallAPISubmitRepeatInstallReturnsSamePasswordWhilePlaintextExists(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
e1, h1 := newInstallTestAPI(envPath)
rec1 := submitInstallRequest(t, e1, payload)
if rec1.Code != http.StatusOK {
t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String())
}
body1 := decodeBody(t, rec1)
password1, _ := body1["init_password"].(string)
if strings.TrimSpace(password1) == "" {
t.Fatalf("expected first init_password, got body=%v", body1)
}
assertDoneClosed(t, h1.done)
e2, h2 := newInstallTestAPI(envPath)
rec2 := submitInstallRequest(t, e2, payload)
if rec2.Code != http.StatusOK {
t.Fatalf("second install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String())
}
body2 := decodeBody(t, rec2)
password2, _ := body2["init_password"].(string)
if password2 != password1 {
t.Fatalf("expected repeat install to return same init_password %q, got %q", password1, password2)
}
assertDoneClosed(t, h2.done)
}
func TestInstallAPISubmitRepeatInstallOmitsPasswordAfterPasswordChange(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
e1, h1 := newInstallTestAPI(envPath)
rec1 := submitInstallRequest(t, e1, payload)
if rec1.Code != http.StatusOK {
t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String())
}
assertDoneClosed(t, h1.done)
changeInstalledAdminPassword(t, envPath, "new-password-456")
e2, h2 := newInstallTestAPI(envPath)
rec2 := submitInstallRequest(t, e2, payload)
if rec2.Code != http.StatusOK {
t.Fatalf("repeat install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String())
}
body2 := decodeBody(t, rec2)
if got, ok := body2["init_password"]; ok {
t.Fatalf("expected init_password to be omitted after password change, got %v", got)
}
if got, _ := body2["message"].(string); got == "" {
t.Fatalf("expected success message, got body=%v", body2)
}
assertDoneClosed(t, h2.done)
}
func TestInstallAPISubmitInitFailureKeepsInstallMode(t *testing.T) {
resetInstallTestState(t)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
e, h := newInstallTestAPI(envPath)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"runtime_root_path":"/dev/null/runtime","order_expiration_time":10,"order_notice_max_retry":1}`
rec := submitInstallRequest(t, e, payload)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body: %s", rec.Code, rec.Body.String())
}
select {
case <-h.done:
t.Fatal("handler should not close done channel on init failure")
case <-time.After(100 * time.Millisecond):
}
contentBytes, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("read env after init failure: %v", err)
}
content := string(contentBytes)
if !strings.Contains(content, "install=true") {
t.Fatalf("expected env to keep install=true after failure; content:\n%s", content)
}
} }
func TestInstallAPISubmitMissingURI(t *testing.T) { func TestInstallAPISubmitMissingURI(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, _ := newInstallTestAPI(envPath)
e := echo.New() rec := submitInstallRequest(t, e, `{"app_name":"x"}`)
e.POST("/install", h.Submit)
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code) t.Fatalf("status = %d, want 400", rec.Code)
@@ -205,15 +373,8 @@ func TestInstallAPISubmitInvalidPort(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})} e, _ := newInstallTestAPI(envPath)
e := echo.New() rec := submitInstallRequest(t, e, `{"app_uri":"http://example.com","http_bind_port":99999}`)
e.POST("/install", h.Submit)
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
+3
View File
@@ -27,6 +27,9 @@ const (
func CheckAdminJWT() echo.MiddlewareFunc { func CheckAdminJWT() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
if ctx.Path() == "" {
return next(ctx)
}
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization")) token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
if err != nil { if err != nil {
return err return err
+10
View File
@@ -14,6 +14,12 @@ import (
var once sync.Once var once sync.Once
// ResetMdbTableInitForTest resets the install/startup migration guard so tests
// can exercise fresh temporary databases in the same process.
func ResetMdbTableInitForTest() {
once = sync.Once{}
}
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds // MdbTableInit performs AutoMigrate for all primary DB tables and seeds
// the minimum static rows: chains, chain tokens, default settings, and // the minimum static rows: chains, chain tokens, default settings, and
// a Telegram notification channel migrated from legacy settings keys. // a Telegram notification channel migrated from legacy settings keys.
@@ -65,6 +71,7 @@ func seedChains() {
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5}, {Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5}, {Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5}, {Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkAptos, DisplayName: "Aptos", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
} }
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil { if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chains err=%s\n", err) color.Red.Printf("[store_db] seed chains err=%s\n", err)
@@ -102,6 +109,8 @@ func seedChainTokens() {
// TON // TON
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true}, {Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true}, {Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
} }
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil { if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err) color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
@@ -138,6 +147,7 @@ func defaultRpcNodes() []mdb.RpcNode {
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkAptos, Url: "https://aptos-rest.publicnode.com/", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
+91
View File
@@ -2,6 +2,7 @@ package dao
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
@@ -74,6 +75,80 @@ func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
} }
} }
func TestDefaultRpcNodesIncludesAptosPublicNode(t *testing.T) {
var got *mdb.RpcNode
nodes := defaultRpcNodes()
for i := range nodes {
node := nodes[i]
if node.Network == mdb.NetworkAptos {
got = &node
break
}
}
if got == nil {
t.Fatal("missing Aptos seed rpc node")
}
if got.Url != "https://aptos-rest.publicnode.com/" {
t.Fatalf("Aptos seed url = %q", got.Url)
}
if got.Type != mdb.RpcNodeTypeHttp {
t.Fatalf("Aptos seed type = %q, want %q", got.Type, mdb.RpcNodeTypeHttp)
}
if got.Purpose != mdb.RpcNodePurposeGeneral {
t.Fatalf("Aptos seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
}
if !got.Enabled {
t.Fatal("Aptos seed enabled = false, want true")
}
if got.Status != mdb.RpcNodeStatusUnknown {
t.Fatalf("Aptos seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
}
}
func TestSeedChainsIncludesAptos(t *testing.T) {
db := setupSeedTableTestDB(t, &mdb.Chain{})
Mdb = db
seedChains()
var row mdb.Chain
if err := Mdb.Where("network = ?", mdb.NetworkAptos).Take(&row).Error; err != nil {
t.Fatalf("load Aptos chain seed: %v", err)
}
if !row.Enabled {
t.Fatal("Aptos chain enabled = false, want true")
}
}
func TestSeedChainTokensIncludesAptosAssets(t *testing.T) {
db := setupSeedTableTestDB(t, &mdb.ChainToken{})
Mdb = db
seedChainTokens()
wantEnabled := map[string]bool{
mdb.NetworkAptos + "/USDC": true,
mdb.NetworkAptos + "/USDT": true,
}
wantContract := map[string]string{
mdb.NetworkAptos + "/USDT": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b",
}
for key, enabled := range wantEnabled {
parts := strings.Split(key, "/")
var row mdb.ChainToken
if err := Mdb.Where("network = ? AND symbol = ?", parts[0], parts[1]).Take(&row).Error; err != nil {
t.Fatalf("load token seed %s: %v", key, err)
}
if row.Enabled != enabled {
t.Fatalf("%s enabled = %v, want %v", key, row.Enabled, enabled)
}
if want, ok := wantContract[key]; ok && row.ContractAddress != want {
t.Fatalf("%s contract_address = %q, want %q", key, row.ContractAddress, want)
}
}
}
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) { func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
db := setupSeedSettingsTestDB(t) db := setupSeedSettingsTestDB(t)
Mdb = db Mdb = db
@@ -194,3 +269,19 @@ func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
} }
return db return db
} }
func setupSeedTableTestDB(t *testing.T, models ...interface{}) *gorm.DB {
t.Helper()
oldDB := Mdb
t.Cleanup(func() {
Mdb = oldDB
})
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-table.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(models...); err != nil {
t.Fatalf("migrate seed table: %v", err)
}
return db
}
+128 -85
View File
@@ -40,39 +40,47 @@ type InitialAdminPasswordHashInfo struct {
// can print it to the console. Idempotent — subsequent calls return // can print it to the console. Idempotent — subsequent calls return
// ("", false, nil). // ("", false, nil).
func EnsureDefaultAdmin() (password string, created bool, err error) { func EnsureDefaultAdmin() (password string, created bool, err error) {
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil { if err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := purgeDeletedInitialAdminPasswordPlainTx(tx); err != nil {
return err
}
var count int64
if err := tx.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := tx.Create(user).Error; err != nil {
return err
}
if err := initAdminPasswordStateTx(tx, password); err != nil {
return err
}
created = true
return nil
}); err != nil {
return "", false, err return "", false, err
} }
var count int64 if created {
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil { cacheInitialAdminPasswordState(password)
return "", false, err
} }
if count > 0 { return password, created, nil
return "", false, nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return "", false, err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
return "", false, err
}
if err := initAdminPasswordState(password); err != nil {
// Account was already created; surface both for visibility while
// preserving created=true and password for emergency fallback.
return password, true, err
}
return password, true, nil
} }
func purgeDeletedInitialAdminPasswordPlain() error { func purgeDeletedInitialAdminPasswordPlainTx(tx *gorm.DB) error {
return dao.Mdb.Unscoped(). return tx.Unscoped().
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain). Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
Delete(&mdb.Setting{}).Error Delete(&mdb.Setting{}).Error
} }
@@ -91,12 +99,20 @@ func HashInitialAdminPassword(plain string) string {
} }
func initAdminPasswordState(plain string) error { func initAdminPasswordState(plain string) error {
if err := initAdminPasswordStateTx(dao.Mdb, plain); err != nil {
return err
}
cacheInitialAdminPasswordState(plain)
return nil
}
func initAdminPasswordStateTx(tx *gorm.DB, plain string) error {
hash := HashInitialAdminPassword(plain) hash := HashInitialAdminPassword(plain)
settings := []mdb.Setting{ settings := []mdb.Setting{
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: plain, Type: mdb.SettingTypeString, Value: plain, Type: mdb.SettingTypeString,
Description: "One-time readable initial admin password", Description: "Readable initial admin password until password change",
}, },
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
@@ -106,7 +122,7 @@ func initAdminPasswordState(plain string) error {
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "false", Type: mdb.SettingTypeBool, Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched", Description: "Whether initial admin password plaintext has been cleared",
}, },
{ {
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged, Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
@@ -115,18 +131,21 @@ func initAdminPasswordState(plain string) error {
}, },
} }
for _, row := range settings { for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil { if err := upsertSettingRow(tx, row); err != nil {
return err return err
} }
} }
// Keep in-process cache coherent for the current process. return nil
}
func cacheInitialAdminPasswordState(plain string) {
hash := HashInitialAdminPassword(plain)
settingsCacheMu.Lock() settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false" settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false" settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock() settingsCacheMu.Unlock()
return nil
} }
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error { func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
@@ -138,56 +157,51 @@ func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
}).Create(&row).Error }).Create(&row).Error
} }
// ConsumeInitialAdminPassword returns the one-time initial admin password. // GetInitialAdminPassword returns the initial admin password plaintext while
// After a successful read, the plaintext is deleted and cannot be fetched // it is still available. The plaintext remains readable until the admin
// again. // password is changed, after which the stored plaintext is deleted.
func ConsumeInitialAdminPassword() (string, error) { func GetInitialAdminPassword() (string, error) {
var password string row := new(mdb.Setting)
err := dao.Mdb.Transaction(func(tx *gorm.DB) error { if err := dao.Mdb.Model(&mdb.Setting{}).
row := new(mdb.Setting) Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
if err := tx.Model(&mdb.Setting{}). Limit(1).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). Find(row).Error; err != nil {
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInitAdminPasswordAlreadyFetched
}
return upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
})
})
if err != nil {
return "", err return "", err
} }
settingsCacheMu.Lock() if row.ID != 0 && row.Value != "" {
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) return row.Value, nil
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" }
settingsCacheMu.Unlock()
return password, nil var fetched mdb.Setting
if err := dao.Mdb.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return "", err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return "", ErrInitAdminPasswordAlreadyFetched
}
return "", ErrInitAdminPasswordUnavailable
}
func clearInitialAdminPasswordPlain(tx *gorm.DB) error {
res := tx.Unscoped().
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if err := upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password plaintext has been cleared",
}); err != nil {
return err
}
return nil
} }
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint // GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
@@ -250,12 +264,15 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
return u, err return u, err
} }
// UpdateAdminUserPassword rehashes and persists a new password. // UpdateAdminUserPassword rehashes and persists a new password. When the
// change succeeds, the stored initial-password plaintext is deleted so it can
// no longer be returned by the install flow.
func UpdateAdminUserPassword(id uint64, newPlain string) error { func UpdateAdminUserPassword(id uint64, newPlain string) error {
hash, err := HashPassword(newPlain) hash, err := HashPassword(newPlain)
if err != nil { if err != nil {
return err return err
} }
clearedPlaintext := false
changedCacheValue := "" changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error { err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}). if err := tx.Model(&mdb.AdminUser{}).
@@ -264,8 +281,18 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
return err return err
} }
var plainRow mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(&plainRow).Error; err != nil {
return err
}
hasPlaintext := plainRow.ID != 0 && strings.TrimSpace(plainRow.Value) != ""
// Old installs may not have initial-password metadata; skip in // Old installs may not have initial-password metadata; skip in
// that case to keep password updates backward compatible. // that case to keep password_changed backward compatible, but still
// clear any leftover plaintext if it exists.
var initHashRow mdb.Setting var initHashRow mdb.Setting
if err := tx.Model(&mdb.Setting{}). if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash). Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
@@ -275,6 +302,12 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
} }
initHash := strings.TrimSpace(initHashRow.Value) initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" { if initHash == "" {
if hasPlaintext {
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
}
return nil return nil
} }
@@ -293,15 +326,25 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
}); err != nil { }); err != nil {
return err return err
} }
if err := clearInitialAdminPasswordPlain(tx); err != nil {
return err
}
clearedPlaintext = true
changedCacheValue = changedValue changedCacheValue = changedValue
return nil return nil
}) })
if err != nil { if err != nil {
return err return err
} }
if changedCacheValue != "" { if clearedPlaintext || changedCacheValue != "" {
settingsCacheMu.Lock() settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue if clearedPlaintext {
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
}
if changedCacheValue != "" {
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
}
settingsCacheMu.Unlock() settingsCacheMu.Unlock()
} }
return nil return nil
+150 -5
View File
@@ -42,7 +42,7 @@ func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
} }
} }
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) { func TestGetInitialAdminPasswordKeepsPlaintext(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -51,14 +51,58 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("seed initial password state: %v", err) t.Fatalf("seed initial password state: %v", err)
} }
got, err := ConsumeInitialAdminPassword() got, err := GetInitialAdminPassword()
if err != nil { if err != nil {
t.Fatalf("consume initial password: %v", err) t.Fatalf("get initial password: %v", err)
} }
if got != password { if got != password {
t.Fatalf("password = %q, want %q", got, password) t.Fatalf("password = %q, want %q", got, password)
} }
var count int64
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&count).Error; err != nil {
t.Fatalf("count plaintext setting: %v", err)
}
if count != 1 {
t.Fatalf("plaintext setting rows after read = %d, want 1", count)
}
if GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to stay false before password change")
}
}
func TestUpdateAdminUserPasswordHardDeletesInitialPasswordPlaintext(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
oldPassword = "init-pass-plain"
newPassword = "new-password-123"
)
if err := initAdminPasswordState(oldPassword); err != nil {
t.Fatalf("seed initial password state: %v", err)
}
hash, err := HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
t.Fatalf("seed admin user: %v", err)
}
if err := UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("update admin password: %v", err)
}
var count int64 var count int64
if err := dao.Mdb.Unscoped(). if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}). Model(&mdb.Setting{}).
@@ -67,10 +111,59 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
t.Fatalf("count plaintext setting: %v", err) t.Fatalf("count plaintext setting: %v", err)
} }
if count != 0 { if count != 0 {
t.Fatalf("plaintext setting rows after consume = %d, want 0", count) t.Fatalf("plaintext setting rows after password change = %d, want 0", count)
} }
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) { if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to be true") t.Fatal("expected fetched flag to be true after password change")
}
}
func TestUpdateAdminUserPasswordHardDeletesPlaintextWithoutHashMetadata(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
oldPassword = "legacy-init-pass"
newPassword = "new-password-456"
)
if err := upsertSettingRow(dao.Mdb, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: oldPassword,
Type: mdb.SettingTypeString,
}); err != nil {
t.Fatalf("seed plaintext setting: %v", err)
}
hash, err := HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
t.Fatalf("seed admin user: %v", err)
}
if err := UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil {
t.Fatalf("update admin password: %v", err)
}
var count int64
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&count).Error; err != nil {
t.Fatalf("count plaintext setting: %v", err)
}
if count != 0 {
t.Fatalf("plaintext setting rows after password change = %d, want 0", count)
}
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
t.Fatal("expected fetched flag to be true after clearing plaintext")
} }
} }
@@ -120,3 +213,55 @@ func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count) t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
} }
} }
func TestEnsureDefaultAdminRollsBackWhenInitialPasswordStateFails(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Exec(`
CREATE TRIGGER fail_initial_password_state
BEFORE INSERT ON settings
WHEN NEW.key = 'system.init_admin_password_plain'
BEGIN
SELECT RAISE(FAIL, 'forced initial password state failure');
END;
`).Error; err != nil {
t.Fatalf("create failure trigger: %v", err)
}
password, created, err := EnsureDefaultAdmin()
if err == nil {
t.Fatal("expected EnsureDefaultAdmin to fail")
}
if created || password != "" {
t.Fatalf("created=%v password=%q, want no created admin on failure", created, password)
}
var adminCount int64
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&adminCount).Error; err != nil {
t.Fatalf("count admin users after failure: %v", err)
}
if adminCount != 0 {
t.Fatalf("admin users after failed initial password state = %d, want 0", adminCount)
}
if err := dao.Mdb.Exec(`DROP TRIGGER fail_initial_password_state`).Error; err != nil {
t.Fatalf("drop failure trigger: %v", err)
}
password, created, err = EnsureDefaultAdmin()
if err != nil {
t.Fatalf("retry EnsureDefaultAdmin: %v", err)
}
if !created || password == "" {
t.Fatalf("retry created=%v password=%q, want new admin with password", created, password)
}
got, err := GetInitialAdminPassword()
if err != nil {
t.Fatalf("get initial password after retry: %v", err)
}
if got != password {
t.Fatalf("initial password after retry = %q, want %q", got, password)
}
}
+5
View File
@@ -49,6 +49,11 @@ func normalizeLockAddress(network, address string) string {
return raw return raw
} }
} }
if network == mdb.NetworkAptos {
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
return normalized
}
}
return address return address
} }
+16
View File
@@ -184,3 +184,19 @@ func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID) t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
} }
} }
func TestAptosTransactionLockAddressUsesCanonicalKey(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := LockTransaction(mdb.NetworkAptos, "0xA", "USDT", "trade-aptos", 1.23, time.Hour); err != nil {
t.Fatalf("lock aptos transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, "a", "USDT", 1.23)
if err != nil {
t.Fatalf("lookup aptos lock: %v", err)
}
if gotTradeID != "trade-aptos" {
t.Fatalf("aptos lock lookup = %q, want trade-aptos", gotTradeID)
}
}
+3
View File
@@ -41,6 +41,9 @@ func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
if network == mdb.NetworkTon { if network == mdb.NetworkTon {
return addressutil.NormalizeTonAddress(address) return addressutil.NormalizeTonAddress(address)
} }
if network == mdb.NetworkAptos {
return addressutil.NormalizeMoveAddress(address)
}
return normalizeWalletAddressByNetwork(network, address), nil return normalizeWalletAddressByNetwork(network, address), nil
} }
@@ -103,3 +103,20 @@ func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err) t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
} }
} }
func TestAddWalletAddressWithNetworkNormalizesMoveAddressVariants(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
want := "0x000000000000000000000000000000000000000000000000000000000000000a"
row, err := AddWalletAddressWithNetwork(mdb.NetworkAptos, " A ")
if err != nil {
t.Fatalf("add aptos wallet: %v", err)
}
if row.Address != want {
t.Fatalf("stored Aptos address = %q, want %q", row.Address, want)
}
if _, err = AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x0A"); err != constant.WalletAddressAlreadyExists {
t.Fatalf("add equivalent aptos wallet error = %v, want already exists", err)
}
}
+5 -4
View File
@@ -5,10 +5,11 @@ const (
ChainTokenStatusDisable = 2 ChainTokenStatusDisable = 2
) )
// ChainToken describes a token to watch on a given chain. Replaces the // ChainToken describes a token to watch on a given chain. For EVM/TRON rows
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners // ContractAddress is the token contract; for Solana it is the mint; for
// load this table on startup (and refresh periodically) to learn which // TON it is the jetton master; for Aptos it is the Move asset identifier
// contracts to subscribe to. // (coin type, metadata address or object type). Native rows use an empty
// ContractAddress.
type ChainToken struct { type ChainToken struct {
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"` Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"` Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
+1
View File
@@ -51,6 +51,7 @@ type Orders struct {
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"` RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
Name string `gorm:"column:name" json:"name" example:"VIP月卡"` Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
EpayType string `gorm:"column:epay_type;size:64;default:''" json:"-"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
// 回调确认状态 1=回调成功 2=未回调/回调失败 // 回调确认状态 1=回调成功 2=未回调/回调失败
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"` CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
+1
View File
@@ -13,6 +13,7 @@ const (
NetworkPolygon = "polygon" NetworkPolygon = "polygon"
NetworkPlasma = "plasma" NetworkPlasma = "plasma"
NetworkTon = "ton" NetworkTon = "ton"
NetworkAptos = "aptos"
) )
const ( 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"` OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单 Token string `json:"token" form:"token" 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"` Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"` NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."` Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
@@ -18,6 +18,7 @@ type CreateTransactionRequest struct {
// empty or any other value is stored as "Gmpay" and uses GMPay JSON. // 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. // It is optional for GMPay, but must be included in the signature when sent.
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"` PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
EpayType string `json:"-" form:"-"`
} }
func (r CreateTransactionRequest) Translates() map[string]string { func (r CreateTransactionRequest) Translates() map[string]string {
@@ -58,7 +59,7 @@ func (r ManualPaymentRequest) Translates() map[string]string {
type SwitchNetworkRequest struct { type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
Token string `json:"token" validate:"required" example:"USDT"` Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"` Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum,aptos"`
} }
func (r SwitchNetworkRequest) Translates() map[string]string { func (r SwitchNetworkRequest) Translates() map[string]string {
+3 -3
View File
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
} }
type EpayPublicConfig struct { type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent. DefaultToken string `json:"default_token" example:""` // 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; falls back to cny when unset. 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; empty means submit.php can create a status=4 placeholder when request token/network are also absent. DefaultNetwork string `json:"default_network" example:""` // EPay default network; ignored when a supported type=token.network selector is supplied. Empty means submit.php can create a status=4 placeholder when request token/network are also absent.
} }
type SitePublicConfig struct { type SitePublicConfig struct {
+526
View File
@@ -0,0 +1,526 @@
package service
import (
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
addressutil "github.com/GMWalletApp/epusdt/util/address"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/tidwall/gjson"
)
func AptosGetLedgerVersion() (int64, error) {
body, err := aptosGet("/v1")
if err != nil {
return 0, err
}
latest := gjson.GetBytes(body, "ledger_version").Int()
log.Sugar.Debugf("[APTOS] latest ledger version=%d", latest)
return latest, nil
}
func AptosGetTransactionByHash(txID string) ([]byte, error) {
txID = normalizeAptosTxID(txID)
log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s", txID)
return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
}
func AptosGetTransactions(start int64, limit int64) ([]byte, error) {
if start < 0 {
start = 0
}
if limit <= 0 {
limit = 1
}
log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d", start, limit)
return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit))
}
func ParseAptosTransfers(body []byte, address string, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
receive, err := addressutil.NormalizeMoveAddress(address)
if err != nil {
return nil, err
}
return ParseAptosTransfersForWallets(body, map[string]struct{}{receive: {}}, tokens)
}
func ParseAptosTransfersForWallets(body []byte, wallets map[string]struct{}, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
root := gjson.ParseBytes(body)
if !root.IsArray() {
return nil, fmt.Errorf("unexpected Aptos transactions response")
}
var out []MoveObservedTransfer
for _, tx := range root.Array() {
if tx.Get("type").String() != "" && tx.Get("type").String() != "user_transaction" {
continue
}
if !tx.Get("success").Bool() {
continue
}
txID := strings.TrimSpace(tx.Get("hash").String())
if txID == "" {
continue
}
blockTimeMs := parseAptosTimestampMs(tx.Get("timestamp"))
version := tx.Get("version").Int()
txctx := buildAptosTransferContext(tx)
events := buildAptosFungibleEvents(tx, txctx, tokens)
deposits := make([]aptosFungibleEvent, 0)
withdrawals := make(map[string]int)
for _, event := range events {
if event.token == nil {
continue
}
switch event.action {
case "withdraw":
withdrawals[aptosTransferMatchKey(event.token.Symbol, event.rawAmount)]++
case "deposit":
deposits = append(deposits, event)
}
}
usedDeposits := make([]bool, len(deposits))
for i, event := range deposits {
matchKey := aptosTransferMatchKey(event.token.Symbol, event.rawAmount)
if withdrawals[matchKey] <= 0 {
continue
}
if appendAptosObservedTransfer(&out, txID, version, blockTimeMs, event, wallets) {
withdrawals[matchKey]--
usedDeposits[i] = true
} else {
logAptosUnwatchedDeposit(txID, version, event, "direct")
}
}
for i := 0; i < len(deposits); i++ {
if usedDeposits[i] {
continue
}
for j := i + 1; j < len(deposits); j++ {
if usedDeposits[j] || !aptosDepositPairCanMatch(deposits[i], deposits[j]) {
continue
}
sum := new(big.Int).Add(deposits[i].rawAmount, deposits[j].rawAmount)
matchKey := aptosTransferMatchKey(deposits[i].token.Symbol, sum)
if withdrawals[matchKey] <= 0 {
continue
}
appendedFirst := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[i], wallets)
appendedSecond := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[j], wallets)
if !appendedFirst && !appendedSecond {
logAptosUnwatchedDeposit(txID, version, deposits[i], "split")
logAptosUnwatchedDeposit(txID, version, deposits[j], "split")
continue
}
withdrawals[matchKey]--
usedDeposits[i] = true
usedDeposits[j] = true
break
}
}
}
return out, nil
}
func appendAptosObservedTransfer(out *[]MoveObservedTransfer, txID string, version int64, blockTimeMs int64, event aptosFungibleEvent, wallets map[string]struct{}) bool {
recipient := event.owner
if !moveWalletWatched(recipient, wallets) {
return false
}
*out = append(*out, MoveObservedTransfer{
Network: mdb.NetworkAptos,
ReceiveAddress: recipient,
Token: NormalizeAptosPaymentSymbol(event.token.Symbol),
RawAmount: event.rawAmount,
Decimals: event.token.Decimals,
MinAmount: event.token.MinAmount,
Amount: rawToFloat(event.rawAmount, event.token.Decimals),
TxID: normalizeAptosTxID(txID),
BlockTimeMs: blockTimeMs,
Version: version,
TransferKey: moveAptosTransferKey(version, event.eventIndex, recipient, event.token.Symbol, event.rawAmount),
})
return true
}
func logAptosUnwatchedDeposit(txID string, version int64, event aptosFungibleEvent, matchMode string) {
log.Sugar.Debugf(
"[APTOS] matched deposit skipped owner_not_watched mode=%s version=%d tx=%s token=%s amount=%.8f raw=%s owner=%s",
matchMode,
version,
normalizeAptosTxID(txID),
NormalizeAptosPaymentSymbol(event.token.Symbol),
rawToFloat(event.rawAmount, event.token.Decimals),
event.rawAmount.String(),
event.owner,
)
}
func aptosDepositPairCanMatch(first, second aptosFungibleEvent) bool {
if first.token == nil || second.token == nil || first.rawAmount == nil || second.rawAmount == nil {
return false
}
if NormalizeAptosPaymentSymbol(first.token.Symbol) != NormalizeAptosPaymentSymbol(second.token.Symbol) {
return false
}
return first.token.ContractAddress == second.token.ContractAddress
}
type aptosFungibleEvent struct {
eventIndex int
action string
owner string
rawAmount *big.Int
token *mdb.ChainToken
}
func buildAptosFungibleEvents(tx gjson.Result, ctx aptosTransferContext, tokens []mdb.ChainToken) []aptosFungibleEvent {
out := make([]aptosFungibleEvent, 0)
for eventIndex, event := range tx.Get("events").Array() {
action := aptosFungibleEventAction(event)
if action == "" {
continue
}
rawAmount, ok := parseSignedBigInt(aptosEventRawAmount(event, ctx))
if !ok || rawAmount.Sign() <= 0 {
continue
}
token := resolveAptosPaymentToken(aptosEventAssetID(event, ctx), tokens)
if token == nil {
continue
}
out = append(out, aptosFungibleEvent{
eventIndex: eventIndex,
action: action,
owner: aptosEventRecipient(event, ctx),
rawAmount: rawAmount,
token: token,
})
}
return out
}
func aptosFungibleEventAction(event gjson.Result) string {
typ := strings.ToLower(strings.TrimSpace(event.Get("type").String()))
switch typ {
case "0x1::fungible_asset::deposit":
return "deposit"
case "0x1::fungible_asset::withdraw":
return "withdraw"
default:
return ""
}
}
func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string {
raw := ""
if rawAmount != nil {
raw = rawAmount.String()
}
return NormalizeAptosPaymentSymbol(symbol) + ":" + raw
}
func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
return resolveMoveToken(mdb.NetworkAptos, assetID, tokens)
}
func NormalizeAptosPaymentSymbol(symbol string) string {
return strings.ToUpper(strings.TrimSpace(symbol))
}
type aptosTransferContext struct {
storeOwners map[string]string
storeMetadata map[string]string
}
func buildAptosTransferContext(tx gjson.Result) aptosTransferContext {
ctx := aptosTransferContext{
storeOwners: make(map[string]string),
storeMetadata: make(map[string]string),
}
for _, change := range tx.Get("changes").Array() {
if change.Get("type").String() != "write_resource" {
continue
}
store, err := addressutil.NormalizeMoveAddress(change.Get("address").String())
if err != nil {
continue
}
changeType := strings.TrimSpace(change.Get("data.type").String())
switch changeType {
case "0x1::fungible_asset::FungibleStore":
if metadata := firstNonEmpty(
change.Get("data.data.metadata.inner").String(),
change.Get("data.data.metadata").String(),
); metadata != "" {
ctx.storeMetadata[store] = normalizeAptosObservedAssetID(metadata)
}
case "0x1::object::ObjectCore":
if owner := firstMoveAddress(change.Get("data.data.owner").String()); owner != "" {
ctx.storeOwners[store] = owner
}
}
}
return ctx
}
func aptosEventRecipient(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if owner := ctx.storeOwners[store]; owner != "" {
return owner
}
}
return ""
}
func aptosEventRawAmount(event gjson.Result, ctx aptosTransferContext) string {
return event.Get("data.amount").String()
}
func aptosEventAssetID(event gjson.Result, ctx aptosTransferContext) string {
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
if metadata := ctx.storeMetadata[store]; metadata != "" {
return metadata
}
}
return ""
}
func normalizeAptosObservedAssetID(assetID string) string {
return addressutil.NormalizeMoveAssetID(assetID)
}
func parseAptosTimestampMs(value gjson.Result) int64 {
raw := strings.TrimSpace(value.String())
if raw == "" {
return 0
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return n / 1000
}
func normalizeAptosTxID(txID string) string {
txID = strings.ToLower(strings.TrimSpace(txID))
if strings.HasPrefix(txID, "0x") {
return txID
}
return "0x" + txID
}
func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) {
txID = normalizeAptosTxID(txID)
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue
}
if _, err = validateManualAptosPaymentWithNode(order, txID, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", data.RpcNodeLogLabel(node), err))
continue
}
return txID, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual Aptos verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
func validateManualAptosPaymentWithNode(order *mdb.Orders, txID string, node mdb.RpcNode) (string, error) {
txID = normalizeAptosTxID(txID)
body, err := aptosGetWithNode(node, fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
if err != nil {
return "", fmt.Errorf("fetch aptos transaction: %w", err)
}
if err = validateManualAptosTransactionWithConfirm(order, txID, body, func(version int64) error {
return ensureAptosTransferConfirmedWithNode(version, node)
}); err != nil {
return "", err
}
return txID, nil
}
func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error {
return validateManualAptosTransactionWithConfirm(order, txID, body, EnsureAptosTransferConfirmed)
}
func validateManualAptosTransactionWithConfirm(order *mdb.Orders, txID string, body []byte, confirm func(int64) error) error {
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
transfers, err := ParseAptosTransfers([]byte("["+string(body)+"]"), order.ReceiveAddress, tokens)
if err != nil {
return err
}
amountMismatch := false
for _, transfer := range transfers {
if !strings.EqualFold(transfer.TxID, txID) {
continue
}
if err = confirm(transfer.Version); err != nil {
return err
}
if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil {
return nil
}
if strings.Contains(err.Error(), "amount mismatch") {
amountMismatch = true
} else {
return err
}
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching aptos transfer to order address not found")
}
func aptosGet(path string) (body []byte, err error) {
network := mdb.NetworkAptos
defer recordMoveRPCResult(network, &err)
tried := make([]uint64, 0, 3)
var lastErr error
for attempts := 0; attempts < 3; attempts++ {
node, resolveErr := resolveAptosRpcNode(tried...)
if resolveErr != nil {
if lastErr != nil {
return nil, lastErr
}
return nil, resolveErr
}
if len(tried) > 0 {
log.Sugar.Warnf("[APTOS] trying alternate RPC node path=%s node=%s", path, data.RpcNodeLogLabel(*node))
}
body, err = aptosGetWithNode(*node, path)
if err == nil {
data.RecordRpcNodeSuccess(node.ID)
return body, nil
}
lastErr = err
failures, cooling := data.RecordRpcNodeFailure(node.ID)
nodeLabel := data.RpcNodeLogLabel(*node)
if cooling {
log.Sugar.Warnf("[APTOS] RPC node reached fail threshold path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
} else {
log.Sugar.Warnf("[APTOS] RPC node failed path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
}
tried = append(tried, node.ID)
}
return nil, lastErr
}
func resolveAptosRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkAptos, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 {
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
node.Url = rpcURL
return node, nil
}
func aptosGetWithNode(node mdb.RpcNode, path string) (body []byte, err error) {
endpoint, err := aptosJoinURL(node.Url, path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: manualVerifyRequestTimeout}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ = io.ReadAll(resp.Body)
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("aptos rpc HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func aptosJoinURL(base, path string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
return "", fmt.Errorf("empty aptos rpc url")
}
if strings.TrimSpace(path) == "" {
return strings.TrimRight(base, "/"), nil
}
path = strings.TrimSpace(path)
trimmedBase := strings.TrimRight(base, "/")
lowerBase := strings.ToLower(trimmedBase)
if strings.HasSuffix(lowerBase, "/v1") && strings.HasPrefix(path, "/v1") {
path = strings.TrimPrefix(path, "/v1")
if path == "" {
path = "/"
}
}
return trimmedBase + path, nil
}
func EnsureAptosTransferConfirmed(version int64) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
latest, err := AptosGetLedgerVersion()
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
return ensureAptosConfirmations(version, latest)
}
func ensureAptosTransferConfirmedWithNode(version int64, node mdb.RpcNode) error {
if version <= 0 {
return fmt.Errorf("aptos transaction version missing")
}
body, err := aptosGetWithNode(node, "/v1")
if err != nil {
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
}
latest := gjson.GetBytes(body, "ledger_version").Int()
return ensureAptosConfirmations(version, latest)
}
func ensureAptosConfirmations(version, latest int64) error {
chain, err := data.GetChainByNetwork(mdb.NetworkAptos)
if err != nil {
return err
}
minConfirmations := int64(1)
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = int64(chain.MinConfirmations)
}
if latest-version+1 < minConfirmations {
return fmt.Errorf("aptos transaction confirmations insufficient")
}
return nil
}
+11 -1
View File
@@ -71,6 +71,16 @@ func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
return row, nil 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) { func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) {
if order == nil || apiKeyRow == nil { if order == nil || apiKeyRow == nil {
return nil, constant.EPayReturnSignatureErr return nil, constant.EPayReturnSignatureErr
@@ -85,7 +95,7 @@ func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string
PID: pidInt, PID: pidInt,
TradeNo: order.TradeId, TradeNo: order.TradeId,
OutTradeNo: order.OrderId, OutTradeNo: order.OrderId,
Type: "alipay", Type: epayResultType(order),
Name: order.Name, Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount), Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS", TradeStatus: "TRADE_SUCCESS",
+25 -6
View File
@@ -40,12 +40,13 @@ func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) {
} }
} }
func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) { func TestBuildEPayResultParamsUsesOriginalType(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{ params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_params", TradeId: "trade_epay_params",
OrderId: "order_epay_params", OrderId: "order_epay_params",
Name: "VIP", Name: "VIP",
Amount: 1, Amount: 1,
EpayType: "usdt.tron",
}, &mdb.ApiKey{ }, &mdb.ApiKey{
Pid: "1001", Pid: "1001",
SecretKey: "epay-secret", SecretKey: "epay-secret",
@@ -58,7 +59,7 @@ func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) {
"pid": "1001", "pid": "1001",
"trade_no": "trade_epay_params", "trade_no": "trade_epay_params",
"out_trade_no": "order_epay_params", "out_trade_no": "order_epay_params",
"type": "alipay", "type": "usdt.tron",
"name": "VIP", "name": "VIP",
"money": "1.0000", "money": "1.0000",
"trade_status": "TRADE_SUCCESS", "trade_status": "TRADE_SUCCESS",
@@ -88,6 +89,24 @@ func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) {
} }
} }
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) { func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) {
_, err := BuildEPayResultParams(&mdb.Orders{ _, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_bad_pid", TradeId: "trade_bad_pid",
+4 -2
View File
@@ -89,6 +89,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
canonicalTxID, err = validateManualSolanaPayment(order, txID) canonicalTxID, err = validateManualSolanaPayment(order, txID)
case mdb.NetworkTon: case mdb.NetworkTon:
canonicalTxID, err = validateManualTonPayment(order, txID) canonicalTxID, err = validateManualTonPayment(order, txID)
case mdb.NetworkAptos:
canonicalTxID, err = ValidateManualAptosPayment(order, txID)
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
canonicalTxID, err = validateManualEvmPayment(order, txID) canonicalTxID, err = validateManualEvmPayment(order, txID)
default: default:
@@ -123,7 +125,7 @@ func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string)
func manualBlockTransactionIDIsHex(network string) bool { func manualBlockTransactionIDIsHex(network string) bool {
switch strings.ToLower(strings.TrimSpace(network)) { switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
return true return true
default: default:
return false return false
@@ -149,7 +151,7 @@ func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string
add(canonicalTxID) add(canonicalTxID)
switch network { switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X") body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body) body = strings.ToLower(body)
add("0x" + body) add("0x" + body)
@@ -113,6 +113,66 @@ func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
} }
} }
func TestValidateManualAptosPayment(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
t.Fatalf("create aptos chain: %v", err)
}
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkAptos,
Symbol: "USDC",
ContractAddress: usdc,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create aptos usdc token: %v", err)
}
receive, err := addressutil.NormalizeMoveAddress("0xa")
if err != nil {
t.Fatalf("normalize aptos receive: %v", err)
}
store, err := addressutil.NormalizeMoveAddress("0x11")
if err != nil {
t.Fatalf("normalize aptos store: %v", err)
}
senderStore, err := addressutil.NormalizeMoveAddress("0x12")
if err != nil {
t.Fatalf("normalize aptos sender store: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
case r.URL.Path == "/v1":
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
order := &mdb.Orders{
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
Network: mdb.NetworkAptos,
Token: "USDC",
ActualAmount: 4.2,
ReceiveAddress: receive,
}
got, err := ValidateManualAptosPayment(order, "ABC")
if err != nil {
t.Fatalf("ValidateManualAptosPayment(): %v", err)
}
if got != "0xabc" {
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
}
}
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) { func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c" receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
hash := strings.Repeat("a", 64) hash := strings.Repeat("a", 64)
+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)
}
}
+16
View File
@@ -54,6 +54,11 @@ func normalizeOrderAddressByNetwork(network, address string) string {
return normalized return normalized
} }
return address return address
case mdb.NetworkAptos:
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
return normalized
}
return address
default: default:
return address return address
} }
@@ -67,6 +72,9 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
if tokenRow == nil || tokenRow.ID == 0 { if tokenRow == nil || tokenRow.ID == 0 {
return nil, constant.SupportedAssetNotFound return nil, constant.SupportedAssetNotFound
} }
if !ChainTokenReadyForPayment(*tokenRow) {
return nil, constant.SupportedAssetNotFound
}
return tokenRow, nil return tokenRow, nil
} }
@@ -108,6 +116,10 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) { if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay paymentType = mdb.PaymentTypeEpay
} }
epayType := ""
if paymentType == mdb.PaymentTypeEpay {
epayType = strings.TrimSpace(req.EpayType)
}
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
@@ -138,6 +150,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL, NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
EpayType: epayType,
PaymentType: paymentType, PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey), ApiKeyID: apiKeyID(apiKey),
@@ -197,6 +210,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL, NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
EpayType: epayType,
PaymentType: paymentType, PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey), ApiKeyID: apiKeyID(apiKey),
@@ -629,6 +643,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
NotifyUrl: "", NotifyUrl: "",
RedirectUrl: parent.RedirectUrl, RedirectUrl: parent.RedirectUrl,
Name: parent.Name, Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
@@ -908,6 +923,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
NotifyUrl: "", NotifyUrl: "",
RedirectUrl: parent.RedirectUrl, RedirectUrl: parent.RedirectUrl,
Name: parent.Name, Name: parent.Name,
EpayType: parent.EpayType,
CallBackConfirm: mdb.CallBackConfirmOk, CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOkPay, PayProvider: mdb.PaymentProviderOkPay,
+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) { func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+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
}
}
+74 -2
View File
@@ -573,6 +573,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL, NotifyUrl: server.URL,
BlockTransactionId: "block_epay_sign", BlockTransactionId: "block_epay_sign",
EpayType: "usdt.tron",
PaymentType: "epay", PaymentType: "epay",
ApiKeyID: key.ID, ApiKeyID: key.ID,
} }
@@ -587,8 +588,8 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
if formPayload["sign_type"] != "MD5" { if formPayload["sign_type"] != "MD5" {
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"]) t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
} }
if formPayload["type"] != "alipay" { if formPayload["type"] != "usdt.tron" {
t.Fatalf("type = %q, want alipay", formPayload["type"]) t.Fatalf("type = %q, want usdt.tron", formPayload["type"])
} }
if formPayload["trade_status"] != "TRADE_SUCCESS" { if formPayload["trade_status"] != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"]) t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
@@ -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) { func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+19 -30
View File
@@ -290,9 +290,9 @@ func TestAdminChangePassword(t *testing.T) {
} }
} }
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint // TestAdminInitialPasswordMetadataFlow verifies that the public plaintext route
// and hash-based "password changed" detection flow. // is gone while the hash-based default-password detection flow still works.
func TestAdminInitPasswordFlow(t *testing.T) { func TestAdminInitialPasswordMetadataFlow(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
const initPassword = "init-pass-123456" const initPassword = "init-pass-123456"
@@ -320,14 +320,11 @@ func TestAdminInitPasswordFlow(t *testing.T) {
t.Fatalf("expected password_changed=false before change, got true") t.Fatalf("expected password_changed=false before change, got true")
} }
recFetch := doGet(e, "/admin/api/v1/auth/init-password") token := adminLogin(t, e, testAdminUsername, initPassword)
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{}) recFetch := doGetAdmin(e, "/admin/api/v1/auth/init-password", token)
if fetchData["username"] != testAdminUsername { if recFetch.Code != http.StatusNotFound {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"]) t.Fatalf("expected removed init-password route to return 404, got %d body=%s", recFetch.Code, recFetch.Body.String())
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
} }
var plaintextRows int64 var plaintextRows int64
if err := dao.Mdb.Unscoped(). if err := dao.Mdb.Unscoped().
@@ -336,27 +333,10 @@ func TestAdminInitPasswordFlow(t *testing.T) {
Count(&plaintextRows).Error; err != nil { Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows: %v", err) t.Fatalf("count init password plaintext rows: %v", err)
} }
if plaintextRows != 0 { if plaintextRows != 1 {
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows) t.Fatalf("expected init password plaintext to remain available, got %d rows", plaintextRows)
} }
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest {
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
}
var respFetch2 map[string]interface{}
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
t.Fatalf("unmarshal second fetch response: %v", err)
}
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
}
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
}
token := adminLogin(t, e, testAdminUsername, initPassword)
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token) recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe1 := assertOK(t, recMe1) respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{}) meData1, _ := respMe1["data"].(map[string]interface{})
@@ -376,6 +356,15 @@ func TestAdminInitPasswordFlow(t *testing.T) {
if got, _ := hashData2["password_changed"].(bool); !got { if got, _ := hashData2["password_changed"].(bool); !got {
t.Fatalf("expected password_changed=true after change, got %v", got) t.Fatalf("expected password_changed=true after change, got %v", got)
} }
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows after change: %v", err)
}
if plaintextRows != 0 {
t.Fatalf("expected init password plaintext to be hard-deleted after change, got %d rows", plaintextRows)
}
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token) recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe2 := assertOK(t, recMe2) respMe2 := assertOK(t, recMe2)
+50 -6
View File
@@ -15,12 +15,43 @@ import (
"github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
) )
// resolveEPayTypeSelector only claims the type as a selector when it maps to a
// currently supported payment asset. Non-selector values are handled by the
// caller, which now only accepts empty type or alipay before falling back to
// request token/network/default resolution.
func resolveEPayTypeSelector(rawType string) (string, string, bool, error) {
rawType = strings.TrimSpace(rawType)
if rawType == "" || strings.Count(rawType, ".") != 1 {
return "", "", false, nil
}
parts := strings.SplitN(rawType, ".", 2)
token := strings.TrimSpace(parts[0])
network := strings.ToLower(strings.TrimSpace(parts[1]))
if token == "" || network == "" {
return "", "", false, nil
}
if !data.IsChainEnabled(network) {
return "", "", false, nil
}
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
if err != nil {
return "", "", false, err
}
if tokenRow == nil || tokenRow.ID == 0 || !service.ChainTokenReadyForPayment(*tokenRow) {
return "", "", false, nil
}
return token, network, true, nil
}
// RegisterRoute 路由注册 // RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) { func RegisterRoute(e *echo.Echo) {
e.POST("/", func(c echo.Context) error { e.POST("/", func(c echo.Context) error {
@@ -130,16 +161,29 @@ func RegisterRoute(e *echo.Echo) {
money := getString(params, "money") money := getString(params, "money")
name := getString(params, "name") name := getString(params, "name")
epayType := strings.TrimSpace(getString(params, "type"))
notifyURL := getString(params, "notify_url") notifyURL := getString(params, "notify_url")
outTradeNo := getString(params, "out_trade_no") outTradeNo := getString(params, "out_trade_no")
returnURL := getString(params, "return_url") returnURL := getString(params, "return_url")
token := strings.TrimSpace(getString(params, "token")) selectorToken, selectorNetwork, selectorMatched, err := resolveEPayTypeSelector(epayType)
if token == "" { if err != nil {
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "") 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")) network := strings.TrimSpace(getString(params, "network"))
if network == "" { if selectorMatched {
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "") 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")) currency := strings.TrimSpace(getString(params, "currency"))
if currency == "" { if currency == "" {
@@ -168,6 +212,7 @@ func RegisterRoute(e *echo.Echo) {
} }
ctx.Set("request_body", body) ctx.Set("request_body", body)
ctx.Set(comm.EPayTypeContextKey, epayType)
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID) ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow) ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
@@ -218,7 +263,6 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT) // Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login) adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash) adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated // Authenticated
+310
View File
@@ -1272,6 +1272,9 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
if order.PaymentType != mdb.PaymentTypeEpay { if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("epay payment_type = %q, want %q", 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) checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" { if checkoutData["payment_type"] != "epay" {
t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"]) t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"])
@@ -1327,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) { func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -1365,12 +1462,120 @@ func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T
if order.PaymentType != mdb.PaymentTypeEpay { if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", 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) checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect { if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect {
t.Fatalf("checkout placeholder data = %#v", checkoutData) 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) { func TestEpaySubmitPhpRejectsPartialResolvedTokenNetwork(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -1428,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 // TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a
// graceful JSON error (not 500) when the trade_id doesn't exist. // graceful JSON error (not 500) when the trade_id doesn't exist.
func TestCheckStatus_NotFound(t *testing.T) { func TestCheckStatus_NotFound(t *testing.T) {
@@ -1656,6 +1895,33 @@ func TestPayReturn_PaidEpayRedirectsToMerchantWithSignedParams(t *testing.T) {
} }
} }
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) { func TestPayReturn_UnpaidEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{ tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{
@@ -2501,6 +2767,9 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
if parent.Status != mdb.StatusWaitPay || parent.PaymentType != mdb.PaymentTypeEpay || parent.Network != mdb.NetworkTron || parent.Token != "USDT" || parent.ReceiveAddress == "" { 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) 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" { if parent.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl) t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl)
} }
@@ -2616,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) { func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID")) shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
if shopID == "" { if shopID == "" {
+1
View File
@@ -15,6 +15,7 @@ func Start() {
go StartPlasmaWebSocketListener() go StartPlasmaWebSocketListener()
go StartTronBlockScannerListener() go StartTronBlockScannerListener()
go StartTonBlockScannerListener() go StartTonBlockScannerListener()
go StartAptosLedgerScannerListener()
c := cron.New() c := cron.New()
+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)
}
}
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-B6bKLdq-.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
@@ -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"./forbidden-DZPSchCU.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./not-found-error-CHHM1I1c.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
@@ -0,0 +1 @@
import{t as e}from"./general-error-Drsf0vjz.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./general-error-BoIfl_4Z.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-CuCw7L8t.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{em as n}from"./messages-Bp0bCjzp.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-fFIveJVd.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-Cm64cgS9.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{em as e}from"./messages-Bp0bCjzp.js";import{m as t}from"./search-provider-Bl2HDfcG.js";import{n,t as r}from"./theme-switch-B3Qb32n8.js";import{t as i}from"./general-error-BoIfl_4Z.js";import{t as a}from"./not-found-error-CHHM1I1c.js";import{t as o}from"./_error-CeoUllM-.js";import{t as s}from"./unauthorized-error-B6bKLdq-.js";import{t as c}from"./forbidden-DZPSchCU.js";import{t as l}from"./maintenance-error-CuCw7L8t.js";import{n as u,r as d,t as f}from"./header-D45l3Ai7.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
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-BSLzv73v.js","assets/search-provider-Bl2HDfcG.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-CKFLmh1A.js","assets/messages-Bp0bCjzp.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-YmxNnOr3.js","assets/button-BgMnOYKD.js","assets/clsx-D9aGYY3V.js","assets/dist-CPQ-sRtL.js","assets/dist-_ND6L-P3.js","assets/dist-B0ikDpJU.js","assets/dist-BFnxq45V.js","assets/es2015-D8VLlhse.js","assets/dist-Clua1vrx.js","assets/dist-C97YBjnT.js","assets/dist-036CEgdV.js","assets/dist-D7AUoOWu.js","assets/dist-C6i4A_Js.js","assets/dist-BixeH3nX.js","assets/dist-DGDEBCW9.js","assets/separator-DbmFQXe4.js","assets/tooltip-Gx9CUpPD.js","assets/dist-BSXD7MjW.js","assets/dist-Bmn8KNt7.js","assets/auth-store-Csn6HPxJ.js","assets/dist-BGqHIBUe.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-CIv3ggHf.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-BJ5VA2v_.js","assets/skeleton-BQsmx80h.js","assets/wallet-Bgblr4kl.js","assets/sun-JU8p4FoE.js","assets/shield-check-MAPDL1u8.js","assets/logo-Ce__4GTc.js","assets/input-BleRUV2A.js","assets/font-provider-CtpKPVa7.js","assets/theme-provider-CyJJNAox.js","assets/theme-switch-B3Qb32n8.js","assets/dropdown-menu-8-hEBtzr.js","assets/check-CHp0nSkR.js","assets/header-D45l3Ai7.js","assets/link-BYG8nKNO.js","assets/ClientOnly-fFIveJVd.js","assets/forbidden-DZPSchCU.js","assets/general-error-BoIfl_4Z.js","assets/maintenance-error-CuCw7L8t.js","assets/not-found-error-CHHM1I1c.js","assets/unauthorized-error-B6bKLdq-.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-iygd3-DM.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-BSLzv73v.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
+1
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/_trade_id-Busmu9hU.js","assets/chunk-DECur_0Z.js","assets/button-BgMnOYKD.js","assets/clsx-D9aGYY3V.js","assets/messages-Bp0bCjzp.js","assets/react-CO2uhaBc.js","assets/select-C9s05In_.js","assets/dist-BSXD7MjW.js","assets/dist-CKFLmh1A.js","assets/dist-B0ikDpJU.js","assets/dist-BFnxq45V.js","assets/dist-DGDEBCW9.js","assets/dist-CRowTfGU.js","assets/dist-C6i4A_Js.js","assets/dist-C97YBjnT.js","assets/dist-_ND6L-P3.js","assets/es2015-D8VLlhse.js","assets/dist-BixeH3nX.js","assets/dist-Bmn8KNt7.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-CHp0nSkR.js","assets/chevron-down-BB5h1CsX.js","assets/auth-store-Csn6HPxJ.js","assets/dist-BGqHIBUe.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-DrQ9k883.js","assets/dist-036CEgdV.js","assets/dist-Clua1vrx.js","assets/dist-D7AUoOWu.js","assets/loader-circle-DpEz1fQv.js","assets/send-BNvceEpO.js","assets/triangle-alert-DB5v_9sS.js","assets/dialog-BJ5VA2v_.js","assets/dist-CPQ-sRtL.js","assets/crypto-icon-DsKMU9xz.js","assets/labels-D0HnAPk9.js","assets/input-BleRUV2A.js","assets/checkout-model-CQ3aqlob.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-iygd3-DM.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Busmu9hU.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43])),`component`)});export{r as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Cn as e,Hp as t,Sn as n,Up as r,_n as i,bn as a,em as o,vn as s,xn as c,yn as l}from"./messages-Bp0bCjzp.js";import{i as u,n as d,t as f}from"./button-BgMnOYKD.js";import{n as p,r as m}from"./font-provider-CtpKPVa7.js";import{n as h}from"./theme-provider-CyJJNAox.js";import{t as g}from"./chevron-down-BB5h1CsX.js";import{n as _,t as v}from"./radio-group-DrQ9k883.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-BhKmyN6D.js";import{t as A}from"./page-header-CDwG3nxs.js";import{t as j}from"./show-submitted-data-C8ocsjDF.js";var M=o(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:e,setFont:n}=p(),{theme:o,setTheme:y}=h(),b={theme:o,font:e},A=w({resolver:S(N),defaultValues:b});function P(t){t.font!==e&&n(t.font),t.theme!==o&&y(t.theme),j(t)}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:t()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:i()})]})})}function F(){return(0,M.jsx)(A,{description:n(),title:e(),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{em as n}from"./messages-Bp0bCjzp.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
@@ -1 +1 @@
import{em as e}from"./messages-Bp0bCjzp.js";import{i as t,o as n,r}from"./button-BgMnOYKD.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{em as e}from"./messages-Bp0bCjzp.js";import{i as t}from"./button-BgMnOYKD.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-Csn6HPxJ.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{em as n}from"./messages-Bp0bCjzp.js";import{t as r}from"./dist-BFnxq45V.js";import{i,u as a}from"./button-BgMnOYKD.js";import{a as o,r as s,t as c}from"./dist-CKFLmh1A.js";import{t as l}from"./dist-Clua1vrx.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-DGDEBCW9.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{Fp as e,Ip as t,Lp as n,em as r}from"./messages-Bp0bCjzp.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-BJ5VA2v_.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{Jp as n,Yp as r,em as i}from"./messages-Bp0bCjzp.js";import{c as a,i as o,t as s,u as c}from"./button-BgMnOYKD.js";import{a as l,r as u}from"./dist-CKFLmh1A.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-CPQ-sRtL.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. 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. 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{em as n}from"./messages-Bp0bCjzp.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
@@ -1 +1 @@
import{em as e}from"./messages-Bp0bCjzp.js";import{i as t,t as n}from"./button-BgMnOYKD.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-CPQ-sRtL.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 +1 @@
import{an as e,cn as t,dn as n,em as r,fn as i,gn as a,hn as o,ln as s,mn as c,on as l,pn as u,sn as d,un as f}from"./messages-Bp0bCjzp.js";import{t as p}from"./button-BgMnOYKD.js";import{t as m}from"./checkbox-CKrZFk1G.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-BhKmyN6D.js";import{t as D}from"./page-header-CDwG3nxs.js";import{t as O}from"./show-submitted-data-C8ocsjDF.js";var k=r(),A=[{id:`recents`,label:c()},{id:`home`,label:u()},{id:`applications`,label:i()},{id:`desktop`,label:n()},{id:`downloads`,label:f()},{id:`documents`,label:s()}],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:d()}),(0,k.jsx)(w,{children:l()})]}),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:o(),title:a(),variant:`section`,children:(0,k.jsx)(N,{})})}var F=P;export{F as component}; 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};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{n}from"./dist-CKFLmh1A.js";var r=e(t(),1);function i(e){let[t,i]=r.useState(void 0);return n(()=>{if(e){i({width:e.offsetWidth,height:e.offsetHeight});let t=new ResizeObserver(t=>{if(!Array.isArray(t)||!t.length)return;let n=t[0],r,a;if(`borderBoxSize`in n){let e=n.borderBoxSize,t=Array.isArray(e)?e[0]:e;r=t.inlineSize,a=t.blockSize}else r=e.offsetWidth,a=e.offsetHeight;i({width:r,height:a})});return t.observe(e,{box:`border-box`}),()=>t.unobserve(e)}else i(void 0)},[e]),t}export{i as t}; import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{n}from"./dist-DmeeHi7K.js";var r=e(t(),1);function i(e){let[t,i]=r.useState(void 0);return n(()=>{if(e){i({width:e.offsetWidth,height:e.offsetHeight});let t=new ResizeObserver(t=>{if(!Array.isArray(t)||!t.length)return;let n=t[0],r,a;if(`borderBoxSize`in n){let e=n.borderBoxSize,t=Array.isArray(e)?e[0]:e;r=t.inlineSize,a=t.blockSize}else r=e.offsetWidth,a=e.offsetHeight;i({width:r,height:a})});return t.observe(e,{box:`border-box`}),()=>t.unobserve(e)}else i(void 0)},[e]),t}export{i as t};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{u as n}from"./button-BgMnOYKD.js";import{n as r}from"./dist-CKFLmh1A.js";var i=e(t(),1);function a(e,t){return i.useReducer((e,n)=>t[e][n]??e,e)}var o=e=>{let{present:t,children:r}=e,a=s(t),o=typeof r==`function`?r({present:a.isPresent}):i.Children.only(r),c=n(a.ref,l(o));return typeof r==`function`||a.isPresent?i.cloneElement(o,{ref:c}):null};o.displayName=`Presence`;function s(e){let[t,n]=i.useState(),o=i.useRef(null),s=i.useRef(e),l=i.useRef(`none`),[u,d]=a(e?`mounted`:`unmounted`,{mounted:{UNMOUNT:`unmounted`,ANIMATION_OUT:`unmountSuspended`},unmountSuspended:{MOUNT:`mounted`,ANIMATION_END:`unmounted`},unmounted:{MOUNT:`mounted`}});return i.useEffect(()=>{let e=c(o.current);l.current=u===`mounted`?e:`none`},[u]),r(()=>{let t=o.current,n=s.current;if(n!==e){let r=l.current,i=c(t);e?d(`MOUNT`):i===`none`||t?.display===`none`?d(`UNMOUNT`):d(n&&r!==i?`ANIMATION_OUT`:`UNMOUNT`),s.current=e}},[e,d]),r(()=>{if(t){let e,n=t.ownerDocument.defaultView??window,r=r=>{let i=c(o.current).includes(CSS.escape(r.animationName));if(r.target===t&&i&&(d(`ANIMATION_END`),!s.current)){let r=t.style.animationFillMode;t.style.animationFillMode=`forwards`,e=n.setTimeout(()=>{t.style.animationFillMode===`forwards`&&(t.style.animationFillMode=r)})}},i=e=>{e.target===t&&(l.current=c(o.current))};return t.addEventListener(`animationstart`,i),t.addEventListener(`animationcancel`,r),t.addEventListener(`animationend`,r),()=>{n.clearTimeout(e),t.removeEventListener(`animationstart`,i),t.removeEventListener(`animationcancel`,r),t.removeEventListener(`animationend`,r)}}else d(`ANIMATION_END`)},[t,d]),{isPresent:[`mounted`,`unmountSuspended`].includes(u),ref:i.useCallback(e=>{o.current=e?getComputedStyle(e):null,n(e)},[])}}function c(e){return e?.animationName||`none`}function l(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}export{o as t}; import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{u as n}from"./button-NLSeBFht.js";import{n as r}from"./dist-DmeeHi7K.js";var i=e(t(),1);function a(e,t){return i.useReducer((e,n)=>t[e][n]??e,e)}var o=e=>{let{present:t,children:r}=e,a=s(t),o=typeof r==`function`?r({present:a.isPresent}):i.Children.only(r),c=n(a.ref,l(o));return typeof r==`function`||a.isPresent?i.cloneElement(o,{ref:c}):null};o.displayName=`Presence`;function s(e){let[t,n]=i.useState(),o=i.useRef(null),s=i.useRef(e),l=i.useRef(`none`),[u,d]=a(e?`mounted`:`unmounted`,{mounted:{UNMOUNT:`unmounted`,ANIMATION_OUT:`unmountSuspended`},unmountSuspended:{MOUNT:`mounted`,ANIMATION_END:`unmounted`},unmounted:{MOUNT:`mounted`}});return i.useEffect(()=>{let e=c(o.current);l.current=u===`mounted`?e:`none`},[u]),r(()=>{let t=o.current,n=s.current;if(n!==e){let r=l.current,i=c(t);e?d(`MOUNT`):i===`none`||t?.display===`none`?d(`UNMOUNT`):d(n&&r!==i?`ANIMATION_OUT`:`UNMOUNT`),s.current=e}},[e,d]),r(()=>{if(t){let e,n=t.ownerDocument.defaultView??window,r=r=>{let i=c(o.current).includes(CSS.escape(r.animationName));if(r.target===t&&i&&(d(`ANIMATION_END`),!s.current)){let r=t.style.animationFillMode;t.style.animationFillMode=`forwards`,e=n.setTimeout(()=>{t.style.animationFillMode===`forwards`&&(t.style.animationFillMode=r)})}},i=e=>{e.target===t&&(l.current=c(o.current))};return t.addEventListener(`animationstart`,i),t.addEventListener(`animationcancel`,r),t.addEventListener(`animationend`,r),()=>{n.clearTimeout(e),t.removeEventListener(`animationstart`,i),t.removeEventListener(`animationcancel`,r),t.removeEventListener(`animationend`,r)}}else d(`ANIMATION_END`)},[t,d]),{isPresent:[`mounted`,`unmountSuspended`].includes(u),ref:i.useCallback(e=>{o.current=e?getComputedStyle(e):null,n(e)},[])}}function c(e){return e?.animationName||`none`}function l(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}export{o as t};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{em as n}from"./messages-Bp0bCjzp.js";var r=e(t(),1),i=n(),a=r.createContext(void 0),o=e=>{let{dir:t,children:n}=e;return(0,i.jsx)(a.Provider,{value:t,children:n})};function s(e){let t=r.useContext(a);return e||t||`ltr`}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=r.createContext(void 0),o=e=>{let{dir:t,children:n}=e;return(0,i.jsx)(a.Provider,{value:t,children:n})};function s(e){let t=r.useContext(a);return e||t||`ltr`}export{s as n,o as t};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{em as n}from"./messages-Bp0bCjzp.js";import{t as r}from"./dist-BFnxq45V.js";var i=e(t(),1),a=n(),o=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),s=`VisuallyHidden`,c=i.forwardRef((e,t)=>(0,a.jsx)(r.span,{...e,ref:t,style:{...o,...e.style}}));c.displayName=s;var l=c;export{o as n,l 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";var i=e(t(),1),a=n(),o=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),s=`VisuallyHidden`,c=i.forwardRef((e,t)=>(0,a.jsx)(r.span,{...e,ref:t,style:{...o,...e.style}}));c.displayName=s;var l=c;export{o as n,l as t};
@@ -1 +1 @@
import{r as e,t}from"./chunk-DECur_0Z.js";import{t as n}from"./react-CO2uhaBc.js";import{em as r}from"./messages-Bp0bCjzp.js";import{s as i}from"./button-BgMnOYKD.js";var a=t((e=>{var t=n();function r(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function i(){}var a={d:{f:i,r:function(){throw Error(r(522))},D:i,C:i,L:i,m:i,X:i,S:i,M:i},p:0,findDOMNode:null},o=Symbol.for(`react.portal`);function s(e,t,n){var r=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:o,key:r==null?null:``+r,children:e,containerInfo:t,implementation:n}}var c=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function l(e,t){if(e===`font`)return``;if(typeof t==`string`)return t===`use-credentials`?t:``}e.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=a,e.createPortal=function(e,t){var n=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)throw Error(r(299));return s(e,t,null,n)},e.flushSync=function(e){var t=c.T,n=a.p;try{if(c.T=null,a.p=2,e)return e()}finally{c.T=t,a.p=n,a.d.f()}},e.preconnect=function(e,t){typeof e==`string`&&(t?(t=t.crossOrigin,t=typeof t==`string`?t===`use-credentials`?t:``:void 0):t=null,a.d.C(e,t))},e.prefetchDNS=function(e){typeof e==`string`&&a.d.D(e)},e.preinit=function(e,t){if(typeof e==`string`&&t&&typeof t.as==`string`){var n=t.as,r=l(n,t.crossOrigin),i=typeof t.integrity==`string`?t.integrity:void 0,o=typeof t.fetchPriority==`string`?t.fetchPriority:void 0;n===`style`?a.d.S(e,typeof t.precedence==`string`?t.precedence:void 0,{crossOrigin:r,integrity:i,fetchPriority:o}):n===`script`&&a.d.X(e,{crossOrigin:r,integrity:i,fetchPriority:o,nonce:typeof t.nonce==`string`?t.nonce:void 0})}},e.preinitModule=function(e,t){if(typeof e==`string`)if(typeof t==`object`&&t){if(t.as==null||t.as===`script`){var n=l(t.as,t.crossOrigin);a.d.M(e,{crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0})}}else t??a.d.M(e)},e.preload=function(e,t){if(typeof e==`string`&&typeof t==`object`&&t&&typeof t.as==`string`){var n=t.as,r=l(n,t.crossOrigin);a.d.L(e,n,{crossOrigin:r,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,type:typeof t.type==`string`?t.type:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0,referrerPolicy:typeof t.referrerPolicy==`string`?t.referrerPolicy:void 0,imageSrcSet:typeof t.imageSrcSet==`string`?t.imageSrcSet:void 0,imageSizes:typeof t.imageSizes==`string`?t.imageSizes:void 0,media:typeof t.media==`string`?t.media:void 0})}},e.preloadModule=function(e,t){if(typeof e==`string`)if(t){var n=l(t.as,t.crossOrigin);a.d.m(e,{as:typeof t.as==`string`&&t.as!==`script`?t.as:void 0,crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0})}else a.d.m(e)},e.requestFormReset=function(e){a.d.r(e)},e.unstable_batchedUpdates=function(e,t){return e(t)},e.useFormState=function(e,t,n){return c.H.useFormState(e,t,n)},e.useFormStatus=function(){return c.H.useHostTransitionStatus()},e.version=`19.2.5`})),o=t(((e,t)=>{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=a()})),s=e(n(),1),c=e(o(),1),l=r(),u=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=i(`Primitive.${t}`),r=s.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,l.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{});function d(e,t){e&&c.flushSync(()=>e.dispatchEvent(t))}export{d as n,o as r,u as t}; import{r as e,t}from"./chunk-DECur_0Z.js";import{t as n}from"./react-CO2uhaBc.js";import{tm as r}from"./messages-CL4J6BaJ.js";import{s as i}from"./button-NLSeBFht.js";var a=t((e=>{var t=n();function r(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function i(){}var a={d:{f:i,r:function(){throw Error(r(522))},D:i,C:i,L:i,m:i,X:i,S:i,M:i},p:0,findDOMNode:null},o=Symbol.for(`react.portal`);function s(e,t,n){var r=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:o,key:r==null?null:``+r,children:e,containerInfo:t,implementation:n}}var c=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function l(e,t){if(e===`font`)return``;if(typeof t==`string`)return t===`use-credentials`?t:``}e.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=a,e.createPortal=function(e,t){var n=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)throw Error(r(299));return s(e,t,null,n)},e.flushSync=function(e){var t=c.T,n=a.p;try{if(c.T=null,a.p=2,e)return e()}finally{c.T=t,a.p=n,a.d.f()}},e.preconnect=function(e,t){typeof e==`string`&&(t?(t=t.crossOrigin,t=typeof t==`string`?t===`use-credentials`?t:``:void 0):t=null,a.d.C(e,t))},e.prefetchDNS=function(e){typeof e==`string`&&a.d.D(e)},e.preinit=function(e,t){if(typeof e==`string`&&t&&typeof t.as==`string`){var n=t.as,r=l(n,t.crossOrigin),i=typeof t.integrity==`string`?t.integrity:void 0,o=typeof t.fetchPriority==`string`?t.fetchPriority:void 0;n===`style`?a.d.S(e,typeof t.precedence==`string`?t.precedence:void 0,{crossOrigin:r,integrity:i,fetchPriority:o}):n===`script`&&a.d.X(e,{crossOrigin:r,integrity:i,fetchPriority:o,nonce:typeof t.nonce==`string`?t.nonce:void 0})}},e.preinitModule=function(e,t){if(typeof e==`string`)if(typeof t==`object`&&t){if(t.as==null||t.as===`script`){var n=l(t.as,t.crossOrigin);a.d.M(e,{crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0})}}else t??a.d.M(e)},e.preload=function(e,t){if(typeof e==`string`&&typeof t==`object`&&t&&typeof t.as==`string`){var n=t.as,r=l(n,t.crossOrigin);a.d.L(e,n,{crossOrigin:r,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,type:typeof t.type==`string`?t.type:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0,referrerPolicy:typeof t.referrerPolicy==`string`?t.referrerPolicy:void 0,imageSrcSet:typeof t.imageSrcSet==`string`?t.imageSrcSet:void 0,imageSizes:typeof t.imageSizes==`string`?t.imageSizes:void 0,media:typeof t.media==`string`?t.media:void 0})}},e.preloadModule=function(e,t){if(typeof e==`string`)if(t){var n=l(t.as,t.crossOrigin);a.d.m(e,{as:typeof t.as==`string`&&t.as!==`script`?t.as:void 0,crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0})}else a.d.m(e)},e.requestFormReset=function(e){a.d.r(e)},e.unstable_batchedUpdates=function(e,t){return e(t)},e.useFormState=function(e,t,n){return c.H.useFormState(e,t,n)},e.useFormStatus=function(){return c.H.useHostTransitionStatus()},e.version=`19.2.5`})),o=t(((e,t)=>{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=a()})),s=e(n(),1),c=e(o(),1),l=r(),u=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=i(`Primitive.${t}`),r=s.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,l.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{});function d(e,t){e&&c.flushSync(()=>e.dispatchEvent(t))}export{d as n,o as r,u as t};
@@ -1 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{em as n}from"./messages-Bp0bCjzp.js";import{t as r}from"./dist-BFnxq45V.js";import{u as i}from"./button-BgMnOYKD.js";import{a,r as o,t as s}from"./dist-CKFLmh1A.js";import{t as c}from"./dist-Clua1vrx.js";import{n as l}from"./dist-C97YBjnT.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-DGDEBCW9.js";import{n as f,r as p,t as m}from"./dist-D7AUoOWu.js";var h=e(t(),1),g=n(),_=`Radio`,[v,y]=a(_),[b,x]=v(_),S=h.forwardRef((e,t)=>{let{__scopeRadio:n,name:a,checked:s=!1,required:c,disabled:l,value:u=`on`,onCheck:d,form:f,...p}=e,[m,_]=h.useState(null),v=i(t,e=>_(e)),y=h.useRef(!1),x=m?f||!!m.closest(`form`):!0;return(0,g.jsxs)(b,{scope:n,checked:s,disabled:l,children:[(0,g.jsx)(r.button,{type:`button`,role:`radio`,"aria-checked":s,"data-state":D(s),"data-disabled":l?``:void 0,disabled:l,value:u,...p,ref:v,onClick:o(e.onClick,e=>{s||d?.(),x&&(y.current=e.isPropagationStopped(),y.current||e.stopPropagation())})}),x&&(0,g.jsx)(E,{control:m,bubbles:!y.current,name:a,value:u,checked:s,required:c,disabled:l,form:f,style:{transform:`translateX(-100%)`}})]})});S.displayName=_;var C=`RadioIndicator`,w=h.forwardRef((e,t)=>{let{__scopeRadio:n,forceMount:i,...a}=e,o=x(C,n);return(0,g.jsx)(c,{present:i||o.checked,children:(0,g.jsx)(r.span,{"data-state":D(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t})})});w.displayName=C;var T=`RadioBubbleInput`,E=h.forwardRef(({__scopeRadio:e,control:t,checked:n,bubbles:a=!0,...o},s)=>{let c=h.useRef(null),l=i(c,s),f=u(n),p=d(t);return h.useEffect(()=>{let e=c.current;if(!e)return;let t=window.HTMLInputElement.prototype,r=Object.getOwnPropertyDescriptor(t,`checked`).set;if(f!==n&&r){let t=new Event(`click`,{bubbles:a});r.call(e,n),e.dispatchEvent(t)}},[f,n,a]),(0,g.jsx)(r.input,{type:`radio`,"aria-hidden":!0,defaultChecked:n,...o,tabIndex:-1,ref:l,style:{...o.style,...p,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0}})});E.displayName=T;function D(e){return e?`checked`:`unchecked`}var O=[`ArrowUp`,`ArrowDown`,`ArrowLeft`,`ArrowRight`],k=`RadioGroup`,[A,j]=a(k,[p,y]),M=p(),N=y(),[P,F]=A(k),I=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,name:i,defaultValue:a,value:o,required:c=!1,disabled:u=!1,orientation:d,dir:p,loop:m=!0,onValueChange:h,..._}=e,v=M(n),y=l(p),[b,x]=s({prop:o,defaultProp:a??null,onChange:h,caller:k});return(0,g.jsx)(P,{scope:n,name:i,required:c,disabled:u,value:b,onValueChange:x,children:(0,g.jsx)(f,{asChild:!0,...v,orientation:d,dir:y,loop:m,children:(0,g.jsx)(r.div,{role:`radiogroup`,"aria-required":c,"aria-orientation":d,"data-disabled":u?``:void 0,dir:y,..._,ref:t})})})});I.displayName=k;var L=`RadioGroupItem`,R=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,disabled:r,...a}=e,s=F(L,n),c=s.disabled||r,l=M(n),u=N(n),d=h.useRef(null),f=i(t,d),p=s.value===a.value,_=h.useRef(!1);return h.useEffect(()=>{let e=e=>{O.includes(e.key)&&(_.current=!0)},t=()=>_.current=!1;return document.addEventListener(`keydown`,e),document.addEventListener(`keyup`,t),()=>{document.removeEventListener(`keydown`,e),document.removeEventListener(`keyup`,t)}},[]),(0,g.jsx)(m,{asChild:!0,...l,focusable:!c,active:p,children:(0,g.jsx)(S,{disabled:c,required:s.required,checked:p,...u,...a,name:s.name,ref:f,onCheck:()=>s.onValueChange(a.value),onKeyDown:o(e=>{e.key===`Enter`&&e.preventDefault()}),onFocus:o(a.onFocus,()=>{_.current&&d.current?.click()})})})});R.displayName=L;var z=`RadioGroupIndicator`,B=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,...r}=e;return(0,g.jsx)(w,{...N(n),...r,ref:t})});B.displayName=z;var V=I,H=R,U=B;export{H as n,V as r,U 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{u as i}from"./button-NLSeBFht.js";import{a,r as o,t as s}from"./dist-DmeeHi7K.js";import{t as c}from"./dist-BZMqLvO-.js";import{n as l}from"./dist-CGRahgI2.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-B0pRVbY9.js";import{n as f,r as p,t as m}from"./dist-DhcQhr4B.js";var h=e(t(),1),g=n(),_=`Radio`,[v,y]=a(_),[b,x]=v(_),S=h.forwardRef((e,t)=>{let{__scopeRadio:n,name:a,checked:s=!1,required:c,disabled:l,value:u=`on`,onCheck:d,form:f,...p}=e,[m,_]=h.useState(null),v=i(t,e=>_(e)),y=h.useRef(!1),x=m?f||!!m.closest(`form`):!0;return(0,g.jsxs)(b,{scope:n,checked:s,disabled:l,children:[(0,g.jsx)(r.button,{type:`button`,role:`radio`,"aria-checked":s,"data-state":D(s),"data-disabled":l?``:void 0,disabled:l,value:u,...p,ref:v,onClick:o(e.onClick,e=>{s||d?.(),x&&(y.current=e.isPropagationStopped(),y.current||e.stopPropagation())})}),x&&(0,g.jsx)(E,{control:m,bubbles:!y.current,name:a,value:u,checked:s,required:c,disabled:l,form:f,style:{transform:`translateX(-100%)`}})]})});S.displayName=_;var C=`RadioIndicator`,w=h.forwardRef((e,t)=>{let{__scopeRadio:n,forceMount:i,...a}=e,o=x(C,n);return(0,g.jsx)(c,{present:i||o.checked,children:(0,g.jsx)(r.span,{"data-state":D(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t})})});w.displayName=C;var T=`RadioBubbleInput`,E=h.forwardRef(({__scopeRadio:e,control:t,checked:n,bubbles:a=!0,...o},s)=>{let c=h.useRef(null),l=i(c,s),f=u(n),p=d(t);return h.useEffect(()=>{let e=c.current;if(!e)return;let t=window.HTMLInputElement.prototype,r=Object.getOwnPropertyDescriptor(t,`checked`).set;if(f!==n&&r){let t=new Event(`click`,{bubbles:a});r.call(e,n),e.dispatchEvent(t)}},[f,n,a]),(0,g.jsx)(r.input,{type:`radio`,"aria-hidden":!0,defaultChecked:n,...o,tabIndex:-1,ref:l,style:{...o.style,...p,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0}})});E.displayName=T;function D(e){return e?`checked`:`unchecked`}var O=[`ArrowUp`,`ArrowDown`,`ArrowLeft`,`ArrowRight`],k=`RadioGroup`,[A,j]=a(k,[p,y]),M=p(),N=y(),[P,F]=A(k),I=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,name:i,defaultValue:a,value:o,required:c=!1,disabled:u=!1,orientation:d,dir:p,loop:m=!0,onValueChange:h,..._}=e,v=M(n),y=l(p),[b,x]=s({prop:o,defaultProp:a??null,onChange:h,caller:k});return(0,g.jsx)(P,{scope:n,name:i,required:c,disabled:u,value:b,onValueChange:x,children:(0,g.jsx)(f,{asChild:!0,...v,orientation:d,dir:y,loop:m,children:(0,g.jsx)(r.div,{role:`radiogroup`,"aria-required":c,"aria-orientation":d,"data-disabled":u?``:void 0,dir:y,..._,ref:t})})})});I.displayName=k;var L=`RadioGroupItem`,R=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,disabled:r,...a}=e,s=F(L,n),c=s.disabled||r,l=M(n),u=N(n),d=h.useRef(null),f=i(t,d),p=s.value===a.value,_=h.useRef(!1);return h.useEffect(()=>{let e=e=>{O.includes(e.key)&&(_.current=!0)},t=()=>_.current=!1;return document.addEventListener(`keydown`,e),document.addEventListener(`keyup`,t),()=>{document.removeEventListener(`keydown`,e),document.removeEventListener(`keyup`,t)}},[]),(0,g.jsx)(m,{asChild:!0,...l,focusable:!c,active:p,children:(0,g.jsx)(S,{disabled:c,required:s.required,checked:p,...u,...a,name:s.name,ref:f,onCheck:()=>s.onValueChange(a.value),onKeyDown:o(e=>{e.key===`Enter`&&e.preventDefault()}),onFocus:o(a.onFocus,()=>{_.current&&d.current?.click()})})})});R.displayName=L;var z=`RadioGroupIndicator`,B=h.forwardRef((e,t)=>{let{__scopeRadioGroup:n,...r}=e;return(0,g.jsx)(w,{...N(n),...r,ref:t})});B.displayName=z;var V=I,H=R,U=B;export{H as n,V as r,U as t};
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{em as n}from"./messages-Bp0bCjzp.js";import{t as r}from"./dist-BFnxq45V.js";import{s as i,u as a}from"./button-BgMnOYKD.js";import{a as o,i as s,r as c,t as l}from"./dist-CKFLmh1A.js";import{t as u}from"./dist-Clua1vrx.js";import{n as d}from"./dist-B0ikDpJU.js";import{n as f,t as p}from"./dist-_ND6L-P3.js";import{i as m,n as h,r as g,t as _}from"./es2015-D8VLlhse.js";var v=e(t(),1),y=n(),b=`Dialog`,[x,S]=o(b),[C,w]=x(b),T=e=>{let{__scopeDialog:t,children:n,open:r,defaultOpen:i,onOpenChange:a,modal:o=!0}=e,s=v.useRef(null),c=v.useRef(null),[u,f]=l({prop:r,defaultProp:i??!1,onChange:a,caller:b});return(0,y.jsx)(C,{scope:t,triggerRef:s,contentRef:c,contentId:d(),titleId:d(),descriptionId:d(),open:u,onOpenChange:f,onOpenToggle:v.useCallback(()=>f(e=>!e),[f]),modal:o,children:n})};T.displayName=b;var E=`DialogTrigger`,D=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,o=w(E,n),s=a(t,o.triggerRef);return(0,y.jsx)(r.button,{type:`button`,"aria-haspopup":`dialog`,"aria-expanded":o.open,"aria-controls":o.contentId,"data-state":q(o.open),...i,ref:s,onClick:c(e.onClick,o.onOpenToggle)})});D.displayName=E;var O=`DialogPortal`,[k,A]=x(O,{forceMount:void 0}),j=e=>{let{__scopeDialog:t,forceMount:n,children:r,container:i}=e,a=w(O,t);return(0,y.jsx)(k,{scope:t,forceMount:n,children:v.Children.map(r,e=>(0,y.jsx)(u,{present:n||a.open,children:(0,y.jsx)(p,{asChild:!0,container:i,children:e})}))})};j.displayName=O;var M=`DialogOverlay`,N=v.forwardRef((e,t)=>{let n=A(M,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=w(M,e.__scopeDialog);return a.modal?(0,y.jsx)(u,{present:r||a.open,children:(0,y.jsx)(F,{...i,ref:t})}):null});N.displayName=M;var P=i(`DialogOverlay.RemoveScroll`),F=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(M,n);return(0,y.jsx)(h,{as:P,allowPinchZoom:!0,shards:[a.contentRef],children:(0,y.jsx)(r.div,{"data-state":q(a.open),...i,ref:t,style:{pointerEvents:`auto`,...i.style}})})}),I=`DialogContent`,L=v.forwardRef((e,t)=>{let n=A(I,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=w(I,e.__scopeDialog);return(0,y.jsx)(u,{present:r||a.open,children:a.modal?(0,y.jsx)(R,{...i,ref:t}):(0,y.jsx)(z,{...i,ref:t})})});L.displayName=I;var R=v.forwardRef((e,t)=>{let n=w(I,e.__scopeDialog),r=v.useRef(null),i=a(t,n.contentRef,r);return v.useEffect(()=>{let e=r.current;if(e)return _(e)},[]),(0,y.jsx)(B,{...e,ref:i,trapFocus:n.open,disableOutsidePointerEvents:!0,onCloseAutoFocus:c(e.onCloseAutoFocus,e=>{e.preventDefault(),n.triggerRef.current?.focus()}),onPointerDownOutside:c(e.onPointerDownOutside,e=>{let t=e.detail.originalEvent,n=t.button===0&&t.ctrlKey===!0;(t.button===2||n)&&e.preventDefault()}),onFocusOutside:c(e.onFocusOutside,e=>e.preventDefault())})}),z=v.forwardRef((e,t)=>{let n=w(I,e.__scopeDialog),r=v.useRef(!1),i=v.useRef(!1);return(0,y.jsx)(B,{...e,ref:t,trapFocus:!1,disableOutsidePointerEvents:!1,onCloseAutoFocus:t=>{e.onCloseAutoFocus?.(t),t.defaultPrevented||(r.current||n.triggerRef.current?.focus(),t.preventDefault()),r.current=!1,i.current=!1},onInteractOutside:t=>{e.onInteractOutside?.(t),t.defaultPrevented||(r.current=!0,t.detail.originalEvent.type===`pointerdown`&&(i.current=!0));let a=t.target;n.triggerRef.current?.contains(a)&&t.preventDefault(),t.detail.originalEvent.type===`focusin`&&i.current&&t.preventDefault()}})}),B=v.forwardRef((e,t)=>{let{__scopeDialog:n,trapFocus:r,onOpenAutoFocus:i,onCloseAutoFocus:o,...s}=e,c=w(I,n),l=v.useRef(null),u=a(t,l);return g(),(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(m,{asChild:!0,loop:!0,trapped:r,onMountAutoFocus:i,onUnmountAutoFocus:o,children:(0,y.jsx)(f,{role:`dialog`,id:c.contentId,"aria-describedby":c.descriptionId,"aria-labelledby":c.titleId,"data-state":q(c.open),...s,ref:u,onDismiss:()=>c.onOpenChange(!1)})}),(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(Z,{titleId:c.titleId}),(0,y.jsx)($,{contentRef:l,descriptionId:c.descriptionId})]})]})}),V=`DialogTitle`,H=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(V,n);return(0,y.jsx)(r.h2,{id:a.titleId,...i,ref:t})});H.displayName=V;var U=`DialogDescription`,W=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(U,n);return(0,y.jsx)(r.p,{id:a.descriptionId,...i,ref:t})});W.displayName=U;var G=`DialogClose`,K=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(G,n);return(0,y.jsx)(r.button,{type:`button`,...i,ref:t,onClick:c(e.onClick,()=>a.onOpenChange(!1))})});K.displayName=G;function q(e){return e?`open`:`closed`}var J=`DialogTitleWarning`,[Y,X]=s(J,{contentName:I,titleName:V,docsSlug:`dialog`}),Z=({titleId:e})=>{let t=X(J),n=`\`${t.contentName}\` requires a \`${t.titleName}\` 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{tm as n}from"./messages-CL4J6BaJ.js";import{t as r}from"./dist-D3WFT-Yo.js";import{s as i,u as a}from"./button-NLSeBFht.js";import{a as o,i as s,r as c,t as l}from"./dist-DmeeHi7K.js";import{t as u}from"./dist-BZMqLvO-.js";import{n as d}from"./dist-SR6sl-WU.js";import{n as f,t as p}from"./dist-esC74fSg.js";import{i as m,n as h,r as g,t as _}from"./es2015-3J9GnV9P.js";var v=e(t(),1),y=n(),b=`Dialog`,[x,S]=o(b),[C,w]=x(b),T=e=>{let{__scopeDialog:t,children:n,open:r,defaultOpen:i,onOpenChange:a,modal:o=!0}=e,s=v.useRef(null),c=v.useRef(null),[u,f]=l({prop:r,defaultProp:i??!1,onChange:a,caller:b});return(0,y.jsx)(C,{scope:t,triggerRef:s,contentRef:c,contentId:d(),titleId:d(),descriptionId:d(),open:u,onOpenChange:f,onOpenToggle:v.useCallback(()=>f(e=>!e),[f]),modal:o,children:n})};T.displayName=b;var E=`DialogTrigger`,D=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,o=w(E,n),s=a(t,o.triggerRef);return(0,y.jsx)(r.button,{type:`button`,"aria-haspopup":`dialog`,"aria-expanded":o.open,"aria-controls":o.contentId,"data-state":q(o.open),...i,ref:s,onClick:c(e.onClick,o.onOpenToggle)})});D.displayName=E;var O=`DialogPortal`,[k,A]=x(O,{forceMount:void 0}),j=e=>{let{__scopeDialog:t,forceMount:n,children:r,container:i}=e,a=w(O,t);return(0,y.jsx)(k,{scope:t,forceMount:n,children:v.Children.map(r,e=>(0,y.jsx)(u,{present:n||a.open,children:(0,y.jsx)(p,{asChild:!0,container:i,children:e})}))})};j.displayName=O;var M=`DialogOverlay`,N=v.forwardRef((e,t)=>{let n=A(M,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=w(M,e.__scopeDialog);return a.modal?(0,y.jsx)(u,{present:r||a.open,children:(0,y.jsx)(F,{...i,ref:t})}):null});N.displayName=M;var P=i(`DialogOverlay.RemoveScroll`),F=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(M,n);return(0,y.jsx)(h,{as:P,allowPinchZoom:!0,shards:[a.contentRef],children:(0,y.jsx)(r.div,{"data-state":q(a.open),...i,ref:t,style:{pointerEvents:`auto`,...i.style}})})}),I=`DialogContent`,L=v.forwardRef((e,t)=>{let n=A(I,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=w(I,e.__scopeDialog);return(0,y.jsx)(u,{present:r||a.open,children:a.modal?(0,y.jsx)(R,{...i,ref:t}):(0,y.jsx)(z,{...i,ref:t})})});L.displayName=I;var R=v.forwardRef((e,t)=>{let n=w(I,e.__scopeDialog),r=v.useRef(null),i=a(t,n.contentRef,r);return v.useEffect(()=>{let e=r.current;if(e)return _(e)},[]),(0,y.jsx)(B,{...e,ref:i,trapFocus:n.open,disableOutsidePointerEvents:!0,onCloseAutoFocus:c(e.onCloseAutoFocus,e=>{e.preventDefault(),n.triggerRef.current?.focus()}),onPointerDownOutside:c(e.onPointerDownOutside,e=>{let t=e.detail.originalEvent,n=t.button===0&&t.ctrlKey===!0;(t.button===2||n)&&e.preventDefault()}),onFocusOutside:c(e.onFocusOutside,e=>e.preventDefault())})}),z=v.forwardRef((e,t)=>{let n=w(I,e.__scopeDialog),r=v.useRef(!1),i=v.useRef(!1);return(0,y.jsx)(B,{...e,ref:t,trapFocus:!1,disableOutsidePointerEvents:!1,onCloseAutoFocus:t=>{e.onCloseAutoFocus?.(t),t.defaultPrevented||(r.current||n.triggerRef.current?.focus(),t.preventDefault()),r.current=!1,i.current=!1},onInteractOutside:t=>{e.onInteractOutside?.(t),t.defaultPrevented||(r.current=!0,t.detail.originalEvent.type===`pointerdown`&&(i.current=!0));let a=t.target;n.triggerRef.current?.contains(a)&&t.preventDefault(),t.detail.originalEvent.type===`focusin`&&i.current&&t.preventDefault()}})}),B=v.forwardRef((e,t)=>{let{__scopeDialog:n,trapFocus:r,onOpenAutoFocus:i,onCloseAutoFocus:o,...s}=e,c=w(I,n),l=v.useRef(null),u=a(t,l);return g(),(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(m,{asChild:!0,loop:!0,trapped:r,onMountAutoFocus:i,onUnmountAutoFocus:o,children:(0,y.jsx)(f,{role:`dialog`,id:c.contentId,"aria-describedby":c.descriptionId,"aria-labelledby":c.titleId,"data-state":q(c.open),...s,ref:u,onDismiss:()=>c.onOpenChange(!1)})}),(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(Z,{titleId:c.titleId}),(0,y.jsx)($,{contentRef:l,descriptionId:c.descriptionId})]})]})}),V=`DialogTitle`,H=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(V,n);return(0,y.jsx)(r.h2,{id:a.titleId,...i,ref:t})});H.displayName=V;var U=`DialogDescription`,W=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(U,n);return(0,y.jsx)(r.p,{id:a.descriptionId,...i,ref:t})});W.displayName=U;var G=`DialogClose`,K=v.forwardRef((e,t)=>{let{__scopeDialog:n,...i}=e,a=w(G,n);return(0,y.jsx)(r.button,{type:`button`,...i,ref:t,onClick:c(e.onClick,()=>a.onOpenChange(!1))})});K.displayName=G;function q(e){return e?`open`:`closed`}var J=`DialogTitleWarning`,[Y,X]=s(J,{contentName:I,titleName:V,docsSlug:`dialog`}),Z=({titleId:e})=>{let t=X(J),n=`\`${t.contentName}\` requires a \`${t.titleName}\` for the component to be accessible for screen reader users.
If you want to hide the \`${t.titleName}\`, you can wrap it with our VisuallyHidden component. If you want to hide the \`${t.titleName}\`, you can wrap it with our VisuallyHidden component.

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